import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, RuleError, viewGame, type Action, type Game } from '../game/engine'; import { combatArmy } from '../game/combat-location'; import { RECRUITS_CARD_ID } from '../game/recruits'; import { advanceNativeWorldRecruits, assertNativeWorldRecruitsInventory, createNativeWorldRecruitsFixture, nativeWorldRecruitsClean, nativeWorldRecruitsPlayer, requestNativeWorldRecruits, } from './fixture-native-world-recruits'; const play = { type: 'card', card: RECRUITS_CARD_ID } as const; function rejectsUnchanged(game: Game, actor: string, action: Action): void { const before = JSON.stringify(game); assert.throws(() => applyAction(game, actor, action), RuleError); assert.equal(JSON.stringify(game), before); } function continueToShipment(game: Game): Game { const next = advanceNativeWorldRecruits(game, g => g.turn === 2 && g.phase === 5 && nativeWorldRecruitsClean(g)); assert.ok(next.active, 'Original next Shipment must have its actual active seat'); assertNativeWorldRecruitsInventory(next); return next; } for (const advanced of [false, true]) { const mode = advanced ? 'Advanced' : 'Basic'; void test(`${mode} native Ix deposits paid cyborgs and free suboids, ${advanced ? 'keeps its opportunity open' : 'reopens'} with Recruits, then reprices after population five`, () => { const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians' }); const native = fixture.native; assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).spice, 10); assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).tanks, 14); assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).elites!.tanks, 4); assert.deepEqual(combatArmy(fixture.game, fixture.visitor, fixture.location), { normal: 1, elite: 0 }); assert.equal(nativeWorldRecruitsPlayer(fixture.afterShipment, fixture.visitor).spice, nativeWorldRecruitsPlayer(fixture.beforeShipment, fixture.visitor).spice - 1); const paid = requestNativeWorldRecruits(fixture.game, native, 3, 1, 0); assert.equal(paid.price, 3, 'Two free suboids leave one cyborg at its original three-spice price'); assert.equal(paid.walletDelta, 3); assert.equal(paid.free, 2); assert.deepEqual(paid.nativeAfter, { normal: 2, elite: 1 }); let game = paid.game; if (!advanced) game = applyAction(game, native, { type: 'ready' }); else assert.equal(game.revivalOrder?.remaining[0], native); game = applyAction(game, fixture.owner, play); assert.equal(nativeWorldRecruitsPlayer(game, native).spice, 7, 'Activation never refunds the paid cyborg'); assert.equal(viewGame(game, native).revival.freeRemaining, 2); const crossing = requestNativeWorldRecruits(game, native, 2, 2, 2); assert.equal(crossing.price, 0); assert.equal(crossing.free, 2); assert.deepEqual(crossing.nativeAfter, { normal: 2, elite: 3 }); game = crossing.game; const rate = viewGame(game, native).revival; assert.equal(rate.homeworldBonus, 0); assert.equal(rate.freeRemaining, 0, 'A separate request uses the now-high native World, not the original four-free rate'); assert.equal(rate.eliteRemaining, 1, 'Ix retains its native multiple-cyborg allowance in both modes'); rejectsUnchanged(game, native, { type: 'revive', amount: 2, elite: 2 }); const last = requestNativeWorldRecruits(game, native, 2, 1, 0); assert.equal(last.price, 5, 'One paid cyborg plus one paid suboid still costs five'); assert.equal(last.walletDelta, 5); assert.equal(last.ixSuboidBonus, 2, 'A paid Cyborg at current High returns two extra physical Suboids'); assert.deepEqual(last.nativeAfter, { normal: 5, elite: 4 }); assert.equal(nativeWorldRecruitsPlayer(last.game, native).freeForcesRevived, 4); assert.equal(nativeWorldRecruitsPlayer(last.game, native).revived, 7); assert.equal(nativeWorldRecruitsPlayer(last.game, native).spice, 2); assert.equal(nativeWorldRecruitsPlayer(last.game, native).tanks, 5); assert.equal(nativeWorldRecruitsPlayer(last.game, native).elites!.tanks, 0); rejectsUnchanged(last.game, native, { type: 'revive', amount: 1, elite: 0 }); assert.equal(last.game.discard.filter(c => c.id === RECRUITS_CARD_ID).length, 1); continueToShipment(last.game); }); void test(`${mode} the real mixed Ix Tanks retain the player's free-cyborg allocation and its distinct prices`, () => { const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians' }); const active = applyAction(fixture.game, fixture.owner, play); rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 8, elite: 3, freeElite: 0 }); rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 7, elite: 3, freeElite: 4 }); for (const freeElite of [0, 3]) { const returned = requestNativeWorldRecruits(structuredClone(active), fixture.native, 7, 3, freeElite); assert.equal(returned.free, 4); assert.equal(returned.price, freeElite === 0 ? 9 : 6); assert.equal(returned.walletDelta, freeElite === 0 ? 9 : 6); assert.deepEqual(returned.nativeAfter, { normal: 4, elite: 3 }); assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).elites!.tanks, 1); assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).tanks, 7); assertNativeWorldRecruitsInventory(returned.game); continueToShipment(returned.game); } }); void test(`${mode} native Tleilaxu accepts eleven in one current-rate request without the ordinary seven cap`, () => { const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'tleilaxu' }); let game = applyAction(fixture.game, fixture.owner, play); assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 5); rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 12, elite: 0 }); const returned = requestNativeWorldRecruits(game, fixture.native, 11); assert.equal(returned.price, 5, 'Six free and five half-price paid forces retain their accepted invoice'); assert.equal(returned.free, 6); assert.equal(returned.walletDelta, 4, 'Original own free-revival income pays one spice separately'); game = returned.game; assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 1); assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).revived, 11); assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 6); assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).tanks, 9); assert.deepEqual(returned.nativeAfter, { normal: 11, elite: 0 }); assert.equal(viewGame(game, fixture.native).revival.limit, 20); assert.equal(viewGame(game, fixture.native).revival.forcesRemaining, 9); assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 0); assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0); continueToShipment(game); }); void test(`${mode} paid Tleilaxu returns are not refunded and a separate crossing request loses the low-World free bonus`, () => { const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'tleilaxu' }); const first = requestNativeWorldRecruits(fixture.game, fixture.native, 8); assert.equal(first.price, 5); assert.equal(first.free, 3); assert.equal(first.walletDelta, 4); let game = applyAction(first.game, fixture.owner, play); assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 1); assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 3); const crossing = requestNativeWorldRecruits(game, fixture.native, 1); assert.equal(crossing.free, 1); assert.equal(crossing.price, 0); assert.equal(crossing.walletDelta, 0, 'Own free-revival income is not awarded a second time'); game = crossing.game; assert.deepEqual(crossing.nativeAfter, { normal: 9, elite: 0 }); assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 0); assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0); assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 4); rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 2, elite: 0 }); const paid = requestNativeWorldRecruits(game, fixture.native, 1); assert.equal(paid.price, 1); assert.equal(paid.walletDelta, 1); assert.equal(paid.free, 0); assert.deepEqual(paid.nativeAfter, { normal: 10, elite: 0 }); assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).freeForcesRevived, 4); assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).spice, 0); continueToShipment(paid.game); }); void test(`${mode} native CHOAM keeps unlimited one-spice returns and prior paid/free ledgers after Recruits`, () => { const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'choam' }); assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.native).spice, 15, 'Real CHOAM phase income funds the programme'); const first = requestNativeWorldRecruits(fixture.game, fixture.native, 8); assert.equal(first.price, 7); assert.equal(first.walletDelta, 7); assert.equal(first.free, 1); let game = applyAction(first.game, fixture.owner, play); assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 8); assert.equal(viewGame(game, fixture.native).revival.limit, 20); assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 1); const free = requestNativeWorldRecruits(game, fixture.native, 1); assert.equal(free.price, 0); assert.equal(free.free, 1); const paid = requestNativeWorldRecruits(free.game, fixture.native, 3); assert.equal(paid.price, 3); assert.equal(paid.walletDelta, 3); assert.equal(paid.free, 0); game = paid.game; assert.deepEqual(paid.nativeAfter, { normal: 12, elite: 0 }); assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 5); assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 2); assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).revived, 12); assert.equal(viewGame(game, fixture.native).revival.forcesRemaining, 8); assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0); rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 6, elite: 0 }); continueToShipment(game); }); void test(`${mode} native CHOAM can actually return seventeen with Recruits rather than merely quoting unlimited`, () => { const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'choam' }); const active = applyAction(fixture.game, fixture.owner, play); rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 18, elite: 0 }); const returned = requestNativeWorldRecruits(active, fixture.native, 17); assert.equal(returned.free, 2); assert.equal(returned.price, 15); assert.equal(returned.walletDelta, 15); assert.deepEqual(returned.nativeAfter, { normal: 17, elite: 0 }); assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).spice, 0); assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).tanks, 3); assert.equal(viewGame(returned.game, fixture.native).revival.forcesRemaining, 3); continueToShipment(returned.game); }); void test(`${mode} ordinary native Richese ${advanced ? 'keeps its opportunity open' : 'reopens a ready revival'}, receives six free plus one paid, and still stops at seven`, () => { const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'richese' }); const spice = nativeWorldRecruitsPlayer(fixture.game, fixture.native).spice; const first = requestNativeWorldRecruits(fixture.game, fixture.native, 3); assert.equal(first.price, 0); let game = first.game; if (!advanced) game = applyAction(game, fixture.native, { type: 'ready' }); else assert.equal(game.revivalOrder?.remaining[0], fixture.native); game = applyAction(game, fixture.owner, play); assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 3); rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 5, elite: 0 }); const free = requestNativeWorldRecruits(game, fixture.native, 3); assert.equal(free.price, 0); assert.equal(free.free, 3); const paid = requestNativeWorldRecruits(free.game, fixture.native, 1); assert.equal(paid.price, 2); assert.equal(paid.walletDelta, 2); assert.equal(paid.free, 0); assert.deepEqual(paid.nativeAfter, { normal: 7, elite: 0 }); assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).freeForcesRevived, 6); assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).tanks, 13); assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).spice, spice - 2); rejectsUnchanged(paid.game, fixture.native, { type: 'revive', amount: 1, elite: 0 }); continueToShipment(paid.game); }); void test(`${mode} actual World battle Sardaukar casualties retain the ordinary elite cap despite Recruits`, () => { const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians', imperialCasualties: true }); assert.equal(fixture.shipmentPrice, 2); assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.visitor).tanks, 2); assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.visitor).elites!.tanks, 2); let game = applyAction(fixture.game, fixture.owner, play); if (advanced) rejectsUnchanged(game, fixture.visitor, { type: 'revive', amount: 2, elite: 2 }); const returned = requestNativeWorldRecruits(game, fixture.visitor, advanced ? 1 : 2, advanced ? 1 : 2); assert.equal(returned.price, 0); assert.equal(returned.walletDelta, 0); game = returned.game; const after = nativeWorldRecruitsPlayer(game, fixture.visitor); assert.equal(after.elites!.tanks, advanced ? 1 : 0); assert.equal(after.elites!.reserves, advanced ? 4 : 5); const worlds = viewGame(game, fixture.visitor).homeworlds?.worlds; assert.ok(worlds, 'The visitor needs its native World custody view'); const destination = worlds.find(w => w.native === fixture.visitor && w.secondary === advanced); assert.ok(destination); assert.equal((destination.forces[fixture.visitor] ?? { normal: 0, elite: 0 }).elite, advanced ? 4 : 5); if (advanced) rejectsUnchanged(game, fixture.visitor, { type: 'revive', amount: 1, elite: 1 }); continueToShipment(game); }); } void test('Advanced all14 native Tleilaxu keeps its unlimited return, accepted low-World invoice and separate own free income', () => { const f = createNativeWorldRecruitsFixture({ advanced: true, skills: true, faction: 'tleilaxu', training: { emperor: 'mentat', tleilaxu: 'swordmaster-of-ginaz', guild: 'spice-banker' } }); const active = applyAction(f.game, f.owner, play); const returned = requestNativeWorldRecruits(active, f.native, 11); assert.deepEqual({ price: returned.price, free: returned.free, wallet: returned.walletDelta }, { price: 5, free: 6, wallet: 4 }); assert.deepEqual(returned.nativeAfter, { normal: 11, elite: 0 }); assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).spice, 1); assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).revived, 11); assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).freeForcesRevived, 6); assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).tanks, 9); const current = viewGame(returned.game, f.native).revival; assert.deepEqual({ limit: current.limit, remaining: current.forcesRemaining, free: current.freeRemaining, bonus: current.homeworldBonus }, { limit: 20, remaining: 9, free: 0, bonus: 0 }); const receipt = returned.game.spiceBankerIncome!.sources.at(-1)!; assert.equal(receipt.source.kind, 'force-revival'); assert.deepEqual(receipt.source.bankLegs, [{ payer: f.native, amount: 5 }], 'The actual paid invoice, not its net four-spice wallet delta, is the bank leg'); assert.equal(receipt.grant?.owner, 'guild'); const next = continueToShipment(returned.game); assert.equal(nativeWorldRecruitsPlayer(next, f.native).spice, 1); assert.equal(nativeWorldRecruitsPlayer(next, f.native).revived, 11); assert.equal(nativeWorldRecruitsPlayer(next, f.native).freeForcesRevived, 6); assert.equal(viewGame(next, 'guild').spiceBankerIncome!.deferred .find(row => row.owner === 'guild')?.amount, 1, 'Banker is not paid at Revival end'); });