import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { MOBILE_LOCATION, MOBILE_STRONGHOLD, gameDistance, gameTerritories } from '../game/board'; import { strongholdControllers } from '../game/stronghold-cards'; import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions'; import { assertDiscoveryNativeStrongholdCustody, createDiscoveryNativeStrongholdFixture, discoveryNativeStrongholdPlayer, discoveryNativeStrongholdReload, finishDiscoveryNativeStrongholdBattle, prepareDiscoveryNativeStrongholdBattle, revealDiscoveryNativeStrongholdBattle, } from './fixture-discovery-native-strongholds'; function reject(game: Game, actor: string, action: Action): void { const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before, 'Rejected native controls cannot debit wallets, move counters or change retained card custody'); } function legalPolicies(game: Game, actor: string): void { for (const difficulty of DIFFICULTIES) { const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty; const choices = botActions(view); assert.ok(choices.length, `${difficulty} supplies an actual native continuation`); for (const action of choices) { const next = applyAction(discoveryNativeStrongholdReload(game), actor, action); assertDiscoveryNativeStrongholdCustody(next); } } } void test('native Advanced Discovery blow/reveal and original final Mentat produce retained cards without redealing authenticated setup', () => { for (const kind of ['ix-hms-invoice', 'richese-nested-defense'] as const) { const fixture = createDiscoveryNativeStrongholdFixture({ kind, tech: true }); const { owner } = fixture; const original = discoveryNativeStrongholdReload(fixture.setup); const fromSetup = createDiscoveryNativeStrongholdFixture({ kind, initial: fixture.setup }); assert.deepEqual(fixture.setup, original); assert.deepEqual(fromSetup.setup, original, 'An original undealt native setup is continued, never initialized twice'); assert.equal(fixture.setup.strongholdCards!.claimedTurn, 0); assert.ok(Object.values(fixture.setup.strongholdCards!.owners).every(id => id === null)); const old = discoveryNativeStrongholdPlayer(fixture.beforeBlow, owner); const blown = discoveryNativeStrongholdPlayer(fixture.afterBlow, owner); assert.equal(old.forces['hagga_basin:12'], 2); assert.equal(blown.forces['hagga_basin:12'], undefined); assert.equal(blown.tanks, old.tanks + 2); if (kind === 'ix-hms-invoice') { assert.equal(old.elites!.forces['hagga_basin:12'], 1); assert.equal(blown.elites!.forces['hagga_basin:12'], undefined); assert.equal(blown.elites!.tanks, old.elites!.tanks + 1); assert.equal(discoveryNativeStrongholdPlayer(fixture.afterSetup, owner).forces[MOBILE_LOCATION], 6); assert.equal(discoveryNativeStrongholdPlayer(fixture.afterSetup, owner).elites!.forces[MOBILE_LOCATION], 3); } const token = fixture.afterBlow.discoveries!.tokens.find(t => t.id === fixture.token)!; assert.equal(token.status, 'placed'); assert.equal(token.territory, 'gara_kulon'); assert.equal(token.sector, 8); assert.ok(fixture.afterBlow.spiceDiscard.flat().some(c => 'territory' in c && c.discovery === 'discovery-hagga-basin')); assert.equal(gameTerritories(fixture.beforeReveal).some(t => t.id === 'shrine'), false); assert.equal(gameTerritories(fixture.afterReveal).some(t => t.id === 'shrine'), true); assert.equal(fixture.beforeFirstMentat.phase, 8); assert.equal(fixture.beforeFirstMentat.turn, 1); assert.equal(fixture.beforeFirstMentat.strongholdCards!.claimedTurn, 0); const claimed = applyAction(discoveryNativeStrongholdReload(fixture.beforeFirstMentat), fixture.firstMentatStep.actor, fixture.firstMentatStep.action); assert.equal(claimed.turn, 2); assert.deepEqual(claimed.strongholdCards, fixture.afterFirstMentat.strongholdCards); assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, kind === 'ix-hms-invoice')); assert.equal(claimed.strongholdCards!.owners[kind === 'ix-hms-invoice' ? MOBILE_STRONGHOLD : 'carthag'], owner); assert.deepEqual(fixture.beforeBattle.strongholdCards, claimed.strongholdCards); for (const snapshot of [fixture.setup, fixture.afterSetup, fixture.beforeReveal, claimed, fixture.beforeBattle]) assertDiscoveryNativeStrongholdCustody(snapshot); } }); void test('earned HMS card composes with actual typed Discovery free entry, paid nested shipment and optional original industry', () => { for (const tech of [false, true]) { const f = createDiscoveryNativeStrongholdFixture({ tech }); const before = discoveryNativeStrongholdPlayer(f.entry.before, f.owner); const entered = discoveryNativeStrongholdPlayer(f.entry.after, f.owner); const offer = viewGame(f.entry.before, f.owner).discoveryEntry!; assert.deepEqual(offer.sources, [{ source: f.source, normal: 2, elite: 1 }]); legalPolicies(f.entry.before, f.owner); assert.equal(entered.forces['shrine:0'], 2); assert.equal(entered.elites!.forces['shrine:0'], 1); assert.equal(entered.forces[f.source], 1); assert.equal(entered.elites!.forces[f.source] ?? 0, 0); assert.equal(entered.reserves, before.reserves); assert.equal(entered.elites!.reserves, before.elites!.reserves); assert.equal(entered.spice, before.spice); assert.equal(entered.shipped, before.shipped); assert.equal(entered.moved, before.moved); assert.equal(f.entry.after.discoveryEntry, undefined); reject(f.entry.after, f.owner, f.entry.step.action); const shipping = discoveryNativeStrongholdPlayer(f.shipment.before, f.owner); const shipped = discoveryNativeStrongholdPlayer(f.shipment.after, f.owner); assert.equal(gameDistance(f.shipment.before, f.source, 'shrine:0'), 1); assert.equal(shipped.forces['shrine:0'], 4); assert.equal(shipped.elites!.forces['shrine:0'], 2); assert.equal(shipped.reserves, shipping.reserves - 2); assert.equal(shipped.elites!.reserves, shipping.elites!.reserves - 1); assert.equal(shipped.spice, shipping.spice - 2); assert.equal(discoveryNativeStrongholdPlayer(f.shipment.after, f.opponent).spice, discoveryNativeStrongholdPlayer(f.shipment.before, f.opponent).spice + 2); assert.equal(shipped.forces[MOBILE_LOCATION], 6); assert.equal(shipped.elites!.forces[MOBILE_LOCATION], 3); assert.equal(shipped.shipped, true); assert.deepEqual(f.shipment.after.strongholdCards, f.afterFirstMentat.strongholdCards); reject(f.shipment.after, f.owner, f.shipment.step.action); if (tech) { assert.equal(f.shipment.after.techTokens!.heighliners.owner, f.owner); assert.equal(f.shipment.after.techTokens!.heighliners.triggeredTurn, 2); assert.equal(f.shipment.after.techTokens!.heighliners.spice, 1); assert.equal(f.beforeBattle.techTokens!.heighliners.spice, 0); assert.equal(discoveryNativeStrongholdPlayer(f.beforeBattle, f.owner).spice, shipped.spice + 1); } else assert.equal(f.beforeBattle.techTokens, undefined); assertDiscoveryNativeStrongholdCustody(f.shipment.after); } }); void test('original HMS declaration copies CURRENT Arrakeen control before plans; native CHOAM invoice includes bank support but pays no cash subsidy', () => { const f = createDiscoveryNativeStrongholdFixture({ tech: true }); assert.equal(f.game.decision?.kind, 'strongholdCopy'); assert.equal(strongholdControllers(f.game.players, true).arrakeen, f.owner); assert.ok(f.game.decision?.kind === 'strongholdCopy'); assert.deepEqual(f.game.decision.choices, ['arrakeen', 'sietch_tabr']); assert.equal(f.game.strongholdCards!.owners.sietch_tabr, null, 'Current control offers a choice without fabricating retained card custody'); assert.equal(f.game.strongholdCards!.owners[MOBILE_STRONGHOLD], f.owner); legalPolicies(f.game, f.owner); reject(f.game, f.opponent, f.copyStep!.action); reject(f.game, f.owner, { ...f.copyStep!.action, event: 'stale-original-declaration' }); reject(f.game, f.owner, { ...f.copyStep!.action, stronghold: 'carthag' }); const ready = prepareDiscoveryNativeStrongholdBattle(f); assert.equal(viewGame(ready, f.owner).battle!.strongholdEffects[f.owner], 'arrakeen'); assert.equal(viewGame(ready, f.owner).battle!.ownForces!.normalFixedHalf, true); const revealed = revealDiscoveryNativeStrongholdBattle(f); assert.equal(discoveryNativeStrongholdPlayer(revealed, f.owner).spice, discoveryNativeStrongholdPlayer(ready, f.owner).spice, 'Sealing the plan never credits spendable bank support'); const quote = quoteStrongholdFactionsBattle(revealed); assert.equal(quote.winner, f.owner); assert.deepEqual(quote.payments.find(p => p.player === f.owner), { player: f.owner, cost: 1, ownPayment: 1, allyPayment: 0, donor: null, bankSupport: 2, freeByTraitor: false, }); assert.deepEqual(quote.choamIncome, { owner: f.choam, amount: 1 }); assert.deepEqual(quote.casualties!.options, [{ normal: 0, elite: 3, paidNormal: 0, paidElite: 3 }]); const done = finishDiscoveryNativeStrongholdBattle(revealed); assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).tanks, discoveryNativeStrongholdPlayer(revealed, f.owner).tanks + 3); assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).elites!.tanks, discoveryNativeStrongholdPlayer(revealed, f.owner).elites!.tanks + 3); assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).forces[MOBILE_LOCATION], 3); assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).elites!.forces[MOBILE_LOCATION] ?? 0, 0); assert.equal(done.phase, 6, 'A real second conflict prevents unrelated Collection from contaminating this invoice'); assert.equal(done.lastBattleContext!.winner, f.owner); assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).spice, discoveryNativeStrongholdPlayer(revealed, f.owner).spice - 1); assert.equal(discoveryNativeStrongholdPlayer(done, f.choam).spice, discoveryNativeStrongholdPlayer(revealed, f.choam).spice + 1); assert.deepEqual(done.strongholdCards, f.afterFirstMentat.strongholdCards); const expectedTech = structuredClone(f.beforeBattle.techTokens!); assert.equal(Object.values(expectedTech).filter(t => t.owner === f.opponent).length, 1); for (const token of Object.values(expectedTech)) if (token.owner === f.opponent) token.owner = f.owner; assert.deepEqual(done.techTokens, expectedTech, 'Original winner takes the defeated seat’s one actual token only after battle cleanup'); assertDiscoveryNativeStrongholdCustody(done); assert.deepEqual(normalizeAutomaticGame(discoveryNativeStrongholdReload(done)), done); }); void test('physical E2 No-Field shipment/reveal at native nested Shrine does not import retained Carthag poison protection or parent armies', () => { const f = createDiscoveryNativeStrongholdFixture({ kind: 'richese-nested-defense', tech: true }); assert.equal(f.parent, 'gara_kulon', 'Printed native placement is a sand parent, never an invented Carthag token'); const before = discoveryNativeStrongholdPlayer(f.shipment.before, f.owner); const shipped = discoveryNativeStrongholdPlayer(f.shipment.after, f.owner); const token = before.noField!.tokens.find(t => t.value === 5)!; const originalTokens = structuredClone(before.noField!.tokens); legalPolicies(f.shipment.before, f.owner); assert.equal(shipped.spice, before.spice - 1); assert.equal(discoveryNativeStrongholdPlayer(f.shipment.after, f.opponent).spice, discoveryNativeStrongholdPlayer(f.shipment.before, f.opponent).spice + 1); assert.deepEqual(shipped.forces, before.forces); assert.equal(shipped.reserves, before.reserves); assert.deepEqual(shipped.noField!.deployed!.location, { territory: 'shrine', sector: 0 }); assert.deepEqual(shipped.noField!.tokens, originalTokens); const reveal = f.markerReveal!; reject(reveal.before, f.opponent, reveal.step.action); reject(reveal.before, f.owner, { ...reveal.step.action, event: before.noFieldEvent }); const materialized = discoveryNativeStrongholdPlayer(reveal.after, f.owner); assert.equal(materialized.forces['shrine:0'], 5); assert.equal(materialized.reserves, shipped.reserves - 5); assert.equal(materialized.spice, shipped.spice); assert.equal(materialized.noField!.deployed, null); assert.equal(materialized.noField!.lastShipped, token.id); assert.deepEqual(materialized.noField!.tokens, originalTokens); reject(reveal.after, f.owner, reveal.step.action); assert.equal(discoveryNativeStrongholdPlayer(f.beforeBattle, f.opponent).forces[f.source], 1); assert.equal(f.beforeBattle.strongholdCards!.owners.carthag, f.owner); const ready = prepareDiscoveryNativeStrongholdBattle(f); assert.equal(viewGame(ready, f.owner).battle!.strongholdEffects[f.owner], null); assert.equal(viewGame(ready, f.owner).battle!.ownForces!.normal, 5); assert.equal(viewGame(ready, f.opponent).battle!.ownForces!.normal, 6, 'Parent-sector counter is not a nested fighter'); const revealed = revealDiscoveryNativeStrongholdBattle(f), quote = quoteStrongholdFactionsBattle(revealed); assert.equal(quote.winner, f.opponent); assert.equal(quote.strongholdIncome.length, 0); const done = finishDiscoveryNativeStrongholdBattle(revealed); assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).leaders.find(l => l.id === f.leader)!.dead, true, 'A real Shield in Shrine does not gain the poison coverage of a retained Carthag card elsewhere'); assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).tanks, discoveryNativeStrongholdPlayer(revealed, f.owner).tanks + 5); assert.equal(discoveryNativeStrongholdPlayer(done, f.owner).forces['shrine:0'], undefined); assert.equal(discoveryNativeStrongholdPlayer(done, f.opponent).forces['shrine:0'], 6); assert.equal(discoveryNativeStrongholdPlayer(done, f.opponent).forces[f.source], 1); assert.equal(discoveryNativeStrongholdPlayer(done, f.opponent).spice, discoveryNativeStrongholdPlayer(revealed, f.opponent).spice + quote.bounty!.amount); assert.ok(done.discard.some(c => c.id === f.defense)); assert.ok(discoveryNativeStrongholdPlayer(done, f.opponent).hand.some(c => c.id === f.weapon)); const expectedTech = structuredClone(f.beforeBattle.techTokens!); assert.equal(Object.values(expectedTech).filter(t => t.owner === f.owner).length, 1); for (const token of Object.values(expectedTech)) if (token.owner === f.owner) token.owner = f.opponent; assert.deepEqual(done.techTokens, expectedTech, 'Retained Carthag custody cannot divert the native loser’s Tech reward'); assert.deepEqual(done.strongholdCards, f.afterFirstMentat.strongholdCards); assert.equal(done.phase, 6); assertDiscoveryNativeStrongholdCustody(done); assert.deepEqual(normalizeAutomaticGame(discoveryNativeStrongholdReload(done)), done); });