import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, initializeDiscoveryGameForAudit, viewGame, type Action, type Game, } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { ownedTech } from '../game/tech-tokens'; import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; import { forceRevivalQuote } from '../game/revival'; import { enterHomeworldClassicDiscovery, homeworldClassicDiscoveryInventory, revealHomeworldClassicDiscovery, } from './fixture-homeworld-classic-discovery'; import { advanceHomeworldOptional as advance, claimHomeworldOptional, createHomeworldOptionalFixture as fixture, createHomeworldOptionalLobby, finishHomeworldOptionalBattle, initializeHomeworldOptional, invadeHomeworldOptional, nextHomeworldOptionalStep, openHomeworldOptionalBattle, optionalHomeworldClean as clean, optionalHomeworldLeader as leader, optionalHomeworldMovementWindow as movement, optionalHomeworldPlayer as player, settleHomeworldOptionalArrival, shipHomeworldOptional, } from './fixture-homeworld-optional-modules'; /** Authorized root rulebook physical pp.21–23: optional modules together or * separately, original first-Storm token assignment/phase-end collections, * winner transfer, end-Mentat Stronghold custody, off-planet native reserves and * combined Imperial shipment. Territory-local effects use the physical original * Arrakeen/Carthag faces (docs/STRONGHOLD_CARDS.md). All armies, casualties, * funding, held cards and rewards below originate in original engine actions. */ function reject(game: Game, actor: string, action: Action): void { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); } function legalPolicies(game: Game, actor: string): void { for (const difficulty of DIFFICULTIES) { const view = viewGame(game, actor); view.players.find(seat => seat.id === actor)!.bot = difficulty; const actions = botActions(view); assert.ok(actions.length, `${difficulty} has an actual owned continuation.`); for (const action of actions) homeworldClassicDiscoveryInventory(applyAction(structuredClone(game), actor, action)); } } function beforeMovementEnd(state: Game): Game { let game = state; for (let i = 0; i < 200; i++) { assert.equal(game.phase, 5); const step = nextHomeworldOptionalStep(game); const after = applyAction(structuredClone(game), step.actor, step.action); if (after.phase !== 5) return game; game = after; } throw Error('Original Shipping never reached its phase-end action.'); } function endMovementPhase(game: Game): Game { const last = nextHomeworldOptionalStep(game), after = applyAction(game, last.actor, last.action); assert.notEqual(after.phase, 5); return after; } function resolveToCleanup(state: Game): Game { return advance(state, game => game.decision?.kind === 'battleCards'); } void test('original no-Skills Homeworld Discovery admits Basic/Advanced 2–6 and original optional components only at their real seat/mode boundaries', () => { for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) { const options = { advanced, seats, tech: seats >= 3, strongholds: advanced }; const f = fixture(options); assert.equal(f.game.players.length, seats); assert.equal(f.game.phase, 7); assert.equal(f.game.turn, 1); assert.equal(f.game.leaderSkills, undefined); homeworldClassicDiscoveryInventory(f.setup); homeworldClassicDiscoveryInventory(f.game); if (seats >= 3) { assert.equal(f.beforeFirstStorm.techTokens!.production.owner, f.fremen); assert.equal(f.beforeFirstStorm.techTokens!.axlotl.owner, null); assert.equal(f.beforeFirstStorm.techTokens!.heighliners.owner, null); const assigned = Object.values(f.afterFirstStorm.techTokens!); assert.ok(assigned.every(token => token.owner)); assert.equal(new Set(assigned.map(token => token.owner)).size, 3); assert.ok(assigned.every(token => token.spice === 0)); } if (advanced) { const claim = claimHomeworldOptional(f.game); assert.equal(claim.before.strongholdCards!.claimedTurn, 0); assert.ok(Object.values(claim.before.strongholdCards!.owners).every(owner => owner === null)); assert.equal(claim.after.strongholdCards!.claimedTurn, 1); if (seats >= 4) assert.equal(claim.after.strongholdCards!.owners.arrakeen, 'atreides'); if (seats >= 5) assert.equal(claim.after.strongholdCards!.owners.carthag, 'harkonnen'); assert.equal(claim.after.strongholdCards!.owners.hidden_mobile_stronghold, null); homeworldClassicDiscoveryInventory(claim.after); } } assert.throws(() => initializeHomeworldOptional({ seats: 2, tech: true })); assert.throws(() => initializeHomeworldOptional({ advanced: false, strongholds: true })); // Invalid ORIGINAL component admission is checked by the actual initializer, // not by mirroring the profile predicate in an expectation. const original = createHomeworldOptionalLobby({ tech: true, strongholds: true }); for (const corrupt of [ (game: Game) => { game.techTokens!.axlotl.owner = game.host; }, (game: Game) => { game.techTokens!.heighliners.spice = 1; }, (game: Game) => { game.strongholdCards!.owners.arrakeen = game.host; }, (game: Game) => { game.strongholdCards!.claimedTurn = 1; }, ]) { let changed = structuredClone(original); corrupt(changed); for (const seat of changed.players) changed = applyAction(changed, seat.id, { type: 'ready' }); assert.throws(() => initializeDiscoveryGameForAudit(changed)); } }); void test('combined Imperial off-planet shipment and another actual invoice accrue one Heighliner payment, collected once at original Shipping end', () => { const f = fixture({ tech: true, strongholds: true }); let game = advance(revealHomeworldClassicDiscovery(f), g => g.turn === 2 && g.phase === 5 && clean(g)); const owner = game.techTokens!.heighliners.owner!; const remaining = new Set([f.collector, 'harkonnen']); while (remaining.size) { game = advance(game, g => g.turn === 2 && g.phase === 5 && clean(g) && remaining.has(g.active!)); const actor = game.active!; if (actor === f.collector) { const sources = { 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } }; const ownBefore = structuredClone(player(game, actor)); const homesBefore = structuredClone(viewGame(game, actor).homeworlds!.worlds!); const target = homesBefore.find(home => home.native === f.fremen)!; const choice = homeworldShipmentChoice(viewGame(game, actor), target.id, sources); assert.ok(choice.action); legalPolicies(game, actor); game = settleHomeworldOptionalArrival(applyAction(game, actor, choice.action)); assert.equal(player(game, actor).spice, ownBefore.spice - 2); assert.equal(player(game, actor).reserves, ownBefore.reserves - 2); const homesAfter = viewGame(game, actor).homeworlds!.worlds!; for (const id of Object.keys(sources)) { assert.equal(homesAfter.find(home => home.id === id)!.forces[actor].normal, homesBefore.find(home => home.id === id)!.forces[actor].normal - 1); } assert.deepEqual(homesAfter.find(home => home.id === target.id)!.forces[actor], { normal: 2, elite: 0 }); } else { game = shipHomeworldOptional(game, actor, 'cistern', 0, 1); } assert.equal(game.techTokens!.heighliners.spice, 1, 'Two real paid invoices still accrue one industry event.'); assert.equal(game.techTokens!.heighliners.triggeredTurn, 2); remaining.delete(actor); if (remaining.size) game = applyAction(game, actor, { type: 'endMovement' }); } const last = beforeMovementEnd(game), balance = player(last, owner).spice; const paid = endMovementPhase(last); assert.equal(player(paid, owner).spice, balance + 1); assert.equal(paid.techTokens!.heighliners.spice, 0); assert.equal(paid.techTokens!.axlotl.spice, 0); assert.equal(paid.techTokens!.production.spice, 0); homeworldClassicDiscoveryInventory(paid); }); void test('Guild-only actual Homeworld transport is excluded, while a combined Imperial nested shipment pays the printed single tariff and triggers Heighliners', () => { const f = fixture({ tech: true, strongholds: true }); let guild = movement(f.game, 'guild'); const guildBefore = player(guild, 'guild').spice; guild = invadeHomeworldOptional(guild, 'guild', 'homeworld:emperor', 1); assert.equal(player(guild, 'guild').spice, guildBefore - 1); assert.equal(guild.techTokens!.heighliners.spice, 0); assert.equal(guild.techTokens!.heighliners.triggeredTurn, 1); const end = beforeMovementEnd(guild), owner = end.techTokens!.heighliners.owner!, balance = player(end, owner).spice; const excluded = endMovementPhase(end); assert.equal(player(excluded, owner).spice, balance); assert.equal(excluded.techTokens!.heighliners.spice, 0); let nested = movement(enterHomeworldClassicDiscovery(f), f.collector); const before = structuredClone(player(nested, f.collector)); nested = shipHomeworldOptional(nested, f.collector, 'cistern', 0, 2, 0, { 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } }); assert.equal(player(nested, f.collector).spice, before.spice - 2); assert.equal(player(nested, f.collector).forces['cistern:0'], before.forces['cistern:0'] + 2); assert.equal(nested.techTokens!.heighliners.spice, 1); homeworldClassicDiscoveryInventory(nested); }); void test('real low-Caladan poverty claim and three free force returns trigger distinct phase-end industries without charging or recursively paying', () => { for (const advanced of [false, true]) { const setup = initializeHomeworldOptional({ advanced, tech: true, strongholds: advanced }); let game = advance(setup, game => game.status === 'playing'); // Select only original unplayed ordinary blows, avoiding incidental income // at the future expedition site. No phase, wallet or force is assigned. for (let position = 0; position < 4; position++) { const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery && card.territory !== 'imperial_basin'); assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); } game = movement(game, 'emperor', 1); game = shipHomeworldOptional(game, 'emperor', 'habbanya_ridge_sietch', 17, 1); game = movement(game, 'atreides', 1); game = shipHomeworldOptional(game, 'atreides', 'habbanya_ridge_sietch', 17, 9); game = applyAction(game, 'atreides', { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount: 10 }); assert.equal(player(game, 'atreides').spice, 1); assert.equal(player(game, 'atreides').reserves, 1); game = openHomeworldOptionalBattle(game, 'habbanya_ridge_sietch', 'atreides', 'emperor'); game = applyAction(game, 'atreides', { type: 'battlePlan', leader: leader(game, 'atreides', false), dial: 0, support: 0 }); game = applyAction(game, 'emperor', { type: 'battlePlan', leader: leader(game, 'emperor', true), dial: 0, support: 0 }); game = finishHomeworldOptionalBattle(game); assert.equal(player(game, 'atreides').tanks, 9); game = advance(game, game => game.turn === 2 && game.phase === 2 && clean(game)); assert.equal(player(game, 'atreides').spice, 1); assert.equal(viewGame(game, 'atreides').charity.amount, 2); const productionOwner = game.techTokens!.production.owner!, prodBefore = player(game, productionOwner).spice; game = applyAction(game, 'atreides', { type: 'charity' }); assert.equal(player(game, 'atreides').spice, 3); assert.equal(player(game, productionOwner).spice, prodBefore); assert.equal(game.techTokens!.production.spice, ownedTech(game.techTokens, productionOwner).length); reject(game, 'atreides', { type: 'charity' }); const beforeCharityEnd = advance(game, game => game.phase === 2 && clean(game) && game.ready.length === game.players.length - 1); const productionIncome = beforeCharityEnd.techTokens!.production.spice; const productionBalance = player(beforeCharityEnd, productionOwner).spice; const afterCharity = advance(beforeCharityEnd, game => game.phase === 3 && clean(game)); assert.equal(player(afterCharity, productionOwner).spice, productionBalance + productionIncome); assert.equal(afterCharity.techTokens!.production.spice, 0); game = advance(afterCharity, game => game.phase === 4 && clean(game)); const returning = structuredClone(player(game, 'atreides')); assert.equal(forceRevivalQuote(game, returning, 3).cost, 0, 'Low home adds the third free counter to Atreides printed two.'); legalPolicies(game, 'atreides'); game = applyAction(game, 'atreides', { type: 'revive', amount: 3, elite: 0 }); game = advance(game, game => !game.pendingRevival && clean(game)); assert.equal(player(game, 'atreides').spice, returning.spice); assert.equal(player(game, 'atreides').tanks, returning.tanks - 3); assert.equal(player(game, 'atreides').reserves, returning.reserves + 3); const axlotlOwner = game.techTokens!.axlotl.owner!, accrued = game.techTokens!.axlotl.spice; assert.equal(accrued, ownedTech(game.techTokens, axlotlOwner).length); assert.equal(game.techTokens!.production.spice, 0); const beforeRevivalEnd = advance(game, game => game.phase === 4 && clean(game) && game.ready.length === game.players.length - 1); const axlotlBalance = player(beforeRevivalEnd, axlotlOwner).spice; const afterRevival = advance(beforeRevivalEnd, game => game.phase === 5 && clean(game)); assert.equal(player(afterRevival, axlotlOwner).spice, axlotlBalance + accrued); assert.equal(afterRevival.techTokens!.axlotl.spice, 0); assert.equal(afterRevival.techTokens!.heighliners.spice, 0); homeworldClassicDiscoveryInventory(afterRevival); } }); void test('actually held Arrakeen subsidy and Carthag Shield apply at their printed Arrakis sites, never at native Homeworld or Cistern battles', () => { for (const kind of ['support', 'defense'] as const) for (const location of ['printed', 'homeworld', 'nested'] as const) { const f = fixture({ tech: true, strongholds: true }); const owner = kind === 'support' ? 'atreides' : 'harkonnen'; const printed = kind === 'support' ? 'arrakeen' : 'carthag'; const territory = location === 'printed' ? printed : location === 'homeworld' ? `homeworld:${owner}` : 'cistern'; let game = location === 'nested' ? enterHomeworldClassicDiscovery(f) : f.game; assert.equal(game.strongholdCards?.owners[printed], location === 'nested' ? owner : null); if (location === 'nested') { game = movement(game, owner); game = shipHomeworldOptional(game, owner, territory, 0, kind === 'support' ? 3 : 2); } else { game = movement(game, f.collector); assert.equal(game.strongholdCards!.owners[printed], owner); game = location === 'homeworld' ? invadeHomeworldOptional(game, f.collector, territory, 1) : shipHomeworldOptional(game, f.collector, territory, printed === 'arrakeen' ? 10 : 11, 1); } game = openHomeworldOptionalBattle(game, territory, owner, f.collector); const view = viewGame(game, owner), ownBefore = structuredClone(player(game, owner)); assert.equal(view.battle!.strongholdEffects[owner], location === 'printed' ? printed : null); const trainer = leader(game, owner, true), invader = leader(game, f.collector, kind === 'defense'); const defense = player(game, owner).hand.find(card => card.kind === 'shield')!; assert.ok(defense); const weapon = player(game, f.collector).hand.find(card => card.kind === 'poison')!; assert.ok(weapon); legalPolicies(game, owner); game = applyAction(game, owner, { type: 'battlePlan', leader: trainer, defense: defense.id, dial: kind === 'support' ? 2 : 0, support: kind === 'support' ? 2 : 0 }); game = applyAction(game, f.collector, { type: 'battlePlan', leader: invader, dial: 0, support: 0, ...(kind === 'defense' ? { weapon: weapon.id } : {}) }); game = resolveToCleanup(game); assert.equal(game.lastBattleContext!.territory, territory); if (kind === 'support') { assert.equal(player(game, owner).spice, ownBefore.spice - (location === 'printed' ? 0 : 2)); assert.equal(game.lastBattleContext!.winner, owner); } else { assert.equal(player(game, owner).leaders.find(disc => disc.id === trainer)!.dead, location !== 'printed'); assert.equal(game.lastBattleContext!.winner, location === 'printed' ? owner : f.collector); assert.equal(game.discard.some(card => card.id === defense.id), location !== 'printed'); } assert.equal(game.strongholdCards!.owners[printed], owner, 'Combat never silently transfers physical Stronghold card custody.'); homeworldClassicDiscoveryInventory(game); } }); void test('real native Homeworld victory earns a second token, then real visitor victory offers original winner choice only after physical cleanup', () => { for (const advanced of [false, true]) { const f = fixture({ advanced, tech: true, strongholds: advanced }); let game = movement(f.game, f.collector); game = invadeHomeworldOptional(game, f.collector, 'homeworld:fremen', 1); game = openHomeworldOptionalBattle(game, 'homeworld:fremen', f.collector, f.fremen); assert.equal(ownedTech(game.techTokens, f.collector).length, 1); const nativeBefore = player(game, f.fremen).reserves; game = applyAction(game, f.collector, { type: 'battlePlan', leader: leader(game, f.collector, false), dial: 0, support: 0 }); game = applyAction(game, f.fremen, { type: 'battlePlan', leader: leader(game, f.fremen, true), dial: 0, support: 0 }); game = finishHomeworldOptionalBattle(game); assert.equal(game.lastBattleContext!.winner, f.fremen); assert.equal(player(game, f.fremen).reserves, nativeBefore); assert.equal(player(game, f.collector).tanks, 1); assert.equal(ownedTech(game.techTokens, f.fremen).length, 2); game = movement(game, f.collector, 3); game = invadeHomeworldOptional(game, f.collector, 'homeworld:fremen', 2); game = openHomeworldOptionalBattle(game, 'homeworld:fremen', f.collector, f.fremen); const tokens = ownedTech(game.techTokens, f.fremen), originalTech = structuredClone(game.techTokens); const visitorBefore = structuredClone(player(game, f.collector)); const nativeBeforeSecond = structuredClone(player(game, f.fremen)); const weapon = visitorBefore.hand.find(card => card.kind === 'poison')!; assert.ok(weapon); game = applyAction(game, f.collector, { type: 'battlePlan', leader: leader(game, f.collector, true), dial: 1, support: advanced ? 1 : 0, weapon: weapon.id }); game = applyAction(game, f.fremen, { type: 'battlePlan', leader: leader(game, f.fremen, false), dial: 0, support: 0 }); const cleanup = resolveToCleanup(game); assert.equal(cleanup.lastBattleContext!.winner, f.collector); assert.deepEqual(cleanup.techTokens, originalTech); assert.equal(cleanup.decision!.player, f.collector); assert.equal(player(cleanup, f.collector).tanks, visitorBefore.tanks + 1); assert.equal(player(cleanup, f.fremen).tanks, nativeBeforeSecond.tanks + nativeBeforeSecond.reserves); game = applyAction(cleanup, f.collector, { type: 'decision', discard: [] }); game = advance(game, game => game.decision?.kind === 'techToken'); assert.ok(game.decision?.kind === 'techToken'); assert.equal(game.decision.player, f.collector); assert.deepEqual([...game.decision.choices].sort(), [...tokens].sort()); assert.deepEqual(game.techTokens, originalTech); assert.equal(player(game, f.collector).hand.some(card => card.id === weapon.id), true); const physicalHome = viewGame(game, f.collector).homeworlds!.worlds!.find(home => home.native === f.fremen)!; assert.deepEqual(physicalHome.forces[f.collector], { normal: 1, elite: 0 }); assert.deepEqual(physicalHome.forces[f.fremen], { normal: 0, elite: 0 }); legalPolicies(game, f.collector); for (const token of tokens) { reject(game, f.fremen, { type: 'decision', token }); const chosen = applyAction(structuredClone(game), f.collector, { type: 'decision', token }); assert.equal(chosen.techTokens![token].owner, f.collector); assert.equal(ownedTech(chosen.techTokens, f.fremen).length, 1); assert.equal(chosen.lastBattleContext!.winner, f.collector); const chosenHome = viewGame(chosen, f.collector).homeworlds!.worlds!.find(home => home.id === physicalHome.id)!; assert.deepEqual(chosenHome.forces, physicalHome.forces, 'Tech selection changes token custody, never the actual winner army.'); homeworldClassicDiscoveryInventory(chosen); } } });