import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { nexusEmperorRevivalAction } from '../game/nexus-emperor-secret-ally-options'; import { nexusFremenRevivalAction } from '../game/nexus-fremen-revival-options'; import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival'; import { quoteNexusGuildSecretShipment } from '../game/nexus-guild-secret-ally'; import { ownedTech } from '../game/tech-tokens'; import { territory } from '../game/board'; import { quoteSpiceCollection } from '../game/board-resolution-quote'; import { holdNexusSkillsModulesPaymentsCard } from './fixture-nexus-skills-modules-payments'; import { advanceDiscoveryClassicNexusEffects, advanceDiscoveryClassicNexusEffectsToPhase, createDiscoveryClassicNexusEffectsFixture, discoveryClassicNexusEffectsInventory, discoveryClassicNexusEffectsPlayer, finishDiscoveryClassicNexusEffectsInteraction, finishDiscoveryClassicNexusNestedBattle, initializeDiscoveryClassicNexusEffectsSetup, openDiscoveryClassicNexusNestedBattle, payDiscoveryClassicNexusEffectsFixture, type DiscoveryClassicNexusEffectsFixture, } from './fixture-discovery-classic-nexus-effects'; const wallets = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice])); function rejected(game: Game, actor: string, action: Action): void { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before, 'Rejected declarations retain original physical cards, counters and payment ledgers'); } function legalPolicies(game: Game, actor: string): void { assert.equal(DIFFICULTIES.length, 4); for (const difficulty of DIFFICULTIES) { const view = viewGame(game, actor); view.players.find(player => player.id === actor)!.bot = difficulty; const actions = botActions(view); assert.ok(actions.length > 0, `${difficulty} must expose an original legal continuation`); for (const action of actions) discoveryClassicNexusEffectsInventory(applyAction(structuredClone(game), actor, action)); } } function sourceTransitions(fixture: DiscoveryClassicNexusEffectsFixture): void { const revealer = fixture.kind === 'guild-shipment' ? fixture.opponent : fixture.actor; const beforeToken = fixture.beforeReveal.discoveries!.tokens.find(token => token.id === fixture.token)!; const afterToken = fixture.afterReveal.discoveries!.tokens.find(token => token.id === fixture.token)!; assert.equal(beforeToken.face, 'shrine'); assert.equal(beforeToken.revealedTurn, null); assert.equal(afterToken.revealedTurn, 1); assert.equal(fixture.beforeEntry.turn, 2); assert.equal(fixture.beforeEntry.phase, 0); const before = discoveryClassicNexusEffectsPlayer(fixture.beforeEntry, revealer); const after = discoveryClassicNexusEffectsPlayer(fixture.afterEntry, revealer); assert.equal(before.forces[fixture.source], 2); assert.equal(after.forces[fixture.source] ?? 0, 0); assert.equal(after.forces['shrine:0'], 2); assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks); assert.equal(after.spice, before.spice); assert.equal(after.shipped, before.shipped); assert.equal(after.moved, before.moved); assert.deepEqual(fixture.afterEntry.techTokens, fixture.beforeEntry.techTokens, 'Free next-turn entry does not trigger paid shipping industry'); assert.equal(fixture.beforeClosingDraw.nexusCards!.phase!.stage, 'drawing'); assert.equal(fixture.beforeClosingDraw.players.find(player => player.id === fixture.actor)!.ally, null); assert.equal(fixture.afterClosingDraw.nexusCards!.cards!.hands[fixture.actor], fixture.kind === 'guild-shipment' ? 'guild' : fixture.kind === 'fremen-revival' ? 'fremen' : 'emperor'); discoveryClassicNexusEffectsInventory(fixture.game); } function unchangedCustody(before: Game, after: Game): void { assert.deepEqual(after.discoveries, before.discoveries); assert.deepEqual(after.strongholdCards, before.strongholdCards); for (const player of before.players) assert.deepEqual(viewGame(after, player.id).discoveries, viewGame(before, player.id).discoveries); discoveryClassicNexusEffectsInventory(after); } const configurations = [ { name: 'Basic', advanced: false, tech: false, strongholds: false }, { name: 'Basic Tech', advanced: false, tech: true, strongholds: false }, { name: 'Advanced', advanced: true, tech: false, strongholds: false }, { name: 'Advanced Tech', advanced: true, tech: true, strongholds: false }, { name: 'Advanced Strongholds', advanced: true, tech: false, strongholds: true }, { name: 'Advanced Tech/Strongholds', advanced: true, tech: true, strongholds: true }, ] as const; for (const configuration of configurations) for (const kind of ['emperor-revival', 'fremen-revival'] as const) { void test(`${configuration.name} classic Discovery/Nexus ${kind} uses the real source, separate quota and once-only phase-end industry`, () => { const fixture = createDiscoveryClassicNexusEffectsFixture({ ...configuration, kind }); sourceTransitions(fixture); if (configuration.strongholds) assert.equal(fixture.afterEntry.strongholdCards!.owners.arrakeen, fixture.actor); const privateView = viewGame(fixture.game, fixture.actor); const control = kind === 'emperor-revival' ? nexusEmperorRevivalAction(privateView, 0) : nexusFremenRevivalAction(privateView); assert.ok(control, 'Human controls expose the real owner-only card offer'); assert.equal(kind === 'emperor-revival' ? viewGame(fixture.game, fixture.opponent).nexusEmperorSecretAlly : viewGame(fixture.game, fixture.opponent).nexusFremenRevival, null); legalPolicies(fixture.game, fixture.actor); rejected(fixture.game, fixture.opponent, fixture.paymentAction); rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, event: 'stale' }); rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, bonus: true }); const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor); const activated = applyAction(structuredClone(fixture.game), fixture.actor, control); if (kind === 'fremen-revival') { assert.deepEqual(discoveryClassicNexusEffectsPlayer(activated, fixture.actor), before, 'Quota activation returns no forces and changes no ordinary ledger'); assert.deepEqual(activated.techTokens, fixture.game.techTokens, 'Only the subsequent native return can earn Axlotl income'); } let paid = finishDiscoveryClassicNexusEffectsInteraction(fixture.revivalAction ? applyAction(activated, fixture.actor, fixture.revivalAction) : activated); const after = discoveryClassicNexusEffectsPlayer(paid, fixture.actor); assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3); assert.deepEqual(wallets(paid), wallets(fixture.game)); assert.equal(after.revived, kind === 'fremen-revival' ? 3 : before.revived); assert.equal(after.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : before.freeForcesRevived ?? 0); assert.equal(forceRevivalRemaining(paid, after), kind === 'fremen-revival' ? 0 : 3); assert.equal(paid.nexusCards!.cards!.hands[fixture.actor], null); const card = kind === 'emperor-revival' ? 'emperor' : 'fremen'; assert.equal(paid.nexusCards!.cards!.discard.filter(value => value === card).length, 1); const history = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory! : paid.nexusFremenRevivalHistory!; assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.actor); assert.equal(history[0].turn, 2); assert.equal(history[0].phase, 4); if (kind === 'emperor-revival') { const receipt = paid.nexusEmperorSecretHistory![0]; assert.ok(receipt.after, 'The requested Nexus return must settle before physical receipt consumption.'); assert.equal(receipt.before.tanks - receipt.after.tanks, 3); assert.equal(receipt.before.spice, receipt.after.spice); } else { const grant = paid.nexusFremenRevivalHistory![0]; assert.equal(grant.revivedAtUse, before.revived); assert.equal(grant.freeAtUse, before.freeForcesRevived ?? 0); } unchangedCustody(fixture.game, paid); rejected(paid, fixture.actor, fixture.paymentAction); if (kind === 'emperor-revival') { const quote = forceRevivalQuote(paid, after, 3); assert.equal(quote.cost, 2); paid = finishDiscoveryClassicNexusEffectsInteraction(applyAction(paid, fixture.actor, { type: 'revive', amount: 3 })); const ordinary = discoveryClassicNexusEffectsPlayer(paid, fixture.actor); assert.equal(ordinary.reserves, before.reserves + 6); assert.equal(ordinary.tanks, before.tanks - 6); assert.equal(ordinary.revived, 3); assert.equal(ordinary.freeForcesRevived, 2); assert.equal(ordinary.spice, before.spice - 2); } else rejected(paid, fixture.actor, { type: 'revive', amount: 1 }); if (configuration.tech) { assert.equal(paid.techTokens!.axlotl.spice, fixture.tokenCount); assert.equal(paid.techTokens!.axlotl.triggeredTurn, 2); } const expected = wallets(paid); if (fixture.tokenOwner) expected[fixture.tokenOwner] += fixture.tokenCount; const movement = advanceDiscoveryClassicNexusEffectsToPhase(paid, 5); assert.deepEqual(wallets(movement), expected, 'Only real Revival-end Axlotl credit becomes spendable'); if (configuration.tech) assert.equal(movement.techTokens!.axlotl.spice, 0); const completedMovement = advanceDiscoveryClassicNexusEffectsToPhase(movement, 6); const afterMovement = { ...expected }; if (completedMovement.phase >= 7) for (const receipt of quoteSpiceCollection(movement).receipts) afterMovement[receipt.player] += receipt.strongholds + receipt.collected; assert.deepEqual(wallets(completedMovement), afterMovement, 'Only independently quoted Collection can accompany an auto-skipped empty Battle; Axlotl cannot pay twice'); const nextTurn = advanceDiscoveryClassicNexusEffects(completedMovement, game => game.turn === 3); const nextRevival = advanceDiscoveryClassicNexusEffectsToPhase(nextTurn, 4); assert.equal(discoveryClassicNexusEffectsPlayer(nextRevival, fixture.actor).revived, 0); assert.equal(discoveryClassicNexusEffectsPlayer(nextRevival, fixture.actor).freeForcesRevived ?? 0, 0); assert.deepEqual(kind === 'emperor-revival' ? nextRevival.nexusEmperorSecretHistory : nextRevival.nexusFremenRevivalHistory, history); rejected(nextRevival, fixture.actor, fixture.paymentAction); discoveryClassicNexusEffectsInventory(nextRevival); }); } void test('Advanced Discovery Emperor extra-three and Fremen free-three retain actual native elite subsets and the shared one-elite cap', () => { for (const kind of ['emperor-revival', 'fremen-revival'] as const) { const fixture = createDiscoveryClassicNexusEffectsFixture({ kind, advanced: true, tech: true, strongholds: true, ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 }); const activated = kind === 'fremen-revival' ? applyAction(structuredClone(fixture.game), fixture.actor, fixture.paymentAction) : fixture.game; rejected(activated, fixture.actor, kind === 'fremen-revival' ? { type: 'revive', amount: 3, elite: 2 } : { ...fixture.paymentAction, elite: 2 }); legalPolicies(fixture.game, fixture.actor); const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor); const paid = finishDiscoveryClassicNexusEffectsInteraction(payDiscoveryClassicNexusEffectsFixture(fixture)); const after = discoveryClassicNexusEffectsPlayer(paid, fixture.actor); assert.equal(after.elites!.reserves, before.elites!.reserves + 1); assert.equal(after.elites!.tanks, before.elites!.tanks - 1); assert.equal(after.elites!.revived, 1); assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3); assert.deepEqual(wallets(paid), wallets(fixture.game)); rejected(paid, fixture.actor, { type: 'revive', amount: 1, elite: 1 }); unchangedCustody(fixture.game, paid); } }); void test('Discovery ordinary-first Emperor return stays extra; Fremen grants only the remaining ordinary free quota', () => { const emperor = createDiscoveryClassicNexusEffectsFixture({ advanced: true, tech: true, strongholds: true }); const ordinary = finishDiscoveryClassicNexusEffectsInteraction(applyAction(emperor.game, emperor.actor, { type: 'revive', amount: 3 })); const paid = payDiscoveryClassicNexusEffectsFixture(emperor, ordinary); const before = discoveryClassicNexusEffectsPlayer(ordinary, emperor.actor), after = discoveryClassicNexusEffectsPlayer(paid, emperor.actor); assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3); assert.equal(after.spice, before.spice); assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 2); assert.deepEqual(paid.techTokens, ordinary.techTokens); const fremen = createDiscoveryClassicNexusEffectsFixture({ kind: 'fremen-revival', advanced: true, tech: true }); const late = finishDiscoveryClassicNexusEffectsInteraction(applyAction(fremen.game, fremen.actor, { type: 'revive', amount: 1 })); const granted = applyAction(late, fremen.actor, fremen.paymentAction); assert.deepEqual(discoveryClassicNexusEffectsPlayer(granted, fremen.actor), discoveryClassicNexusEffectsPlayer(late, fremen.actor)); rejected(granted, fremen.actor, { type: 'revive', amount: 3 }); const returned = finishDiscoveryClassicNexusEffectsInteraction(applyAction(granted, fremen.actor, { type: 'revive', amount: 2, elite: 0 })); assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).tanks, 3); assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).revived, 3); assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).freeForcesRevived, 3); assert.equal(discoveryClassicNexusEffectsPlayer(returned, fremen.actor).spice, discoveryClassicNexusEffectsPlayer(late, fremen.actor).spice); unchangedCustody(late, returned); const shortEmperor = createDiscoveryClassicNexusEffectsFixture({ tanks: 2 }); rejected(shortEmperor.game, shortEmperor.actor, shortEmperor.paymentAction); const shortFremen = createDiscoveryClassicNexusEffectsFixture({ kind: 'fremen-revival', tanks: 2 }); rejected(shortFremen.game, shortFremen.actor, shortFremen.paymentAction); const shortReturn = finishDiscoveryClassicNexusEffectsInteraction(applyAction(shortFremen.game, shortFremen.actor, { type: 'revive', amount: 2, elite: 0 })); assert.equal(discoveryClassicNexusEffectsPlayer(shortReturn, shortFremen.actor).tanks, 0); assert.equal(discoveryClassicNexusEffectsPlayer(shortReturn, shortFremen.actor).revived, 2); assert.equal(discoveryClassicNexusEffectsPlayer(shortReturn, shortFremen.actor).freeForcesRevived, 2); assert.equal(shortReturn.nexusCards!.cards!.hands[shortFremen.actor], 'fremen', 'Both available counters already have native free entitlement; keep the unused Nexus card'); unchangedCustody(shortFremen.game, shortReturn); }); void test('an earned Discovery/Nexus Axlotl pile cannot settle through an unsettled real printed Truthtrance', () => { const fixture = createDiscoveryClassicNexusEffectsFixture({ advanced: true, tech: true, strongholds: true }); let game = payDiscoveryClassicNexusEffectsFixture(fixture); const card = holdNexusSkillsModulesPaymentsCard(game, fixture.opponent, 'truthtrance', fixture.staging); const before = structuredClone(game); game = applyAction(game, fixture.opponent, { type: 'card', card: card.id }); assert.ok(game.truthtrance); assert.equal(game.phase, 4); assert.deepEqual(wallets(game), wallets(before)); assert.equal(game.techTokens!.axlotl.spice, fixture.tokenCount); for (const player of game.players) rejected(game, player.id, { type: 'ready' }); legalPolicies(game, game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id); const settled = finishDiscoveryClassicNexusEffectsInteraction(game); assert.equal(settled.truthtrance ?? null, null); assert.equal(settled.phase, 4); assert.deepEqual(wallets(settled), wallets(before)); assert.equal(settled.techTokens!.axlotl.spice, fixture.tokenCount); assert.ok(settled.discard.some(value => value.id === card.id && value.effect === 'truthtrance')); discoveryClassicNexusEffectsInventory(settled); }); for (const configuration of [configurations[0], configurations[1], configurations[4], configurations[5]]) { void test(`${configuration.name} paid Guild Secret Ally nested Shrine arrival retains invoice/source identity and original battle reward once`, () => { const fixture = createDiscoveryClassicNexusEffectsFixture({ ...configuration, kind: 'guild-shipment' }); sourceTransitions(fixture); legalPolicies(fixture.game, fixture.actor); const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor); const quote = quoteNexusGuildSecretShipment(territory('shrine').type, 3); assert.equal(quote.cost, 2, 'A nested revealed location uses the printed stronghold shipping tariff, not a desert or native-free tariff'); rejected(fixture.game, fixture.opponent, fixture.paymentAction); rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, nexus: 'stale' }); rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, smuggler: true }); const paid = finishDiscoveryClassicNexusEffectsInteraction(payDiscoveryClassicNexusEffectsFixture(fixture)); const arrived = discoveryClassicNexusEffectsPlayer(paid, fixture.actor); assert.equal(arrived.spice, before.spice - quote.cost); assert.equal(arrived.reserves, before.reserves - 3); assert.equal(arrived.forces['shrine:0'], 3); assert.equal(arrived.shipped, true); assert.equal(arrived.moved, before.moved); for (const player of paid.players) if (player.id !== fixture.actor) assert.equal(player.spice, discoveryClassicNexusEffectsPlayer(fixture.game, player.id).spice); assert.equal(paid.nexusCards!.cards!.hands[fixture.actor], null); assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1); assert.equal(paid.nexusGuildSecretHistory!.length, 1); const record = paid.nexusGuildSecretHistory![0], frame = JSON.parse(record.frame); assert.equal(record.receipt.owner, fixture.actor); assert.equal(record.receipt.turn, 2); assert.equal(record.kind, 'reserve'); assert.equal(frame.player, fixture.actor); assert.equal(frame.territory, 'shrine'); assert.equal(frame.sector, 0); assert.equal(frame.amount, 3); assert.equal(frame.elite, 0); assert.equal(frame.cost, quote.cost); assert.equal(frame.allyPayment, 0); assert.equal(frame.guildSecretEvent, fixture.paymentAction.nexus); assert.equal(frame.nexusEvent, undefined); assert.equal(frame.guildNexusEvent, undefined); assert.equal(frame.source, undefined); unchangedCustody(fixture.game, paid); rejected(paid, fixture.actor, fixture.paymentAction); if (configuration.tech) { assert.equal(paid.techTokens!.heighliners.spice, fixture.tokenCount); assert.equal(paid.techTokens!.heighliners.triggeredTurn, 2); } const expected = wallets(paid); if (fixture.tokenOwner) expected[fixture.tokenOwner] += fixture.tokenCount; const battle = openDiscoveryClassicNexusNestedBattle(fixture, paid); assert.deepEqual(wallets(battle.beforeBattle), expected, 'The original Movement-end industry pays once before the actual nested battle'); if (configuration.tech) assert.equal(battle.beforeBattle.techTokens!.heighliners.spice, 0); assert.equal(battle.game.battle!.territory, 'shrine'); const loser = discoveryClassicNexusEffectsPlayer(battle.game, fixture.opponent); const bounty = loser.leaders.find(leader => leader.id === battle.losingLeader)!.strength; const priorTokenOwners = battle.game.techTokens && Object.fromEntries(Object.entries(battle.game.techTokens).map(([id, token]) => [id, token.owner])); const collection = quoteSpiceCollection(battle.beforeBattle); const done = finishDiscoveryClassicNexusNestedBattle(battle); assert.equal(done.lastBattleContext!.winner, fixture.actor); assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.actor).forces['shrine:0'], 3); assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).forces['shrine:0'] ?? 0, 0); assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).tanks, loser.tanks + 2); const battleWallets = { ...expected }; battleWallets[fixture.actor] += bounty; if (done.phase >= 7) for (const receipt of collection.receipts) battleWallets[receipt.player] += receipt.strongholds + receipt.collected; assert.deepEqual(wallets(done), battleWallets, 'Only the original leader bounty and independently quoted native Collection may accompany battle completion'); assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).leaders.find(leader => leader.id === battle.losingLeader)!.dead, true); assert.ok(discoveryClassicNexusEffectsPlayer(done, fixture.actor).hand.some(card => card.id === battle.weapon.id)); assert.ok(!done.discard.some(card => card.id === battle.weapon.id)); assert.deepEqual(done.nexusGuildSecretHistory, paid.nexusGuildSecretHistory); assert.deepEqual(done.discoveries, paid.discoveries); assert.deepEqual(done.strongholdCards, paid.strongholdCards); if (configuration.tech) { assert.equal(ownedTech(done.techTokens, fixture.opponent).length, 0); for (const [id, owner] of Object.entries(priorTokenOwners!)) assert.equal(done.techTokens![id as keyof NonNullable].owner, owner === fixture.opponent ? fixture.actor : owner); } discoveryClassicNexusEffectsInventory(done); const atMentat = wallets(done); if (done.phase < 7) for (const receipt of collection.receipts) atMentat[receipt.player] += receipt.strongholds + receipt.collected; const mentat = advanceDiscoveryClassicNexusEffectsToPhase(done, 8); assert.deepEqual(wallets(mentat), atMentat, 'Only original city Collection pays; Shrine cannot recollect shipment industry or leader bounty'); assert.deepEqual(mentat.nexusGuildSecretHistory, paid.nexusGuildSecretHistory); discoveryClassicNexusEffectsInventory(mentat); }); } void test('completed Discovery Emperor extra return rejects altered ordinary usage', () => { const initial = initializeDiscoveryClassicNexusEffectsSetup({ advanced: true, tech: true, strongholds: true }); const fixture = createDiscoveryClassicNexusEffectsFixture({ initial }); discoveryClassicNexusEffectsInventory(fixture.game); const paid = payDiscoveryClassicNexusEffectsFixture(fixture); const tampered = structuredClone(paid), after = tampered.nexusEmperorSecretHistory![0].after; assert.ok(after); after.revived++; rejected(tampered, fixture.actor, { type: 'ready' }); discoveryClassicNexusEffectsInventory(paid); });