import assert from 'node:assert/strict'; import test from 'node:test'; import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { richeseRevealInterruptSupported } from '../game/richese-reveal-interruption'; import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early'; import { truthMovementInventory } from './fixture-truth-movement'; const richese = 'richese', guild = 'guild', atreides = 'atreides'; const player = (game: Game, id: string) => { const result = game.players.find(row => row.id === id); assert.ok(result); return result; }; const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard; const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); /** Real public Basic setup, paid No-Field shipment and Guild-rate declaration. * Only unopened starting and Spice card positions and scalar lotteries are * selected; no hands, balance, forces, alliances or pending events are staged. */ function nativeRateSource() { let game = createGame('RICHESEGUILDRATE', newPlayer(richese, 'Richese', 'richese'), false, ['choam']); joinGame(game, newPlayer(guild, 'Guild', 'guild')); joinGame(game, newPlayer(atreides, 'Atreides', 'atreides')); 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 ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 2); const karama = game.deck.find(card => card.effect === 'karama'); assert.equal(ordinary.length, 2); 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]]; } const reach = (until: (state: Game) => boolean) => { for (let count = 0; count < 2200; count++) { if (until(game)) return; assert.notEqual(game.status, 'finished'); if (game.phase === 5 && game.active === richese && clean(game) && !player(game, richese).noField?.deployed && !player(game, richese).shipped) { const owner = player(game, richese), marker = owner.noField!.tokens.find(token => token.value === 3); assert.ok(marker); assert.ok(owner.noFieldEvent); game = act(game, richese, {type:'ship', noField:marker.id, event:owner.noFieldEvent, territory:'polar_sink', sector:0, allyPayment:0}); continue; } const step = nextTruthMovementNativeEarlyStep(game, { position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false, }); assert.ok(step, `Missing native action at ${game.turn}/${game.phase}/${game.decision?.kind}`); game = act(game, step.actor, step.action); } throw Error('The genuine Guild-rate shipment was not reached'); }; reach(state => state.status === 'playing'); assert.ok(player(game, atreides).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]]; } reach(state => state.phase === 5 && state.active === guild && clean(state) && !!player(state, richese).noField?.deployed && !player(state, guild).shipped); const marker = player(game, richese).noField!.deployed!; const event = player(game, richese).noFieldEvent; assert.ok(event); const initialGuild = structuredClone(player(game, guild)); game = act(game, guild, {type:'ship', territory:'arrakeen', sector:10, amount:3, allyPayment:0}); assert.equal(game.response?.kind, 'guildRate'); assert.equal(game.pendingShipment?.player, guild); assert.equal(game.pendingShipment?.territory, 'arrakeen'); assert.equal(game.pendingShipment?.cost, 2); assert.equal(player(game, guild).reserves, initialGuild.reserves); assert.equal(player(game, guild).spice, initialGuild.spice); assert.equal(player(game, guild).shipped, false); assert.ok(viewGame(game, atreides).responseControls?.cancelCards.includes(karama.id)); return {game, marker:marker.tokenId, event, karama:karama.id, initialGuild}; } let saved: ReturnType | undefined; const source = () => saved ??= nativeRateSource(); function reveal() { const {marker,event} = source(), before = structuredClone(source().game); const response = structuredClone(before.response), shipment = structuredClone(before.pendingShipment); const oldRichese = player(before, richese), cards = truthMovementInventory(before); assert.equal(viewGame(before, richese).richeseNoField?.canReveal, true); const action = {type:'revealNoField', token:marker, event} satisfies Action; const wrong = structuredClone(before); assert.throws(() => act(wrong, atreides, action)); assert.deepEqual(wrong, before); const after = act(before, richese, action); assert.deepEqual(after.response, response); assert.deepEqual(after.pendingShipment, shipment); assert.equal(player(after, richese).noField?.deployed, null); assert.equal(player(after, richese).noField?.lastShipped, marker); assert.notEqual(player(after, richese).noFieldEvent, event); assert.equal(player(after, richese).reserves, oldRichese.reserves - 3); assert.equal(player(after, richese).forces['polar_sink:0'], (oldRichese.forces['polar_sink:0'] ?? 0) + 3); assert.equal(player(after, richese).spice, oldRichese.spice); assert.deepEqual(truthMovementInventory(after), cards); assert.throws(() => act(after, richese, action)); return after; } for (const cancel of [false, true]) void test( `Basic No-Field reveal preserves Guild-rate ${cancel ? 'cancellation' : 'allowance'} and the original shipment`, () => { const {karama,initialGuild} = source(); const before = structuredClone(source().game), after = reveal(); const action: Action = cancel ? {type:'card', mode:'cancel', card:karama} : {type:'passResponse'}; let original = act(reload(before), atreides, action); let continued = act(reload(after), atreides, action); if (cancel) { for (const state of [original, continued]) { assert.equal(state.pendingTreacheryDiscard?.batch.cause, 'karama:guildRate'); assert.equal(player(state, guild).reserves, initialGuild.reserves); assert.equal(player(state, guild).spice, initialGuild.spice); assert.equal(player(state, guild).shipped, false); assert.ok(state.pendingShipment); } const finishDiscard = (state: Game) => { for (let count = 0; state.pendingTreacheryDiscard && count < state.players.length + 1; count++) { const step = nextTruthMovementNativeEarlyStep(state); assert.ok(step); assert.equal(step.action.type, 'semutaPass'); state = act(state, step.actor, step.action); } assert.equal(state.pendingTreacheryDiscard, null); return state; }; original = finishDiscard(original); continued = finishDiscard(continued); } assert.equal(continued.response?.kind === 'guildRate', false); assert.equal(continued.pendingShipment, null); assert.deepEqual(player(continued, guild), player(original, guild)); assert.equal(player(continued, guild).reserves, initialGuild.reserves - 3); assert.equal(player(continued, guild).forces['arrakeen:10'], (initialGuild.forces['arrakeen:10'] ?? 0) + 3); assert.equal(player(continued, guild).spice, initialGuild.spice - (cancel ? 3 : 2)); assert.equal(player(continued, guild).shipped, true); assert.equal(player(continued, richese).forces['polar_sink:0'], 3); assert.equal(continued.discard.filter(card => card.id === karama).length, cancel ? 1 : 0); assert.throws(() => act(continued, atreides, action)); }, ); void test('all four bot profiles can answer the preserved Guild-rate response', () => { const after = reveal(); for (const difficulty of DIFFICULTIES) { const view = viewGame(after, atreides); view.players.find(seat => seat.id === atreides)!.bot = difficulty; const choice = botActions(view)[0]; assert.ok(choice, difficulty); const next = act(reload(after), atreides, choice); assert.equal(next.response?.kind === 'guildRate', false); assert.equal(player(next, richese).forces['polar_sink:0'], 3); assert.equal(player(next, guild).forces['arrakeen:10'], 3); } }); void test('Guild-rate reveal quote rejects a changed source, conflicting destination, stale event and other live work', () => { const original = structuredClone(source().game); assert.equal(richeseRevealInterruptSupported(original), true); const variations: [string, (game: Game) => void][] = [ ['destination at marker', game => { game.pendingShipment!.territory = 'polar_sink'; }], ['changed shipper', game => { game.pendingShipment!.player = richese; }], ['stale event', game => { game.response!.guildRateEvent = 'stale'; }], ['other pending child', game => { Object.assign(game, {pendingIxMove: {event:'other-child'}}); }], ['current Truthtrance history', game => { Object.assign(game, {truthHistory: [{turn:game.turn}]}); }], ]; for (const [name, mutate] of variations) { const copy = structuredClone(original); mutate(copy); const before = structuredClone(copy); assert.equal(richeseRevealInterruptSupported(copy), false, name); assert.deepEqual(copy, before, `${name}: a quote must not change game state`); } });