import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { deployRicheseNoField } from '../game/richese-no-field'; import type { TruthQuestion } from '../game/truthtrance'; import { createTruthtranceRichesePromisesFixture as fixture, actTruthtranceRichesePromises as act, askTruthtranceRichesePromises as ask, advanceTruthtranceRichesePromises as advance, truthtranceRichesePromisesClean as clean, truthtranceRichesePromisesPlayer as player, truthtranceRichesePromisesInventory as inventory, } from './fixture-truthtrance-richese-promises'; const markerQuestion = { kind: 'shipment', target: 'richese', claim: {kind: 'noField', territory: 'carthag', sector: 11}, } as unknown as TruthQuestion; const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); const seat = (game: Game, id: string) => { const found = game.players.find(row => row.id === id); assert.ok(found); return found; }; function reject(game: Game, actor: string, action: Action) { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); } function start(answer: 'yes' | 'no') { const f = fixture(); const question = ask(structuredClone(f.game), f, markerQuestion); assert.ok(viewGame(question, f.asker).truthShipmentTargets.includes(f.target)); assert.deepEqual(viewGame(question, f.target).truthShipmentAnswers, ['yes', 'no']); let game = act(question, f.target, {type:'truthAnswer', answer}); game = advance(game, clean); assert.equal(game.shipmentPromises?.length, 1); assert.equal(game.shipmentPromises![0].answer, answer === 'yes'); assert.equal(game.shipmentPromises![0].fulfilled, undefined); assert.equal(game.shipmentPromises![0].released, undefined); assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1); game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state)); return {f, game}; } function markerAction(game: Game, value = 0, territory = 'carthag', sector = 11): Action { const richese = player(game); const token = richese.noField!.tokens.find(item => item.value === value); assert.ok(token); assert.ok(richese.noFieldEvent); return {type:'ship', noField:token.id, event:richese.noFieldEvent, territory, sector, allyPayment:0}; } function finishResponse(game: Game): Game { return advance(game, clean); } void test('Basic early Yes requires the requested owned No-Field, including its zero token', () => { const {f, game: before} = start('yes'); const choice = markerAction(before), target = player(before), marker = String(choice.noField); const ownView = viewGame(before, f.target), otherView = viewGame(before, f.asker); assert.ok(ownView.shipmentCompletion?.actions.some(action => action.type === 'ship' && action.noField)); assert.equal(otherView.shipmentCompletion, null); assert.equal(otherView.richeseNoField?.private, null); assert.equal(JSON.stringify(otherView).includes(marker), false); reject(before, f.target, {type:'endMovement'}); reject(before, f.target, {type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0}); reject(before, f.target, markerAction(before, 0, 'arrakeen', 10)); const declared = act(reload(before), f.target, choice); const done = finishResponse(declared); assert.equal(player(done).noField?.deployed?.tokenId, marker); assert.deepEqual(player(done).noField?.deployed?.location, {territory:'carthag', sector:11}); assert.equal(player(done).reserves, target.reserves); assert.equal(player(done).spice, target.spice - 1); assert.equal(player(done).shipped, true); assert.equal(done.shipmentPromises![0].fulfilled, true); assert.equal(done.shipmentPromises![0].released, undefined); assert.deepEqual(inventory(done), f.originalInventory); assert.equal(viewGame(done, f.asker).richeseNoField?.private, null); reject(done, f.target, choice); }); void test('Basic No forbids the selected marker shipment but permits a physical shipment', () => { const {f, game: before} = start('no'); reject(before, f.target, markerAction(before)); const done = finishResponse(act(before, f.target, { type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0, })); assert.equal(player(done).forces['carthag:11'], 1); assert.equal(player(done).noField?.deployed, null); assert.equal(done.shipmentPromises![0].fulfilled, true); assert.deepEqual(inventory(done), f.originalInventory); }); void test('Basic No permits a marker in a different destination', () => { const {f, game: before} = start('no'); const action = markerAction(before, 3, 'arrakeen', 10); const done = finishResponse(act(before, f.target, action)); assert.deepEqual(player(done).noField?.deployed?.location, {territory:'arrakeen', sector:10}); assert.equal(done.shipmentPromises![0].fulfilled, true); assert.deepEqual(inventory(done), f.originalInventory); }); void test('an opposing Karama prevents the marker and releases an impossible Yes without spending shipment', () => { const {f, game: original} = start('yes'); const game = structuredClone(original), opponent = seat(game, f.observer); const from = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(from >= 0 && opponent.hand.length); // Move an existing physical Karama into the observer's hand via a conserved // card swap; the declaration and the resulting counter are actual actions. [opponent.hand[0], game.deck[from]] = [game.deck[from], opponent.hand[0]]; const karama = opponent.hand[0].id, before = structuredClone(game); const declared = act(game, f.target, markerAction(game)); assert.equal(declared.response?.kind, 'richeseNoField'); assert.equal(declared.shipmentPromises![0].fulfilled, undefined); let stopped = act(declared, f.observer, {type:'card', mode:'cancel', card:karama}); stopped = finishResponse(stopped); assert.equal(player(stopped).noField?.deployed, null); assert.equal(player(stopped).noFieldBlockedTurn, stopped.turn); assert.equal(player(stopped).shipped, false); assert.equal(player(stopped).spice, player(before).spice); assert.equal(stopped.shipmentPromises![0].released, true); assert.equal(stopped.shipmentPromises![0].fulfilled, undefined); reject(stopped, f.target, markerAction(stopped)); const normal = finishResponse(act(stopped, f.target, { type:'ship', territory:'carthag', sector:11, amount:1, elite:0, allyPayment:0, })); assert.equal(player(normal).forces['carthag:11'], 1); assert.deepEqual(inventory(normal), f.originalInventory); }); void test('all existing bot profiles use the same private feasible answers and legal marker completion', () => { const f = fixture(), asked = ask(structuredClone(f.game), f, markerQuestion); for (const difficulty of DIFFICULTIES) { const answering = viewGame(asked, f.target); answering.players.find(row => row.id === f.target)!.bot = difficulty; const answer = botActions(answering).find(action => action.type === 'truthAnswer'); assert.ok(answer && answering.truthShipmentAnswers?.includes(answer.answer as 'yes' | 'no' | 'unknown')); assert.doesNotThrow(() => act(reload(asked), f.target, answer)); const {game} = start('yes'); const turn = viewGame(game, f.target); turn.players.find(row => row.id === f.target)!.bot = difficulty; const action = botActions(turn)[0]; assert.ok(action, difficulty); assert.doesNotThrow(() => act(reload(game), f.target, action)); } }); void test('a marker-only declaration does not satisfy an existing physical-force promise', () => { const f = fixture(); let game = ask(structuredClone(f.game), f); game = act(game, f.target, {type:'truthAnswer', answer:'yes'}); game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state)); reject(game, f.target, markerAction(game)); const done = finishResponse(act(game, f.target, f.shipmentAction)); assert.equal(player(done).forces['carthag:11'], 5); assert.equal(done.shipmentPromises![0].fulfilled, true); }); void test('Basic cannot promise a single shipment is both a marker and physical-only forces', () => { const f = fixture(); const question = { kind:'shipment', target:f.target, claim:{op:'and', terms:[ {kind:'noField', territory:'carthag'}, {territory:'carthag', minimum:1}, ]}, } as unknown as TruthQuestion; const asked = ask(structuredClone(f.game), f, question); assert.deepEqual(viewGame(asked, f.target).truthShipmentAnswers, ['no']); reject(asked, f.target, {type:'truthAnswer', answer:'yes'}); const done = act(asked, f.target, {type:'truthAnswer', answer:'no'}); assert.equal(done.shipmentPromises![0].answer, false); }); void test('the marker question cannot target a faction that does not own a No-Field', () => { const f = fixture(); let game = act(structuredClone(f.game), f.asker, {type:'card', card:f.truthtrance}); game = advance(game, state => state.truthtrance?.stage === 'ask'); const question = {kind:'shipment', target:f.observer, claim:{kind:'noField', territory:'carthag'}}; reject(game, f.asker, {type:'truthAsk', question}); }); void test('a deployed marker can be revealed before the promised different No-Field is shipped', () => { const f = fixture(), staged = structuredClone(f.game), richese = player(staged); const previous = richese.noField!.tokens.find(item => item.value === 3); assert.ok(previous); // Model an earlier turn's original marker with the same physical token and // history; staging does not create forces or change reserves. richese.noField = deployRicheseNoField(richese.noField!, { tokenId: previous.id, controller: richese.id, location: {territory:'polar_sink', sector:0}, }); let game = ask(staged, f, markerQuestion); assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes')); game = act(game, f.target, {type:'truthAnswer', answer:'yes'}); game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state)); const first = viewGame(game, f.target).shipmentCompletion?.actions[0]; assert.equal(first?.type, 'revealNoField'); assert.equal(first?.token, previous.id); game = act(game, f.target, first!); assert.equal(player(game).noField?.deployed, null); assert.equal(player(game).noField?.lastShipped, previous.id); assert.equal(player(game).forces['polar_sink:0'], 3); const next = viewGame(game, f.target).shipmentCompletion?.actions[0]; assert.equal(next?.type, 'ship'); assert.ok(next?.noField && next.noField !== previous.id); game = finishResponse(act(game, f.target, next!)); assert.equal(game.shipmentPromises![0].fulfilled, true); assert.equal(player(game).noField?.deployed?.tokenId, next!.noField); assert.deepEqual(player(game).noField?.deployed?.location, {territory:'carthag', sector:11}); }); function givePhysicalKarama(game: Game, id: string): string { const target = seat(game, id), held = target.hand.find(card => card.effect === 'karama'); if (held) return held.id; assert.ok(target.hand.length); const inDeck = game.deck.findIndex(card => card.effect === 'karama'); if (inDeck >= 0) { [target.hand[0], game.deck[inDeck]] = [game.deck[inDeck], target.hand[0]]; return target.hand[0].id; } for (const other of game.players) { if (other.id === id) continue; const index = other.hand.findIndex(card => card.effect === 'karama'); if (index >= 0) { [target.hand[0], other.hand[index]] = [other.hand[index], target.hand[0]]; return target.hand[0].id; } } throw Error('No original physical Karama was available for the fixture'); } void test('Richese cannot cancel its own promised No-Field while another responder holds Karama', () => { const game = start('yes').game, karama = givePhysicalKarama(game, 'richese'); const opposing = givePhysicalKarama(game, 'emperor'); assert.notEqual(karama, opposing); assert.ok(player(game).hand.some(card => card.id === karama)); const declared = act(game, 'richese', markerAction(game)); assert.equal(declared.response?.kind, 'richeseNoField'); reject(declared, 'richese', {type:'card', mode:'cancel', card:karama}); assert.equal(declared.pendingKull ?? null, null); assert.equal(declared.pendingTreacheryDiscard ?? null, null); assert.ok(player(declared).hand.some(card => card.id === karama)); assert.equal(declared.shipmentPromises![0].released, undefined); assert.equal(declared.shipmentPromises![0].fulfilled, undefined); });