import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, newPlayer, viewGame, type Game, } from '../game/engine'; import { type FactionId } from '../game/catalog'; import { baseDeck } from '../game/cards'; import { territory } from '../game/board'; import { quoteVictory } from '../game/victory-quote'; import { strongholdProgress } from '../game/victory-progress'; import { createTechTokens } from '../game/tech-tokens'; import { createStrongholdCards } from '../game/stronghold-cards'; import { createTerrorState } from '../game/moritani-terror'; import { botActions } from '../game/bots'; import { createAmbassadors } from '../game/ecaz-ambassadors'; import { createRicheseNoField, deployRicheseNoField, } from '../game/richese-no-field'; const THREE = ['arrakeen', 'carthag', 'sietch_tabr']; const seat = (g: Game, id: string) => g.players.find((p) => p.id === id)!; const json = (g: Game): Game => JSON.parse(JSON.stringify(g)); // Constructed, conserved development board; transitions into Mentat are real // ready actions, not a production Ecaz-start or combined-combat claim. function fixture( ally: FactionId = 'emperor', extra: FactionId[] = ['beneGesserit'], advanced = true, ) { const g = createGame( 'ECAZVICTORY', newPlayer('ec', 'Ecaz', 'ecaz'), advanced, ['ecaz'], ); g.players.push(newPlayer('al', 'Ally', ally)); extra.forEach((f, i) => g.players.push(newPlayer(`x${i}`, f, f))); Object.assign(g, { status: 'playing', phase: 7, turn: 2, storm: 18, order: g.players.map((p) => p.id), active: null, ready: [], phaseOpening: null, deck: baseDeck(), }); for (const p of g.players) Object.assign(p, { hand: [], forces: {}, reserves: 20, spice: 20, traitors: [], traitorChoices: [], }); seat(g, 'ec').ally = 'al'; seat(g, 'al').ally = 'ec'; seat(g, 'ec').allySinceTurn = 1; seat(g, 'al').allySinceTurn = 1; return g; } function occupy(g: Game, id: string, holds: string[]) { const p = seat(g, id); p.forces = Object.fromEntries( holds.map((t) => [`${t}:${territory(t).sectors[0]}`, 1]), ); p.reserves = 20 - holds.length; } function joint(g: Game, holds = THREE) { occupy(g, 'ec', holds); occupy(g, 'al', holds); return g; } function mentat(state: Game) { let g = json(state); for (const p of state.players) g = applyAction(g, p.id, { type: 'ready' }); assert.equal(g.phase, 8); return g; } function verdict(state: Game, winners: string[]) { const original = json(state); const input = json(state); input.phase = 8; const quote = quoteVictory(input); assert.deepEqual(quote.winner, winners); assert.deepEqual(state, original); const done = mentat(state); assert.deepEqual(done.winner, winners); assert.equal(done.status, winners.length ? 'finished' : 'playing'); for (const p of state.players) { assert.deepEqual(seat(done, p.id).forces, p.forces); assert.equal(seat(done, p.id).reserves, p.reserves); } return done; } for (const advanced of [false, true]) void test(`actual ${advanced ? 'Advanced' : 'Basic'} Mentat awards three jointly held strongholds to both Ecaz allies`, () => { const g = joint(fixture('emperor', ['beneGesserit'], advanced)); const row = strongholdProgress(g).progress.find((p) => p.player === 'ec')!; assert.equal(row.target, 4); assert.equal(row.occupyTarget, 3); assert.deepEqual(new Set(row.jointlyOccupied), new Set(THREE)); assert.equal(row.qualifies, true); assert.deepEqual( mentat(g).players.map((p) => p.hand.length), [0, 0, 0], ); verdict(g, ['ec', 'al']); }); void test('a two-and-one distribution cannot win at three; ordinary four still wins without three joint locations', () => { const g = fixture(); occupy(g, 'ec', ['arrakeen', 'carthag']); occupy(g, 'al', ['sietch_tabr']); verdict(g, []); occupy(g, 'al', ['sietch_tabr', 'tueks_sietch']); const row = strongholdProgress(g).progress.find((p) => p.player === 'al')!; assert.deepEqual(row.jointlyOccupied, []); assert.equal(row.strongholds.length, 4); verdict(g, ['ec', 'al']); }); void test('two jointly held locations plus all Tech Tokens is not a third physical joint location', () => { const g = joint(fixture(), THREE.slice(0, 2)); g.techTokens = createTechTokens(); for (const token of Object.values(g.techTokens)) token.owner = 'al'; const row = strongholdProgress(g).progress.find((p) => p.player === 'ec')!; assert.equal(row.techStronghold, true); assert.equal(row.jointlyOccupied.length, 2); assert.equal(row.qualifies, false); verdict(g, []); occupy(g, 'ec', THREE); verdict(g, ['ec', 'al']); // Three ordinary locations plus one owned bonus. g.techTokens.production.owner = 'ec'; assert.equal( strongholdProgress(g).progress.find((p) => p.player === 'ec')! .techStronghold, false, ); verdict(g, []); }); void test('storm order changes presentation order, not which Ecaz alliance qualifies', () => { const g = joint(fixture()); g.order = ['al', 'x0', 'ec']; verdict(g, ['al', 'ec']); }); void test('correct BG prediction overrides either allied faction of an Ecaz joint-three winner only on its chosen turn', () => { for (const faction of ['ecaz', 'emperor'] as const) { const g = joint(fixture()); seat(g, 'x0').prediction = { faction, turn: 2 }; const publicBefore = strongholdProgress(g); verdict(g, ['x0']); seat(g, 'x0').prediction!.turn = 3; assert.deepEqual(strongholdProgress(g), publicBefore); verdict(g, ['ec', 'al']); } }); void test('joint stronghold occupied solely by the pair counts under storm; opposing fighters remove it', () => { const g = joint(fixture('emperor', ['harkonnen'])); g.storm = 10; verdict(g, ['ec', 'al']); occupy(g, 'x0', ['arrakeen']); assert.equal( strongholdProgress(g).progress.find((p) => p.player === 'ec')! .jointlyOccupied.length, 2, ); verdict(g, []); }); void test('an allied BG advisor is not the second fighter, and unrelated BG advisors do not contest a joint win', () => { const g = joint(fixture('beneGesserit', ['emperor'])); seat(g, 'al').advisors = { sietch_tabr: {} }; assert.equal(strongholdProgress(g).released.length, 0); verdict(g, []); delete seat(g, 'al').advisors; verdict(g, ['ec', 'al']); const other = joint(fixture()); occupy(other, 'x0', THREE); seat(other, 'x0').advisors = { arrakeen: {}, carthag: {}, sietch_tabr: {}, }; verdict(other, ['ec', 'al']); }); void test('lone BG advisor release feeds ordinary progress but cannot manufacture a second joint occupier', () => { const g = fixture('beneGesserit', ['emperor']); occupy(g, 'ec', ['arrakeen', 'carthag']); occupy(g, 'al', ['sietch_tabr', 'tueks_sietch']); seat(g, 'al').advisors = { sietch_tabr: {}, tueks_sietch: {} }; const progress = strongholdProgress(g); assert.equal(progress.released.length, 2); assert.deepEqual( progress.progress.find((p) => p.player === 'ec')!.jointlyOccupied, [], ); const done = verdict(g, ['ec', 'al']); assert.equal(seat(done, 'al').advisors?.sietch_tabr, undefined); }); void test('all three concealed No-Field values provide identical public joint occupation without revelation', () => { let publicResult: ReturnType | undefined; for (let index = 0; index < 3; index++) { const g = joint(fixture('richese')); occupy(g, 'al', THREE.slice(0, 2)); const ids = ['field-zero', 'field-three', 'field-five']; seat(g, 'al').noField = deployRicheseNoField(createRicheseNoField(ids), { tokenId: ids[index], controller: 'al', location: { territory: 'sietch_tabr', sector: 14 }, }); const progress = strongholdProgress(g); if (publicResult) assert.deepEqual(progress, publicResult); publicResult = progress; const done = verdict(g, ['ec', 'al']); assert.deepEqual(seat(done, 'al').noField, seat(g, 'al').noField); const other = viewGame(done, 'ec').players.find((p) => p.id === 'al')!; assert.equal(JSON.stringify(other.noField).includes(ids[index]), false); } }); void test('a placed HMS can be the third actual jointly held location and final cards remain controlled by Ecaz', () => { const g = joint(fixture('ixians'), THREE.slice(0, 2)); g.mobileStronghold = { location: 'red_chasm:7' }; for (const id of ['ec', 'al']) { seat(g, id).forces['hidden_mobile_stronghold:0'] = 1; seat(g, id).reserves--; } g.strongholdCards = createStrongholdCards(); const done = verdict(g, ['ec', 'al']); assert.equal(done.strongholdCards!.owners.arrakeen, 'ec'); assert.equal(done.strongholdCards!.owners.hidden_mobile_stronghold, 'ec'); assert.equal(done.strongholdCards!.claimedTurn, 2); }); void test('public progress never reads private prediction, cards, balances or No-Field token inventory', () => { const g = joint(fixture('richese')); const expected = strongholdProgress(g); for (const p of g.players) for (const key of ['hand', 'spice', 'prediction']) Object.defineProperty(p, key, { get() { throw Error(`Private ${key} read`); }, }); assert.deepEqual(strongholdProgress(g), expected); const tokens = createRicheseNoField(['n0', 'n3', 'n5']); seat(g, 'al').noField = tokens; Object.defineProperty(tokens, 'tokens', { get() { throw Error('Private No-Field inventory read'); }, }); assert.deepEqual(strongholdProgress(g), expected); }); for (const converted of [false, true]) void test(`${converted ? 'BG-converted' : 'printed'} cancellation of genuine Moritani placement preserves the Ecaz joint victory`, () => { let g = joint(fixture('emperor', ['moritani', 'beneGesserit'])); g.moritaniTerror = createTerrorState(() => 0.4); const actor = converted ? 'x1' : 'al'; const index = g.deck.findIndex( (c) => c.name === (converted ? 'Baliset' : 'Karama'), ); assert.ok(index >= 0); const card = g.deck.splice(index, 1)[0]; seat(g, actor).hand.push(card); if (converted) { // A real remaining blocker keeps the conversion open for JSON recovery. const blocker = g.deck.findIndex((c) => c.name === 'Karama'); assert.ok(blocker >= 0); seat(g, 'ec').hand.push(g.deck.splice(blocker, 1)[0]); } g = mentat(g); assert.equal(g.status, 'playing'); assert.equal(g.decision?.kind, 'moritaniPlacement'); const token = g.moritaniTerror!.tokens[0].id; g = applyAction(g, 'x0', { type: 'decision', token, territory: 'tueks_sietch', }); assert.equal(g.response?.kind, 'moritaniPlacement'); const source = json(g); g = applyAction(g, actor, { type: 'card', card: card.id, mode: 'cancel' }); if (converted) assert.equal(g.response?.kind, 'worthlessKarama'); for (let n = 0; g.response && n < 20; n++) { const responder = g.players.find( (p) => !g.response!.passed.includes(p.id), )!; g = applyAction(json(g), responder.id, { type: 'passResponse' }); } assert.equal(g.response, null); assert.deepEqual(g.winner, ['ec', 'al']); assert.equal(g.status, 'finished'); assert.equal(g.discard.filter((c) => c.id === card.id).length, 1); assert.deepEqual(g.moritaniTerror!.tokens, source.moritaniTerror!.tokens); assert.equal(g.moritaniTerror!.placementTurn, 2); for (const id of ['ec', 'al']) assert.deepEqual(seat(g, id).forces, seat(source, id).forces); }); for (const level of ['Hard', 'Brutal'] as const) void test(`${level} prioritizes a legal shipment completing the third joint stronghold`, () => { const g = joint( fixture('emperor', ['harkonnen'], false), THREE.slice(0, 2), ); occupy(g, 'al', THREE); Object.assign(g, { phase: 5, active: 'ec', movementRemaining: [...g.order], }); const view = viewGame(g, 'ec'); view.players.find((p) => p.id === 'ec')!.bot = level; const before = structuredClone(view); const actions = botActions(view); assert.deepEqual(view, before); const action = actions.find((a) => a.type === 'ship'); assert.ok(action); assert.equal(action.territory, 'sietch_tabr'); const moved = applyAction(g, 'ec', action); assert.equal(moved.phase, 5); assert.equal(moved.status, 'playing'); assert.equal( strongholdProgress(moved).progress.find((p) => p.player === 'ec')! .jointlyOccupied.length, 3, ); }); void test('Brutal Guild takes its real timing opportunity against a joint-two threat, and waits when that public threat is absent', () => { for (const threatened of [false, true]) { let g = joint( fixture('emperor', ['guild']), threatened ? THREE.slice(0, 2) : THREE.slice(0, 1), ); g.phase = 4; g.ecazAmbassadors = createAmbassadors(() => 0.4); for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' }); assert.equal(g.decision?.kind, 'ecazPlacement'); g = applyAction(g, 'ec', { type: 'decision', decline: true }); assert.equal(g.phase, 5); assert.equal(g.decision?.kind, 'guildTiming'); const view = viewGame(g, 'x0'); view.players.find((p) => p.id === 'x0')!.bot = 'Brutal'; const action = botActions(view)[0]; assert.equal(action.type, 'decision'); assert.equal(action.take, threatened); const done = applyAction(g, 'x0', action); assert.equal(done.active, threatened ? 'x0' : 'ec'); assert.deepEqual(done.order, g.order); } });