import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; const owner = 'richese', observer = 'emperor', choam = 'choam'; const seat = (game: Game, id: string) => { const player = game.players.find(row => row.id === id); assert.ok(player); return player; }; const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard; function next(game: Game) { // The inherited native helper intentionally leaves Emperor's own move to // callers. This programme completes the original turn without a shipment. if (game.phase === 5 && game.active === observer && clean(game)) return {actor:observer, action:{type:'endMovement'} satisfies Action}; return nextTruthMovementNativeEarlyStep(game, { position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false, }); } function reach(game: Game, until: (state: Game) => boolean) { for (let count = 0; count < 1800; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished', 'Native game ended before the requested window'); const step = next(game); assert.ok(step, `Supply native action at ${game.turn}/${game.phase}/${game.decision?.kind}`); game = act(game, step.actor, step.action); } throw Error('Native No-Field programme did not reach the requested window'); } /** Genuine public Basic Richese/CHOAM/Emperor, both selected native decks. * Select only original unopened starting-card/Spice positions. Real setup, * auction passes, one paid concealed shipment and native phase controls lead * to the next turn. Polar Sink keeps the marker safe from Storm and battles. */ function nativeSource() { let game = createGame('RICHESEINTERRUPT', newPlayer(owner, 'Richese', 'richese'), false, ['choam','ix']); joinGame(game, newPlayer(choam, 'CHOAM', 'choam')); joinGame(game, newPlayer(observer, 'Emperor', 'emperor')); for (const p of game.players) game = act(game, p.id, {type:'ready'}); game = act(game, game.host, {type:'start'}); assert.equal(game.status, 'setup'); assert.deepEqual(game.expansions, ['choam','ix']); assert.ok(game.players.every(p => p.hand.length === 0)); const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 3); assert.equal(ordinary.length, 3); for (const [position, card] of ordinary.entries()) { const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); [game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]]; } game = reach(game, state => state.status === 'playing'); for (const position of [0,1]) { const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); assert.ok(from >= position); [game.spiceDeck[position], game.spiceDeck[from]] = [game.spiceDeck[from], game.spiceDeck[position]]; } game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === owner && clean(state)); const richese = seat(game, owner), token = richese.noField!.tokens.find(item => item.value === 3); assert.ok(token); const shipmentEvent = richese.noFieldEvent; assert.ok(shipmentEvent); const beforeSpice = richese.spice; game = act(game, owner, {type:'ship', noField:token.id, event:shipmentEvent, territory:'polar_sink', sector:0, allyPayment:0}); game = reach(game, clean); assert.equal(seat(game, owner).spice, beforeSpice - 2); assert.equal(seat(game, owner).noField?.deployed?.tokenId, token.id); assert.equal(seat(game, owner).reserves, 20); assert.equal(seat(game, owner).forces['polar_sink:0'] ?? 0, 0); game = act(game, owner, {type:'endMovement'}); game = reach(game, state => state.turn === 2 && state.phase === 0 && !!state.phaseOpening && !state.response && !state.decision && !state.truthtrance); assert.equal(seat(game, owner).noField?.deployed?.tokenId, token.id); const event = seat(game, owner).noFieldEvent; assert.ok(event); return {game, token:token.id, event}; } let saved: ReturnType | undefined; const source = () => saved ??= nativeSource(); const reveal = (game: Game, token: string, event: string) => applyAction(game, owner, {type:'revealNoField', token, event}); function reject(game: Game, id: string, action: Action) { const before = structuredClone(game); assert.throws(() => applyAction(game, id, action)); assert.deepEqual(game, before); } function verifyRevealed(before: Game, after: Game, token: string, event: string) { assert.equal(seat(after, owner).noField?.deployed, null); assert.notEqual(seat(after, owner).noFieldEvent, event); assert.equal(seat(after, owner).noField?.lastShipped, token); assert.equal(seat(after, owner).reserves, seat(before, owner).reserves - 3); assert.equal(seat(after, owner).forces['polar_sink:0'], (seat(before, owner).forces['polar_sink:0'] ?? 0) + 3); assert.equal(seat(after, owner).tanks, seat(before, owner).tanks); assert.equal(seat(after, owner).spice, seat(before, owner).spice); reject(after, owner, {type:'revealNoField', token, event}); } void test('Basic native Ix opening keeps prior passes and resumes after a voluntary marker reveal', () => { const {token,event} = source(); let game = structuredClone(source().game); game = act(game, owner, {type:'ready'}); assert.ok(game.phaseOpening!.passed.includes(owner)); const parent = structuredClone(game.phaseOpening); assert.equal(viewGame(game, owner).richeseNoField?.canReveal, true); reject(game, observer, {type:'revealNoField', token, event}); reject(game, owner, {type:'revealNoField', token, event:'stale'}); reject(game, owner, {type:'revealNoField', token:'wrong-token', event}); const before = structuredClone(game), after = reveal(game, token, event); verifyRevealed(before, after, token, event); assert.deepEqual(after.phaseOpening, parent); assert.equal(after.phase, before.phase); assert.equal(after.turn, before.turn); const continued = reach(after, state => !state.phaseOpening); assert.equal(seat(continued, owner).forces['polar_sink:0'], 3); }); for (const kind of ['choamMarket','richeseDeclaration','richeseCache','richeseUnbid'] as const) void test( `Basic native ${kind} remains intact while Richese reveals, then its original decision continues`, () => { const {token,event} = source(); const before = reach(structuredClone(source().game), state => state.turn === 2 && state.decision?.kind === kind && !state.response && !state.phaseOpening); const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket ?? null), bidding = structuredClone(before.richeseBidding ?? null), auction = structuredClone(before.auction ?? null); assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); const after = reveal(before, token, event); verifyRevealed(before, after, token, event); assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket ?? null, market); assert.deepEqual(after.richeseBidding ?? null, bidding); assert.deepEqual(after.auction ?? null, auction); const step = next(after); assert.ok(step); assert.equal(step.actor, parent!.player); const continued = act(after, step.actor, step.action); assert.notDeepEqual(continued.decision, parent); assert.equal(seat(continued, owner).forces['polar_sink:0'], 3); }, ); /** Reorder only an unopened turn-two Spice card, then form a real CHOAM / * Emperor alliance at its Nexus and initiate their ordinary market trade. */ function nativeTradeReply() { let game = structuredClone(source().game); const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper); assert.ok(worm >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]); game = reach(game, state => state.turn === 2 && state.phase === 1 && !!state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state)); game = act(game, choam, {type:'alliance', target:observer}); game = act(game, observer, {type:'alliance', target:choam}); assert.equal(seat(game, choam).ally, observer); assert.equal(seat(game, observer).ally, choam); game = reach(game, state => state.turn === 2 && state.phase === 1 && state.decision?.kind === 'choamMarket' && !state.response && !state.phaseOpening); const offered = seat(game, choam).hand[0]; assert.ok(offered); const returned = seat(game, observer).hand[0]; assert.ok(returned); game = act(game, choam, {type:'decision', mode:'trade', card:offered.id}); assert.equal(game.decision?.kind, 'choamTradeReply'); assert.equal(game.decision.player, observer); return {game, offered:offered.id, returned:returned.id}; } let tradeFixture: ReturnType | undefined; const tradeSource = () => tradeFixture ??= nativeTradeReply(); void test('Basic CHOAM trade reply retains its genuine alliance, offered card and exact continuation', () => { const {token,event} = source(), {offered,returned} = tradeSource(); const before = structuredClone(tradeSource().game); const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket); assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); const after = reveal(before, token, event); verifyRevealed(before, after, token, event); assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket, market); let continued = act(after, observer, {type:'decision', card:returned}); assert.equal(continued.decision?.kind, 'choamTradeConfirm'); assert.equal(continued.decision.player, choam); continued = act(continued, choam, {type:'decision', accept:true}); assert.equal(continued.decision?.kind, 'choamMarket'); assert.ok(seat(continued, choam).hand.some(card => card.id === returned)); assert.ok(seat(continued, observer).hand.some(card => card.id === offered)); assert.equal(seat(continued, owner).forces['polar_sink:0'], 3); }); void test('Basic CHOAM trade confirmation remains pending through reveal and exchanges the actual cards', () => { const {token,event} = source(), f = tradeSource(); const before = act(structuredClone(f.game), observer, {type:'decision', card:f.returned}); assert.equal(before.decision?.kind, 'choamTradeConfirm'); const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket); assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); const after = reveal(before, token, event); verifyRevealed(before, after, token, event); assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket, market); const continued = act(after, choam, {type:'decision', accept:true}); assert.equal(continued.decision?.kind, 'choamMarket'); assert.ok(seat(continued, choam).hand.some(card => card.id === f.returned)); assert.ok(seat(continued, observer).hand.some(card => card.id === f.offered)); assert.equal(seat(continued, owner).forces['polar_sink:0'], 3); }); void test('all four minimal bot profiles can answer the preserved native Richese economic decision', () => { const {token,event} = source(); const before = reach(structuredClone(source().game), state => state.turn === 2 && state.decision?.kind === 'richeseDeclaration' && !state.response && !state.phaseOpening); const after = reveal(before, token, event); for (const difficulty of DIFFICULTIES) { const view = viewGame(after, owner); view.players.find(player => player.id === owner)!.bot = difficulty; const choice = botActions(view).find(action => action.type === 'decision'); assert.ok(choice, `${difficulty} needs an original legal declaration`); const continued = applyAction(structuredClone(after), owner, choice); assert.notDeepEqual(continued.decision, after.decision); assert.equal(seat(continued, owner).forces['polar_sink:0'], 3); } });