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 owner = 'richese', choam = 'choam', observer = 'emperor'; const player = (game: Game, id: string) => { const found = game.players.find(row => row.id === id); assert.ok(found); return found; }; const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard; function next(game: Game) { 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): Game { for (let count = 0; count < 1800; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished', 'The native game ended before its response.'); 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('The native response was not reached.'); } /** Genuine Basic setup with CHOAM and Ix decks, a paid first-turn No-Field * shipment and the original next-turn Charity response. Only unopened starting * deal and Spice card positions are selected; the physical Karama is dealt to * Emperor and held through the intervening native turns. */ function nativeCharitySource(withAtreides = false) { let game = createGame('RICHESECHARITY', newPlayer(owner, 'Richese', 'richese'), false, ['choam','ix']); joinGame(game, newPlayer(choam, 'CHOAM', 'choam')); if (withAtreides) joinGame(game, newPlayer('atreides', 'Atreides', 'atreides')); joinGame(game, newPlayer(observer, 'Emperor', 'emperor')); for (const seat of game.players) game = act(game, seat.id, {type:'ready'}); game = act(game, game.host, {type:'start'}); assert.equal(game.status, 'setup'); const ordinaryCount = withAtreides ? 3 : 2; const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, ordinaryCount); const karama = game.deck.find(card => card.effect === 'karama'); assert.equal(ordinary.length, ordinaryCount); assert.ok(karama); for (const [position, card] of [...ordinary, karama].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'); assert.ok(player(game, observer).hand.some(card => card.id === karama.id)); 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 token = player(game, owner).noField!.tokens.find(item => item.value === 3); assert.ok(token); const shipmentEvent = player(game, owner).noFieldEvent; assert.ok(shipmentEvent); game = act(game, owner, {type:'ship', noField:token.id, event:shipmentEvent, territory:'polar_sink', sector:0, allyPayment:0}); game = reach(game, clean); assert.equal(player(game, owner).noField?.deployed?.tokenId, token.id); game = act(game, owner, {type:'endMovement'}); game = reach(game, state => state.turn === 2 && state.phase === 2 && state.response?.kind === 'choamCharity'); assert.equal(game.response?.owner, choam); assert.ok(player(game, observer).hand.some(card => card.id === karama.id)); const event = player(game, owner).noFieldEvent; assert.ok(event); return {game, token:token.id, event, karama:karama.id, cards:inventory(game)}; } let saved: ReturnType | undefined; const source = () => saved ??= nativeCharitySource(); let savedAtreides: ReturnType | undefined; const atreidesSource = () => savedAtreides ??= nativeCharitySource(true); function reject(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); } void test('Richese reveals during the real CHOAM Charity response while its original cancel opportunity and continuation remain', () => { const {token,event,karama,cards} = source(); let before = structuredClone(source().game); before = act(before, owner, {type:'passResponse'}); assert.equal(before.response?.kind, 'choamCharity'); assert.ok(before.response.passed.includes(owner)); const response = structuredClone(before.response), cash = player(before, choam).spice, reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0; assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); reject(before, observer, {type:'revealNoField', token, event}); reject(before, owner, {type:'revealNoField', token, event:'stale'}); reject(before, owner, {type:'revealNoField', token:'stale', event}); let after = act(before, owner, {type:'revealNoField', token, event}); assert.deepEqual(after.response, response); assert.equal(after.turn, before.turn); assert.equal(after.phase, before.phase); assert.equal(player(after, owner).noField?.deployed, null); assert.equal(player(after, owner).noField?.lastShipped, token); assert.notEqual(player(after, owner).noFieldEvent, event); assert.equal(player(after, owner).reserves, reserves - 3); assert.equal(player(after, owner).forces['polar_sink:0'], field + 3); assert.equal(player(after, choam).spice, cash); assert.ok(player(after, observer).hand.some(card => card.id === karama)); assert.deepEqual(inventory(after), cards); reject(after, owner, {type:'revealNoField', token, event}); after = act(after, observer, {type:'passResponse'}); assert.notEqual(after.response?.kind, 'choamCharity'); assert.equal(player(after, choam).spice, cash + 2 * before.players.length); assert.equal(player(after, owner).forces['polar_sink:0'], field + 3); assert.deepEqual(inventory(after), cards); }); void test('all minimal bot profiles can continue the preserved CHOAM Charity response', () => { const {game, token, event} = source(); const revealed = act(structuredClone(game), owner, {type:'revealNoField', token, event}); for (const difficulty of DIFFICULTIES) { const automated = structuredClone(revealed); player(automated, observer).bot = difficulty; const choice = botActions(viewGame(automated, observer)).find(action => action.type === 'passResponse' || action.type === 'card' && action.mode === 'cancel'); assert.ok(choice, `${difficulty} needs a legal response`); const continued = applyAction(automated, observer, choice); assert.ok(continued.response?.kind !== 'choamCharity' || continued.response.passed.includes(observer) || continued.pendingKarama || continued.pendingKull || continued.pendingTreacheryDiscard, `${difficulty} must advance the original response: ${JSON.stringify({choice, response:continued.response, decision:continued.decision, discard:!!continued.pendingTreacheryDiscard})}`); assert.equal(player(continued, owner).forces['polar_sink:0'], 3); } }); void test('Richese reveals during the real Atreides normal-auction inspection response and preserves its lot', () => { const {token,event,karama,cards} = atreidesSource(); const before = reach(structuredClone(atreidesSource().game), state => state.turn === 2 && state.phase === 3 && state.response?.kind === 'atreidesAuction' && !!state.auction && !state.richeseAuction); assert.equal(before.response?.owner, 'atreides'); assert.ok(player(before, observer).hand.some(card => card.id === karama)); const response = structuredClone(before.response), lot = structuredClone(before.auction), reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0; assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); const after = act(before, owner, {type:'revealNoField', token, event}); assert.deepEqual(after.response, response); assert.deepEqual(after.auction, lot); assert.equal(player(after, owner).noField?.deployed, null); assert.equal(player(after, owner).reserves, reserves - 3); assert.equal(player(after, owner).forces['polar_sink:0'], field + 3); assert.deepEqual(inventory(after), cards); const control = act(before, observer, {type:'passResponse'}); const continued = act(after, observer, {type:'passResponse'}); assert.notEqual(continued.response?.kind, 'atreidesAuction'); assert.deepEqual(continued.auction, control.auction); assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3); assert.deepEqual(inventory(continued), cards); }); void test('Richese reveals during the real cache-auction prevention response without replaying its declaration', () => { const {token,event,karama,cards} = source(); const before = reach(structuredClone(source().game), state => state.turn === 2 && state.phase === 3 && state.response?.kind === 'richeseAuction'); assert.equal(before.response?.owner, owner); assert.ok(player(before, observer).hand.some(card => card.id === karama)); const response = structuredClone(before.response), round = structuredClone(before.richeseBidding), reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0; assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true); const after = act(before, owner, {type:'revealNoField', token, event}); assert.deepEqual(after.response, response); assert.deepEqual(after.richeseBidding, round); assert.equal(player(after, owner).noField?.deployed, null); assert.equal(player(after, owner).reserves, reserves - 3); assert.equal(player(after, owner).forces['polar_sink:0'], field + 3); assert.deepEqual(inventory(after), cards); const control = act(before, observer, {type:'passResponse'}); const continued = act(after, observer, {type:'passResponse'}); assert.notEqual(continued.response?.kind, 'richeseAuction'); assert.ok(continued.richeseBidding?.event); assert.deepEqual({...continued.richeseBidding, event:undefined}, {...control.richeseBidding, event:undefined}); assert.deepEqual(continued.richeseAuction, control.richeseAuction); assert.deepEqual({...continued.decision, event:undefined}, {...control.decision, event:undefined}); assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3); assert.deepEqual(inventory(continued), cards); });