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'; import { truthMovementInventory as inventory } from './fixture-truth-movement'; const seat = (g: Game, id: string) => g.players.find(player => player.id === id)!; const quiet = (g: Game) => !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingTreacheryDiscard; function next(g: Game) { if (g.phase === 5 && g.active === 'emperor' && quiet(g)) return {actor: 'emperor', action: {type:'endMovement'} satisfies Action}; return nextTruthMovementNativeEarlyStep(g, {position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false}); } function reach(g: Game, predicate: (g: Game) => boolean): Game { for (let count = 0; count < 1800; count++) { if (predicate(g)) return g; assert.notEqual(g.status, 'finished'); const step = next(g); assert.ok(step, `No native step at ${g.turn}/${g.phase}/${g.decision?.kind}`); g = act(g, step.actor, step.action); } throw Error('The native game did not reach its response.'); } /** Original Basic setup, actual paid first-turn No-Field shipment, then the * following Movement phase. Only unopened starting-card and Spice positions * are controlled, preserving every original physical card. */ function source() { let g = createGame('RICHESEORDINARY', newPlayer('richese', 'Richese', 'richese'), false, ['choam','ix']); for (const id of ['choam','atreides','emperor'] as const) joinGame(g, newPlayer(id, id, id)); for (const p of g.players) g = act(g, p.id, {type:'ready'}); g = act(g, g.host, {type:'start'}); const ordinary = g.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 3); const karama = g.deck.find(card => card.effect === 'karama'); assert.equal(ordinary.length, 3); assert.ok(karama); for (const [position, card] of [...ordinary, karama].entries()) { const from = g.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position); [g.deck[position], g.deck[from]] = [g.deck[from], g.deck[position]]; } g = reach(g, state => state.status === 'playing'); assert.ok(seat(g, 'emperor').hand.some(card => card.id === karama.id)); for (const position of [0,1]) { const from = g.spiceDeck.findIndex((card,index) => index >= position && 'territory' in card && !card.discovery); assert.ok(from >= position); [g.spiceDeck[position],g.spiceDeck[from]] = [g.spiceDeck[from],g.spiceDeck[position]]; } g = reach(g, state => state.turn === 1 && state.phase === 5 && state.active === 'richese' && quiet(state)); const token = seat(g,'richese').noField!.tokens.find(item => item.value === 3); assert.ok(token); const event = seat(g,'richese').noFieldEvent; assert.ok(event); g = act(g, 'richese', {type:'ship', noField:token.id, event, territory:'polar_sink', sector:0, allyPayment:0}); g = reach(g, quiet); g = act(g, 'richese', {type:'endMovement'}); g = reach(g, state => state.turn === 2 && state.phase === 5 && state.response?.kind === 'atreidesSpice'); assert.equal(seat(g,'richese').noField?.deployed?.tokenId, token.id); const revealEvent = seat(g,'richese').noFieldEvent; assert.ok(revealEvent); return {game:g, token:token.id, event:revealEvent, karama:karama.id}; } let cached: ReturnType | undefined; const fixture = () => cached ??= source(); function reveal(g: Game) { const {token,event} = fixture(); assert.equal(viewGame(g,'richese').richeseNoField?.canReveal, true); return act(g,'richese',{type:'revealNoField',token,event}); } function assertMaterialized(before: Game, after: Game) { assert.equal(seat(after,'richese').noField?.deployed, null); assert.equal(seat(after,'richese').reserves, seat(before,'richese').reserves - 3); assert.equal(seat(after,'richese').forces['polar_sink:0'], (seat(before,'richese').forces['polar_sink:0'] ?? 0) + 3); assert.deepEqual(inventory(after), inventory(before)); } function finishCancellation(g: Game, continuation: 'karamaTerminalDiscard' | 'karamaEmperorShareDiscard') { const kull = g.pendingKull; assert.ok(kull); assert.equal(kull.stage, 'offer'); assert.equal(kull.card, fixture().karama); g = act(g, kull.player, {type:'kullDecision',event:kull.event,decline:true}); const original = g.pendingTreacheryDiscard; assert.ok(original); assert.equal(original.continuation.kind, continuation); const event = original.batch.event; assert.deepEqual(original.batch.entries.map(entry => entry.card.id), [fixture().karama]); while (g.pendingTreacheryDiscard?.batch.event === event) { const reaction = g.pendingTreacheryDiscard.reaction; assert.equal(reaction?.stage, 'offer'); const actor = g.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor); g = act(g, actor.id, {type:'semutaPass',event}); } return g; } void test('voluntary reveal preserves the native Atreides Spice inspection and its existing passes', () => { let before = structuredClone(fixture().game); before = act(before,'richese',{type:'passResponse'}); assert.equal(before.response?.kind,'atreidesSpice'); const response = structuredClone(before.response), deck = structuredClone(before.spiceDeck); const after = reveal(before); assert.deepEqual(after.response,response); assert.deepEqual(after.spiceDeck,deck); assertMaterialized(before,after); const control = act(before,'emperor',{type:'passResponse'}); const continued = act(after,'emperor',{type:'passResponse'}); assert.equal(continued.response?.kind === 'atreidesSpice',false); assert.equal(continued.spicePeekKnown,control.spicePeekKnown); assert.deepEqual(continued.spiceDeck,control.spiceDeck); assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']); }); void test('Karama still cancels the original Spice inspection after the marker reveal', () => { const before = structuredClone(fixture().game), after = reveal(before); const continued = finishCancellation(act(after,'emperor',{type:'card',card:fixture().karama,mode:'cancel'}),'karamaTerminalDiscard'); assert.equal(continued.response?.kind === 'atreidesSpice',false); assert.equal(continued.spicePeekKnown,false); assert.equal(continued.discard.filter(card => card.id === fixture().karama).length,1); assert.deepEqual(continued.spiceDeck,before.spiceDeck); assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']); }); function giftSource() { let g = structuredClone(fixture().game); g = reach(g,state => state.turn === 2 && state.phase === 5 && state.active === 'emperor' && quiet(state)); // The real gift declaration is used; only the pre-existing reciprocal // alliance is staged so this fixture does not depend on a random Nexus. seat(g,'emperor').ally = 'richese'; seat(g,'richese').ally = 'emperor'; // Transfer the original Karama to a separate responder with a physical // card swap so the gift has a real counter opportunity. const emperor = seat(g,'emperor'), atreides = seat(g,'atreides'); const index = emperor.hand.findIndex(card => card.id === fixture().karama); assert.ok(index >= 0 && atreides.hand.length); [emperor.hand[index],atreides.hand[0]] = [atreides.hand[0],emperor.hand[index]]; g = act(g,'emperor',{type:'emperorGift',amount:3}); assert.equal(g.response?.kind,'emperorGift'); return g; } void test('voluntary reveal preserves an ordinary allied Emperor gift and its once-only payment', () => { let before = giftSource(); before = act(before,'richese',{type:'passResponse'}); const response = structuredClone(before.response), emperor = seat(before,'emperor').spice, richese = seat(before,'richese').spice; const after = reveal(before); assert.deepEqual(after.response,response); assertMaterialized(before,after); const control = act(before,'atreides',{type:'passResponse'}); const continued = act(after,'atreides',{type:'passResponse'}); assert.equal(continued.response?.kind === 'emperorGift',false); assert.equal(seat(continued,'emperor').spice,emperor - 3); assert.equal(seat(continued,'richese').spice,richese + 3); assert.equal(seat(continued,'emperor').spice,seat(control,'emperor').spice); assert.equal(seat(continued,'richese').spice,seat(control,'richese').spice); assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']); }); void test('Karama still cancels the original allied gift after the marker reveal', () => { const before = giftSource(), after = reveal(before); const emperor = seat(before,'emperor').spice, richese = seat(before,'richese').spice; const continued = finishCancellation(act(after,'atreides',{type:'card',card:fixture().karama,mode:'cancel'}),'karamaEmperorShareDiscard'); assert.equal(continued.response?.kind === 'emperorGift',false); assert.equal(seat(continued,'emperor').spice,emperor); assert.equal(seat(continued,'richese').spice,richese); assert.equal(continued.discard.filter(card => card.id === fixture().karama).length,1); assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']); }); void test('all bot profiles retain a legal response to either original parent after reveal', () => { for (const before of [structuredClone(fixture().game),giftSource()]) { const after = reveal(before); for (const difficulty of DIFFICULTIES) { const actor = before.response?.kind === 'emperorGift' ? 'atreides' : 'emperor'; const view = viewGame(after,actor); view.players.find(p => p.id === actor)!.bot = difficulty; const action = botActions(view).find(item => item.type === 'passResponse' || item.type === 'card' && item.mode === 'cancel'); assert.ok(action, `${difficulty} needs an original response action`); assert.doesNotThrow(() => applyAction(after,actor,action)); } } });