import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, viewGame } from '../game/engine'; import { RECRUITS_CARD_ID } from '../game/recruits'; import { ownedTech } from '../game/tech-tokens'; import { advanceRecruitsModules, assertRecruitsModulesInventory, createRecruitsModulesFixture, recruitsModulesClean, recruitsModulesPlayer, type RecruitsModulesOptions, } from './fixture-recruits-modules'; /** * GF9 Ecaz & Moritani pp11,16: Recruits doubles every player's current free * revival rate and raises the ordinary force-revival limit to seven. The * independent-profile programme uses the real original first-Storm Axlotl * custodian, real battle Tanks and a real Revival; Tech Tokens stay original * and the Axlotl payout waits for that Revival phase to end. */ const CONFIGURATIONS: { label: string; options: RecruitsModulesOptions }[] = [ { label: 'Basic classic', options: {} }, { label: 'Advanced classic with Stronghold Cards', options: { advanced: true, strongholds: true } }, { label: 'Basic native Ixian', options: { roster: 'ix-native' } }, { label: 'Advanced native Ixian with Stronghold Cards', options: { roster: 'ix-native', advanced: true, strongholds: true } }, ]; for (const { label, options } of CONFIGURATIONS) { void test(`${label}: doubled physical revival retains its price and pays Axlotl once at phase end`, () => { const fixture = createRecruitsModulesFixture(options); const ordinary = new Map(fixture.game.players.map(player => [player.id, viewGame(fixture.game, player.id).revival.freeRemaining])); const original = fixture.game.revivalOrder ? advanceRecruitsModules(structuredClone(fixture.game), state => state.phase === 4 && state.revivalOrder?.remaining[0] === fixture.owner && recruitsModulesClean(state)) : structuredClone(fixture.game); const beforeRejected = structuredClone(original); assert.throws(() => applyAction(original, fixture.owner, fixture.revivalAction)); assert.deepEqual(original, beforeRejected, 'The old three-force allowance cannot consume these counters'); const activated = applyAction(fixture.game, fixture.owner, fixture.playAction); assert.equal(activated.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1); for (const player of activated.players) { const preview = viewGame(activated, player.id).recruitsPreview; const rate = preview?.rates.find(entry => entry.player === player.id); assert.equal(rate?.limit, 7); assert.equal(rate?.freeRate, Math.min(2 * ordinary.get(player.id)!, 7), 'Every seat retains its own doubled current rate'); } let returned = activated; let before: ReturnType | undefined; let afterOwner: ReturnType | undefined; let opponentReturned: typeof returned | undefined; for (const step of fixture.revivalSteps) { if (step.actor === fixture.owner && step.action.type === 'revive') before = structuredClone(recruitsModulesPlayer(returned, fixture.owner)); returned = applyAction(returned, step.actor, step.action); if (step.actor === fixture.owner && step.action.type === 'revive') afterOwner = structuredClone(recruitsModulesPlayer(returned, fixture.owner)); if (step.actor === fixture.opponent && step.action.type === 'revive') opponentReturned = structuredClone(returned); } assert.ok(before && afterOwner && opponentReturned); const owner = recruitsModulesPlayer(returned, fixture.owner); assert.equal(owner.revived, fixture.amount); assert.equal(owner.freeForcesRevived, fixture.doubled.freeRate); assert.equal(owner.reserves, before.reserves + fixture.amount); assert.equal(owner.tanks, 0); assert.equal(owner.spice, before.spice - 2 * (fixture.amount - fixture.doubled.freeRate), 'Only paid counters cost spice; pending token income cannot fund this request'); const accrual = ownedTech(returned.techTokens!, fixture.owner).length; assert.equal(returned.techTokens!.axlotl.spice, accrual); assert.equal(recruitsModulesPlayer(opponentReturned, fixture.opponent).freeForcesRevived, 1); assert.equal(opponentReturned.techTokens!.axlotl.spice, accrual, 'Both actual free revivals share one phase income'); assert.equal(owner.spice, afterOwner.spice, 'Neither free return collects the pending income'); const settled = advanceRecruitsModules(returned, state => state.phase === 5 && recruitsModulesClean(state)); assert.equal(recruitsModulesPlayer(settled, fixture.owner).spice, owner.spice + accrual, 'The phase boundary pays the original held income exactly once'); assert.equal(settled.techTokens!.axlotl.spice, 0); assertRecruitsModulesInventory(settled, fixture.afterSetup); }); }