import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; import { gameDistance, splitLocation } from '../game/board'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import type { OrnithopterMode } from '../game/ornithopter'; import { SEMUTA_DRUG_ID } from '../game/semuta-drug'; import { advanceSemutaHomeworldChoam, holdSemutaHomeworldChoamCard, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam'; import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share'; import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement'; import { createSemutaHomeworldOrnithopterFixture, disposeSemutaHomeworldOrnithopter, finishSemutaHomeworldOrnithopter, semutaHomeworldOrnithopterResources as resources, semutaHomeworldOrnithopterStock as stock } from './fixture-semuta-homeworld-ornithopter'; function assertCounters(game: Game, original: Game) { for (const p of game.players) { const before = player(original, p.id); assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, x) => n + x, 0), before.reserves + before.tanks + Object.values(before.forces).reduce((n, x) => n + x, 0)); if (p.elites && before.elites) { assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((n, x) => n + x, 0), before.elites.reserves + before.elites.tanks + Object.values(before.elites.forces).reduce((n, x) => n + x, 0)); for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0)); } } assert.deepEqual(game.homeworlds?.custody, original.homeworlds?.custody); } const paths: { mode: OrnithopterMode; early: boolean }[] = [ { mode: 'range3', early: false }, { mode: 'twoGroups', early: false }, { mode: 'twoGroups', early: true }, ]; for (const advanced of [false, true]) for (const { mode, early } of paths) { const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld Ornithopter ${mode}${early ? ' early finish' : ''}`; for (const claim of [false, true]) void test(`${label}: ${claim ? 'claim' : 'pass'} retires once before the original arrival or turn continuation`, () => { const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early); const inventory = stock(f.beforeFirstMove), owner = player(f.afterShipment, f.owner); assert.equal(f.afterShipment.turn, 1); assert.equal(f.afterShipment.phase, 5); assert.equal(owner.spice, player(f.beforeShipment, f.owner).spice - f.amount * 2); assert.equal(owner.reserves, player(f.beforeShipment, f.owner).reserves - f.amount); assert.equal(owner.forces[f.origin], f.amount); assertCounters(f.afterShipment, f.setup); const distance = gameDistance(f.beforeFirstMove, f.origin, f.destination, key => splitLocation(key).sector === f.beforeFirstMove.storm); assert.equal(distance, mode === 'range3' ? 3 : 1); if (mode === 'range3') assert.throws(() => applyAction(f.beforeFirstMove, f.owner, { ...f.firstMove, movementCard: undefined, ornithopter: undefined })); if (mode === 'twoGroups') { assert.equal(player(f.game, f.owner).forces[f.destination], 1); assert.equal(player(f.game, f.owner).moved, 1); assert.equal(player(f.game, f.owner).hand.some(c => c.id === f.card), false); assert.equal(f.game.discard.some(c => c.id === f.card), false); assert.equal(viewGame(f.game, f.owner).ornithopter?.active?.remaining, 1); assert.deepEqual(viewGame(f.game, f.owner).ornithopter?.active?.cohort?.forces, early ? {} : { [f.origin]: 1 }); assert.throws(() => applyAction(f.game, f.owner, { type: 'move', from: f.destination, amount: 1, elite: 0, ...splitLocation(f.origin), ornithopterEvent: f.game.ornithopter!.event })); assert.throws(() => applyAction(f.game, f.owner, { ...f.action, type: 'move', from: f.origin, amount: 1, ...splitLocation(f.secondDestination), ornithopterEvent: 'stale' })); } const uninterrupted = reactSemutaHomeworldChoam(disposeSemutaHomeworldOrnithopter(f, withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder)), f.holder, false); const offered = disposeSemutaHomeworldOrnithopter(f); assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); assert.equal(offered.discard.filter(c => c.id === f.card).length, 1); assert.equal(player(offered, f.owner).hand.some(c => c.id === f.card), false); assert.equal(player(offered, f.owner).moved, f.moves); assert.equal(player(offered, f.owner).forces[f.destination], 1); if (mode === 'twoGroups' && !early) assert.equal(player(offered, f.owner).forces[f.secondDestination], 1); assert.equal(player(offered, f.owner).forces[f.origin] ?? 0, 0); assert.equal(offered.active, f.owner, 'Retirement suspends the same original turn, not its successor'); assert.deepEqual(offered.movementRemaining, f.game.movementRemaining); assert.deepEqual(stock(offered), inventory); assertCounters(offered, f.setup); assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered); const reloaded = normalizeAutomaticGame(JSON.parse(JSON.stringify(offered))); assert.deepEqual(resources(reloaded), resources(offered)); assert.deepEqual(stock(reloaded), inventory); assert.equal(reloaded.active, offered.active); assert.deepEqual(reloaded.movementRemaining, offered.movementRemaining); for (const p of offered.players) assert.deepEqual(viewGame(reloaded, p.id), viewGame(offered, p.id)); const frozen = resources(offered), event = offered.pendingTreacheryDiscard!.batch.event; assert.throws(() => applyAction(offered, f.owner, { type: 'endMovement' })); assert.throws(() => applyAction(offered, f.owner, f.action)); const resumed = reactSemutaHomeworldChoam(JSON.parse(JSON.stringify(offered)), f.holder, claim); assert.equal(resumed.pendingTreacheryDiscard, null); assert.deepEqual(resources(resumed), frozen, 'Resume cannot replay a paid shipment or ground move'); assert.deepEqual(resources(resumed), resources(uninterrupted)); assert.equal(resumed.active, uninterrupted.active); assert.deepEqual(resumed.movementRemaining, uninterrupted.movementRemaining); assert.equal(player(resumed, f.holder).hand.filter(c => c.id === f.card).length, claim ? 1 : 0); assert.equal(resumed.discard.filter(c => c.id === f.card).length, claim ? 0 : 1); assert.equal(resumed.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0); assert.deepEqual(stock(resumed), inventory); assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event })); assert.throws(() => applyAction(resumed, f.owner, f.firstMove)); assert.throws(() => applyAction(resumed, f.owner, { type: 'move', from: f.destination, amount: 1, ...splitLocation(f.origin) })); const replay = structuredClone(resumed); replay.pendingTreacheryDiscard = structuredClone(offered.pendingTreacheryDiscard); assert.throws(() => normalizeAutomaticGame(replay)); assert.deepEqual(normalizeAutomaticGame(structuredClone(resumed)), resumed); const completed = finishSemutaHomeworldOrnithopter(resumed); assert.equal(completed.turn, 1); assert.equal(completed.phase, 7); assert.equal(player(completed, f.owner).moved, f.moves); assert.equal(player(completed, f.owner).reserves, owner.reserves); assert.deepEqual(player(completed, f.owner).forces, player(offered, f.owner).forces); assert.deepEqual(stock(completed), inventory); assertCounters(completed, f.setup); }); void test(`${label}: full-hand singleton entitlement is private and original forces cannot be borrowed by another player`, () => { const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early, true); assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder))); assert.throws(() => applyAction(f.game, f.holder, f.action)); const offered = disposeSemutaHomeworldOrnithopter(f); const neutral = disposeSemutaHomeworldOrnithopter(f, withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder)); assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true); assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false); const event = offered.pendingTreacheryDiscard!.batch.event; for (const p of offered.players.filter(p => p.id !== f.holder)) { assert.deepEqual(viewGame(offered, p.id), viewGame(neutral, p.id)); assert.equal(viewGame(offered, p.id).semutaReaction?.canCommit, false); assert.throws(() => applyAction(offered, p.id, { type: 'semutaCommit', event })); } assert.throws(() => applyAction(offered, f.holder, { type: 'semutaCommit', event: 'stale' })); const claimed = reactSemutaHomeworldChoam(offered, f.holder, true); assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder))); assert.equal(player(claimed, f.holder).hand.some(c => c.id === f.card), true); assert.equal(player(claimed, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), false); const done = finishSemutaHomeworldOrnithopter(claimed); assert.equal(player(done, f.owner).moved, f.moves); assert.deepEqual(stock(done), stock(f.beforeFirstMove)); }); void test(`${label}: all four bot profiles legally claim or pass without a second shipment or repeated group`, () => { const f = createSemutaHomeworldOrnithopterFixture(mode, advanced, early); const offered = disposeSemutaHomeworldOrnithopter(f), frozen = resources(offered); for (const difficulty of DIFFICULTIES) { let game: Game = structuredClone(offered); const own = viewGame(game, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty; const action = botActions(own)[0]; assert.ok(action); assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass'); game = applyAction(game, f.holder, action); while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') { const pending = game.pendingTreacheryDiscard, reaction = pending.reaction; if (reaction?.stage !== 'offer') throw Error('Missing original retirement offer'); const actor = game.players.find(p => !reaction.passed.includes(p.id))!.id; game = applyAction(game, actor, { type: 'semutaPass', event: pending.batch.event }); } assert.equal(game.pendingTreacheryDiscard, null); assert.equal(player(game, f.holder).hand.some(c => c.id === f.card), action.type === 'semutaCommit'); assert.deepEqual(resources(game), frozen); const done = finishSemutaHomeworldOrnithopter(game); assert.equal(player(done, f.owner).moved, f.moves); assert.deepEqual(stock(done), stock(f.beforeFirstMove)); assertCounters(done, f.setup); } }); } for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld prior move needs an unused entitlement and Hajr preserves the fixed maximum three`, () => { const f = createSemutaHomeworldOrnithopterFixture('range3', advanced); const before = structuredClone(f.beforeFirstMove); assert.throws(() => applyAction(before, f.owner, { ...f.firstMove, ...splitLocation(f.tooFarDestination) })); assert.deepEqual(before, f.beforeFirstMove); const moved = applyAction(before, f.owner, { ...f.firstMove, movementCard: undefined, ornithopter: undefined, ...splitLocation(f.secondDestination) }); assert.equal(player(moved, f.owner).moved, 1); assert.equal(player(moved, f.owner).hand.some(c => c.id === f.card), true); assert.throws(() => applyAction(moved, f.owner, { ...f.firstMove, from: f.secondDestination, ...splitLocation(f.origin) })); const hajrGame = structuredClone(advanced ? moved : before); const hajr = holdSemutaHomeworldChoamCard(hajrGame, f.owner, 'Hajr'); const played = applyAction(hajrGame, f.owner, { type: 'card', card: hajr }); const allowed = advanceSemutaHomeworldChoam(played, g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening); assert.equal(player(allowed, f.owner).moved, advanced ? 1 : 0); assert.ok(allowed.hajr.includes(f.owner)); assert.equal(player(allowed, f.owner).hand.some(c => c.id === f.card), true); const declared = applyAction(allowed, f.owner, advanced ? { ...f.firstMove, from: f.secondDestination, ...splitLocation(f.origin) } : f.firstMove); const completed = advanceSemutaHomeworldChoam(declared, g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening); assert.equal(player(completed, f.owner).moved, advanced ? 2 : 1); assertCounters(allowed, f.setup); assertCounters(completed, f.setup); });