import assert from 'node:assert/strict'; import test from 'node:test'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { distance, location, TERRITORIES } from '../game/board'; import { viewGame, type Action, type Game } from '../game/engine'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { actIndependentOrdinary as act, advanceIndependentOrdinary, createIndependentOrdinaryFixture, independentOrdinaryClean, independentOrdinaryInventory as inventory, independentOrdinaryPlayer as player, } from './fixture-semuta-independent-ordinary'; import { moveSemutaEmperorCard, semutaEmperorCardId } from './fixture-semuta-emperor-share'; type Kind = 'box' | 'distrans' | 'truth' | 'ornithopter'; function close(game: Game, holder: string, claim: boolean): Game { const batch = game.pendingTreacheryDiscard; assert.ok(batch?.reaction); const event = batch.batch.event; if (claim) return act(game, holder, {type:'semutaCommit', event}); for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event}); return game; } /** Real public independent-card game and Semuta acquisition. The additional * printed card is moved once between existing physical zones to isolate this * interaction; no force, money, phase or event is staged. */ function fixture(kind: Kind) { const source = createIndependentOrdinaryFixture('hajr'); let game = structuredClone(source.game); const effect = kind === 'box' ? 'nullentropyBox' : kind === 'truth' ? 'truthtrance' : kind; const card = semutaEmperorCardId(game, item => item.effect === effect); moveSemutaEmperorCard(game, card, source.owner); const initialInventory = inventory(game); let given: string | undefined, selected: string | undefined; if (kind === 'box') { // Use the genuinely bought Hajr to create an actual discard target. game = act(game, source.owner, source.play); game = close(game, source.holder, false); selected = source.card; game = act(game, source.owner, {type:'card', card}); // A sole eligible discard is selected automatically by the native Box. if (game.pendingNullentropy) game = act(game, source.owner, {type:'decision', event:game.pendingNullentropy.event, card:selected}); } else if (kind === 'distrans') { given = source.card; game = act(game, source.owner, {type:'card', card, target:'emperor', give:given}); } else if (kind === 'truth') { game = act(game, source.owner, {type:'card', card}); while (game.truthtrance?.stage === 'priority') { const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat); game = act(game, seat.id, {type:'truthPass'}); } game = act(game, source.owner, {type:'truthAsk', question:{kind:'freeform', target:'emperor', scope:'fact', text:'Do you intend to protect Arrakeen?'}}); game = act(game, 'emperor', {type:'truthAnswer', answer:'yes'}); } else { const open = TERRITORIES.flatMap(place => place.type === 'sand' ? place.sectors.map(sector => ({place, sector})) : []) .find(({place, sector}) => sector !== game.storm && game.players.every(seat => !Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`)))); assert.ok(open); game = act(game, source.owner, {type:'ship', territory:open.place.id, sector:open.sector, amount:1, elite:0, allyPayment:0}); game = advanceIndependentOrdinary(game, independentOrdinaryClean); const from = location(open.place.id, open.sector); const target = TERRITORIES.flatMap(place => place.sectors.map(sector => ({place, sector}))) .find(({place, sector}) => place.id !== open.place.id && sector !== game.storm && distance(from, location(place.id, sector)) <= 1 && game.players.every(seat => !Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`)))); assert.ok(target); const action: Action = {type:'move', from, amount:1, elite:0, territory:target.place.id, sector:target.sector, movementCard:card, ornithopter:'range3'}; game = act(game, source.owner, action); } return {game, owner:source.owner, holder:source.holder, card, given, selected, initialInventory}; } for (const kind of ['box','distrans','truth','ornithopter'] as const) for (const claim of [false,true]) { void test(`independent-card ${kind} Semuta ${claim ? 'claim' : 'pass'} preserves the completed effect`, () => { const f = fixture(kind), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction); assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.card]); assert.equal(viewGame(f.game, f.holder).semutaReaction?.canCommit, true); if (kind === 'distrans') { assert.ok(player(f.game,'emperor').hand.some(card => card.id === f.given)); assert.ok(!pending.batch.entries.some(entry => entry.card.id === f.given)); } if (kind === 'box') assert.ok(player(f.game,f.owner).hand.some(card => card.id === f.selected)); const before = structuredClone({players:f.game.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})), truthHistory:f.game.truthHistory, moved:player(f.game,f.owner).moved}); const done = close(f.game, f.holder, claim); assert.equal(done.pendingTreacheryDiscard, null); assert.deepEqual({players:done.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})), truthHistory:done.truthHistory, moved:player(done,f.owner).moved}, before); assert.equal(player(done,f.holder).hand.some(card => card.id === f.card), claim); assert.equal(done.discard.some(card => card.id === f.card), !claim); assert.equal(done.discard.some(card => card.id === SEMUTA_DRUG_ID), claim); assert.deepEqual(inventory(done), f.initialInventory); }); } void test('all AI profiles can answer the independent-card Distrans discard', () => { const f = fixture('distrans'); for (const difficulty of DIFFICULTIES) { const game = structuredClone(f.game); player(game,f.holder).bot = difficulty; const answer = botActions(viewGame(game,f.holder)).find(action => action.type === 'semutaCommit' || action.type === 'semutaPass'); assert.ok(answer); assert.doesNotThrow(() => act(game,f.holder,answer)); } });