import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, viewGame, normalizeAutomaticGame, type Game, type Action, } from '../game/engine'; import { baseDeck, ixBattleCards, type Card } from '../game/cards'; import { richeseCards } from '../game/richese-cards'; import { createStrongholdCards, type StrongholdId, } from '../game/stronghold-cards'; import { MOBILE_STRONGHOLD, MOBILE_LOCATION, territory } from '../game/board'; import { finishToMovement } from './fixture-advanced-source'; const key = (t: string) => `${t}:${territory(t).sectors[0]}`; const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!; function fixture(t: StrongholdId = 'arrakeen', owner = 'p', choam = false) { const g = createGame('STRONGHOLDS', newPlayer('p', 'Guild', 'guild'), true); g.players.push(newPlayer('q', 'Emperor', 'emperor')); if (choam) g.players.push(newPlayer('c', 'CHOAM', 'choam')); Object.assign(g, { status: 'playing', phase: 6, turn: 2, active: 'p', order: g.players.map((p) => p.id), storm: 18, deck: [...baseDeck(), ...ixBattleCards()], strongholdCards: createStrongholdCards(), }); g.strongholdCards!.claimedTurn = 1; g.strongholdCards!.owners[t] = owner; for (const p of g.players) { p.hand = []; p.traitors = []; p.traitorChoices = []; p.spice = 20; p.forces = p.id === 'c' ? {} : { [key(t)]: 6 }; p.reserves = p.id === 'c' ? 20 : 14; } if (t === MOBILE_STRONGHOLD) g.mobileStronghold = { location: 'red_chasm:7' }; return g; } function hold(g: Game, id: string, kind: Card['kind'] | 'stoneBurner') { if (kind === 'stoneBurner') { const c = richeseCards().find((c) => c.effect === 'stoneBurner')!; seat(g, id).hand.push(c); return c.id; } const index = g.deck.findIndex((c) => c.kind === kind); assert.ok(index >= 0, kind); const c = g.deck.splice(index, 1)[0]; seat(g, id).hand.push(c); return c.id; } function prepare(state: Game, funding = 0) { let g = state; for (let limit = 0; limit < 40; limit++) { if (g.decision?.kind === 'strongholdCopy') return g; if (g.response) { g = applyAction( g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }, ); continue; } if (g.decision?.kind === 'choamBattleFunding') { g = applyAction(g, g.decision.player, { type: 'decision', amount: funding, }); continue; } if (g.battle?.preLeader && !g.battle.preLeader.closed) { const id = [g.battle.attacker, g.battle.defender].find( (id) => !g.battle!.preLeader!.ready.includes(id), ); if (id) { g = applyAction(g, id, { type: 'battlePreparationReady', event: g.battle.event, }); continue; } } if (g.battle?.preparation) { g = applyAction(g, g.battle.preparation.owner, { type: 'declineBattlePower', }); continue; } if (g.decision?.kind === 'fullPlanOffer') { g = applyAction(g, g.decision.player, { type: 'decision', decline: true, }); continue; } return g; } throw Error('Preparation stalled'); } const begin = (g: Game, t: string, funding = 0) => prepare( applyAction(g, 'p', { type: 'chooseBattle', territory: t, target: 'q' }), funding, ); const plan = (g: Game, id: string, extra: Partial = {}) => applyAction(g, id, { type: 'battlePlan', dial: 0, support: 0, leader: seat(g, id).leaders.find((l) => l.strength === 5)!.id, ...extra, }); function finish(state: Game, callers: string[] = []) { let g = state; for (let i = 0; i < 40; i++) { if (g.response) { g = applyAction( g, g.players.find((p) => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' }, ); continue; } if (g.decision?.kind === 'poisonTooth') { g = applyAction(g, g.decision.player, { type: 'decision', activate: true, }); continue; } if (g.decision?.kind === 'stoneBurner') { g = applyAction(g, g.decision.player, { type: 'decision', event: g.battle!.event, mode: 'ignore', }); continue; } if (g.battle?.revealed) { const view = viewGame(g, 'p').battle!; const voter = view.traitorVoters.find( (id) => !view.traitorSubmitted.includes(id), ); if (voter) { g = applyAction(g, voter, { type: 'traitorCall', call: callers.includes(voter), }); continue; } } if (g.decision?.kind === 'battleLosses') { g = applyAction(g, g.decision.player, { type: 'decision', choice: 0 }); continue; } if (g.decision?.kind === 'battleCards') { g = applyAction(g, g.decision.player, { type: 'decision', discard: [] }); continue; } return g; } throw Error('Battle settlement stalled'); } function readyAll(state: Game) { let g = state; for (const p of state.players) g = applyAction(g, p.id, { type: 'ready' }); return g; } const incomeLogs = (g: Game) => g.log.filter((entry) => entry.automatic?.name === 'Stronghold income'); void test('Stronghold Cards are unowned at the start and transfer only at the final end of Mentat', () => { let g = fixture(); g.turn = 1; g.phase = 7; g.active = null; g.strongholdCards = createStrongholdCards(); seat(g, 'p').forces = { 'arrakeen:10': 1 }; seat(g, 'p').reserves = 19; seat(g, 'q').forces = { 'carthag:11': 1 }; seat(g, 'q').reserves = 19; g = readyAll(g); assert.equal(g.phase, 8); assert.equal(g.strongholdCards!.claimedTurn, 0); assert.ok( Object.values(g.strongholdCards!.owners).every((owner) => owner === null), ); g = readyAll(g); assert.equal(g.turn, 2); assert.equal(g.strongholdCards!.claimedTurn, 1); assert.equal(g.strongholdCards!.owners.arrakeen, 'p'); const restored = normalizeAutomaticGame(JSON.parse(JSON.stringify(g))); assert.deepEqual(restored.strongholdCards, g.strongholdCards); g = finishToMovement(g); while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' }); assert.equal(g.phase, 7); assert.equal(g.strongholdCards!.owners.arrakeen, 'p'); g = readyAll(g); assert.equal(g.phase, 8); assert.equal(g.strongholdCards!.claimedTurn, 1); seat(g, 'p').forces = {}; seat(g, 'p').reserves = 20; seat(g, 'q').forces = { 'arrakeen:10': 1 }; seat(g, 'q').reserves = 19; g = applyAction(g, 'p', { type: 'ready' }); assert.equal(g.strongholdCards!.owners.arrakeen, 'p'); g = applyAction(g, 'q', { type: 'ready' }); assert.equal(g.strongholdCards!.owners.arrakeen, 'q'); assert.equal(g.strongholdCards!.owners.carthag, null); assert.equal(g.strongholdCards!.claimedTurn, 2); }); void test('Arrakeen bank support reduces actual spice payment and CHOAM receives income including bank support', () => { for (const support of [0, 1, 2, 3, 4]) { const initial = fixture('arrakeen', 'p', true); seat(initial, 'p').spice = Math.max(0, support - 2); let g = begin(initial, 'arrakeen'); g = plan(g, 'p', { dial: support, support }); g = plan(g, 'q'); g = finish(g); assert.equal(seat(g, 'p').spice, 0); assert.equal(seat(g, 'c').spice, 20 + Math.floor(support / 2)); assert.equal(seat(g, 'p').forces['arrakeen:10'], 6 - support); } }); void test('Arrakeen plus CHOAM donor support consumes only the selected escrow and includes the bank share in CHOAM income', () => { const initial = fixture('arrakeen', 'p', true); seat(initial, 'p').ally = 'c'; seat(initial, 'c').ally = 'p'; seat(initial, 'p').spice = 0; let g = begin(initial, 'arrakeen', 2); assert.equal(g.aid.c.amount, 2); assert.equal(seat(g, 'c').spice, 18); g = plan(g, 'p', { dial: 4, support: 4, allyPayment: 2 }); g = plan(g, 'q'); g = finish(g); assert.equal(seat(g, 'p').spice, 0); assert.equal(g.aid.c?.amount ?? 0, 0); assert.equal(seat(g, 'c').spice, 19); }); void test('Carthag adds Snooper to a played Shield only without a poison weapon and never blocks Poison Tooth', () => { for (const scenario of [ { defense: true, own: undefined, enemy: 'poison', dead: false }, { defense: true, own: 'poison', enemy: 'poison', dead: true }, { defense: false, own: undefined, enemy: 'poison', dead: true }, { defense: true, own: undefined, enemy: 'poisonTooth', dead: true }, ] as const) { const initial = fixture('carthag'); const defense = scenario.defense ? hold(initial, 'p', 'shield') : null; const weapon = scenario.own ? hold(initial, 'p', scenario.own) : null; const enemy = hold(initial, 'q', scenario.enemy); let g = begin(initial, 'carthag'); const leader = seat(g, 'p').leaders.find((l) => l.strength === 5)!.id; g = plan(g, 'p', { weapon, defense }); g = plan(g, 'q', { weapon: enemy, dial: 2 }); g = finish(g); assert.equal( seat(g, 'p').leaders.find((l) => l.id === leader)!.dead, scenario.dead, JSON.stringify(scenario), ); } }); void test('Habbanya holder wins as defender on an ordinary or Stone Burner physical tie', () => { for (const stone of [false, true]) { const initial = fixture('habbanya_ridge_sietch', 'q'); const weapon = stone ? hold(initial, 'p', 'stoneBurner') : null; let g = begin(initial, 'habbanya_ridge_sietch'); g = plan(g, 'p', { weapon }); g = plan(g, 'q'); g = finish(g); assert.equal(seat(g, 'p').forces[key('habbanya_ridge_sietch')], undefined); assert.equal(seat(g, 'q').forces[key('habbanya_ridge_sietch')], 6); } }); void test('Sietch Tabr pays the winning holder the opposing fractional dial rounded down exactly once', () => { const initial = fixture('sietch_tabr'); let g = begin(initial, 'sietch_tabr'); g = plan(g, 'p', { dial: 3, support: 3 }); g = plan(g, 'q', { dial: 1.5, support: 0 }); g = finish(g); assert.equal(seat(g, 'p').spice, 18); assert.equal(incomeLogs(g).length, 1); const again = normalizeAutomaticGame(JSON.parse(JSON.stringify(g))); assert.equal(seat(again, 'p').spice, seat(g, 'p').spice); assert.equal(incomeLogs(again).length, 1); }); void test('Tuek pays losing played Worthless cards but double traitors produce no module payout', () => { for (const doubleTraitor of [false, true]) { const initial = fixture('tueks_sietch'); const weapon = hold(initial, 'p', 'worthless'), defense = hold(initial, 'p', 'worthless'); if (doubleTraitor) { seat(initial, 'p').traitors = [ seat(initial, 'q').leaders.find((l) => l.strength === 5)!.id, ]; seat(initial, 'q').traitors = [ seat(initial, 'p').leaders.find((l) => l.strength === 5)!.id, ]; } let g = begin(initial, 'tueks_sietch'); g = plan(g, 'p', { weapon, defense }); g = plan(g, 'q', { dial: 2 }); g = finish(g, doubleTraitor ? ['p', 'q'] : []); assert.equal(seat(g, 'p').forces[key('tueks_sietch')], undefined); assert.equal(seat(g, 'p').spice, doubleTraitor ? 20 : 24); assert.equal(incomeLogs(g).length, doubleTraitor ? 0 : 1); assert.ok(g.discard.some((c) => c.id === weapon)); assert.ok(g.discard.some((c) => c.id === defense)); } }); void test('one current controlled site automatically supplies the public mobile copy even if another player retains that card', () => { const initial = fixture(MOBILE_STRONGHOLD); seat(initial, 'p').forces = { [MOBILE_LOCATION]: 6, 'arrakeen:10': 1 }; seat(initial, 'p').reserves = 13; initial.strongholdCards!.owners.arrakeen = 'q'; const g = begin(initial, MOBILE_STRONGHOLD); assert.equal(g.battle!.strongholdCopy, 'arrakeen'); assert.notEqual(g.decision?.kind, 'strongholdCopy'); assert.deepEqual(g.battle!.plans, {}); for (const p of g.players) { const view = viewGame(g, p.id); assert.equal(view.battle!.strongholdCopy, 'arrakeen'); assert.equal(view.battle!.strongholdEffects.p, 'arrakeen'); } }); void test('multiple current controlled sites require an event-bound public mobile choice before any plan and reject stale or duplicate choices', () => { const initial = fixture(MOBILE_STRONGHOLD); seat(initial, 'p').forces = { [MOBILE_LOCATION]: 6, 'arrakeen:10': 1, 'carthag:11': 1, }; seat(initial, 'p').reserves = 12; initial.strongholdCards!.owners.arrakeen = 'q'; initial.strongholdCards!.owners.sietch_tabr = 'p'; const g = begin(initial, MOBILE_STRONGHOLD); assert.equal(g.decision?.kind, 'strongholdCopy'); if (g.decision?.kind !== 'strongholdCopy') throw Error('Missing copy choice'); assert.deepEqual(g.decision.choices, ['arrakeen', 'carthag']); const event = g.decision.event, snapshot = structuredClone(g); assert.throws(() => plan(g, 'q')); assert.throws(() => applyAction(g, 'p', { type: 'decision', event: 'stale', stronghold: 'carthag', }), ); assert.throws(() => applyAction(g, 'q', { type: 'decision', event, stronghold: 'carthag' }), ); assert.throws(() => applyAction(g, 'p', { type: 'decision', event, stronghold: 'sietch_tabr' }), ); assert.deepEqual(g, snapshot); const selected = prepare( applyAction(JSON.parse(JSON.stringify(g)), 'p', { type: 'decision', event, stronghold: 'carthag', }), ); assert.deepEqual(selected.battle!.plans, {}); for (const p of selected.players) assert.equal(viewGame(selected, p.id).battle!.strongholdCopy, 'carthag'); assert.throws(() => applyAction(selected, 'p', { type: 'decision', event, stronghold: 'arrakeen', }), ); const planned = plan(selected, 'p'); assert.equal(planned.battle!.strongholdCopy, 'carthag'); });