import assert from 'node:assert/strict'; import test from 'node:test'; import { beginCoalitionAdmission, answerCoalitionAdmission, coalitionOf, type CoalitionState } from '../game/coalition-alliance'; import { createGame, newPlayer, type Game } from '../game/engine'; import type { FactionId } from '../game/catalog'; import { strongholdProgress, VictoryProgressError } from '../game/victory-progress'; import { quoteVictory, VictoryQuoteError } from '../game/victory-quote'; import { fremenSpecialVictory } from '../game/fremen-victory'; import { createVictoryContinuation, voteVictoryContinuation } from '../game/victory-continuation'; import { createTechTokens } from '../game/tech-tokens'; type CoalitionGame = Game & {coalitionState: CoalitionState}; function game(factions: FactionId[] = ['atreides', 'emperor', 'harkonnen', 'beneGesserit']): CoalitionGame { const g = createGame('COALITIONVICTORY', newPlayer('p0', factions[0], factions[0]), false); factions.slice(1).forEach((faction, i) => g.players.push(newPlayer(`p${i + 1}`, faction, faction))); Object.assign(g, { status: 'playing', phase: 8, turn: 2, storm: 18, order: g.players.map(player => player.id) }); for (const p of g.players) Object.assign(p, {forces: {}, ally: null}); g.players[0].ally = 'p1'; g.players[1].ally = 'p0'; const group = coalitionOf(g.players, undefined, 'p0')!; const start = beginCoalitionAdmission(g.players, undefined, {event: 'invite', nexus: 'nexus', turn: 2, groupId: group.id, entrant: 'p2', proposer: 'p0', expectedRevision: 0}); const second = answerCoalitionAdmission(g.players, start.state, {event: 'invite', nexus: 'nexus', turn: 2, actor: 'p1', accept: true, expectedRevision: start.state.revision}); const joined = answerCoalitionAdmission(g.players, second.state, {event: 'invite', nexus: 'nexus', turn: 2, actor: 'p2', accept: true, expectedRevision: second.state.revision}); for (const p of g.players) if (Object.hasOwn(joined.allyUpdates, p.id)) p.ally = joined.allyUpdates[p.id]; return Object.assign(g, {coalitionState: joined.state}); } function hold(g: CoalitionGame, player: number, ...sites: string[]) { g.players[player].forces = Object.fromEntries(sites.map(site => [site, 1])); } const a = 'arrakeen:10', b = 'carthag:11', c = 'sietch_tabr:14', d = 'habbanya_ridge_sietch:17'; void test('three members share a distinct uncontested stronghold union with ordered members', () => { const g = game(); g.order = ['p2', 'p0', 'p3', 'p1']; hold(g, 0, a, b); hold(g, 1, b, c); hold(g, 2, d); const before = structuredClone(g); const rows = strongholdProgress(g).progress.filter(row => row.members.length === 3); assert.equal(rows.length, 3); for (const row of rows) { assert.deepEqual(row.members, ['p2', 'p0', 'p1']); assert.equal(row.strongholds.length, 4); assert.equal(row.target, 4); assert.equal(row.qualifies, true); } assert.deepEqual(quoteVictory(g).winner, ['p2', 'p0', 'p1']); assert.deepEqual(g, before); hold(g, 3, d); assert.equal(strongholdProgress(g).progress.find(row => row.player === 'p0')!.qualifies, false); }); void test('two-member shortened target does not apply to three-member alliances', () => { const g = game(); hold(g, 0, a); hold(g, 1, b); hold(g, 2, c); let row = strongholdProgress(g).progress[0]; assert.equal(row.target, 4); assert.equal(row.qualifies, false); g.shortAllianceVictory = true; row = strongholdProgress(g).progress[0]; assert.equal(row.target, 4); assert.equal(row.qualifies, false); g.victoryExtraStrongholds = 1; row = strongholdProgress(g).progress[0]; assert.equal(row.target, 5); assert.equal(row.qualifies, false); g.victoryExtraStrongholds = 0; g.techTokens = createTechTokens(); for (const token of Object.values(g.techTokens)) token.owner = 'p2'; row = strongholdProgress(g).progress[0]; assert.equal(row.techStronghold, true); assert.equal(row.qualifies, true); g.techTokens.production.owner = 'p1'; assert.equal(strongholdProgress(g).progress[0].techStronghold, false); g.twoPlayerControllers = {version: 1, controllers: [ {id: 'p0', seats: ['p0', 'p1']}, {id: 'p2', seats: ['p2', 'p3']}, ]}; assert.equal(strongholdProgress(g).progress[0].target, 4); }); void test('ordinary prediction checks every coalition winner while final Fremen special still wins', () => { const g = game(); hold(g, 0, a, b); hold(g, 1, c); hold(g, 2, d); g.players[3].prediction = {faction: 'harkonnen', turn: 2}; assert.deepEqual(quoteVictory(g).winner, ['p3']); g.players[3].prediction.turn = 3; assert.deepEqual(quoteVictory(g).winner, ['p0', 'p1', 'p2']); const f = game(['fremen', 'atreides', 'emperor', 'beneGesserit']); f.turn = 10; f.players[3].prediction = {faction: 'atreides', turn: 10}; assert.deepEqual(quoteVictory(f).winner, ['p0', 'p1', 'p2']); }); void test('Fremen, Guild and no-Guild Fremen final branches award the full alliance', () => { const fremen = game(['fremen', 'atreides', 'emperor', 'guild']); fremen.turn = 10; assert.deepEqual(fremenSpecialVictory(fremen)!.members, ['p0', 'p1', 'p2']); assert.deepEqual(quoteVictory(fremen).winner, ['p0', 'p1', 'p2']); hold(fremen, 1, c); assert.equal(fremenSpecialVictory(fremen)!.qualifies, false); assert.deepEqual(quoteVictory(fremen).winner, ['p3']); const guild = game(['guild', 'atreides', 'emperor', 'beneGesserit']); guild.turn = 10; assert.deepEqual(quoteVictory(guild).winner, ['p0', 'p1', 'p2']); const noGuild = game(['fremen', 'atreides', 'emperor', 'harkonnen']); noGuild.turn = 10; hold(noGuild, 1, c); assert.equal(fremenSpecialVictory(noGuild)!.qualifies, false); assert.deepEqual(quoteVictory(noGuild).winner, ['p0', 'p1', 'p2']); }); void test('Advanced classic triples quote normally while unsupported rosters and malformed records fail closed', () => { const g = game(); g.advanced = true; assert.doesNotThrow(() => strongholdProgress(g)); assert.doesNotThrow(() => fremenSpecialVictory(g)); assert.doesNotThrow(() => quoteVictory(g)); g.advanced = false; g.players[3].faction = 'ecaz'; assert.throws(() => strongholdProgress(g), VictoryProgressError); assert.throws(() => quoteVictory(g), VictoryQuoteError); g.players[3].faction = 'beneGesserit'; g.coalitionState.groups[0].edges.pop(); assert.throws(() => strongholdProgress(g), VictoryProgressError); assert.throws(() => quoteVictory(g), VictoryQuoteError); }); void test('a continuation deduplicates a triple side and requires every participant to consent', () => { let frame = createVictoryContinuation({event: 'continue', turn: 2, resume: 'mentat', order: ['p2', 'p0', 'p3', 'p1'], sides: [['p0', 'p1', 'p2'], ['p2', 'p0', 'p1'], ['p3']]}); assert.deepEqual(frame.sides, [['p2', 'p0', 'p1'], ['p3']]); assert.deepEqual(frame.voters, ['p2', 'p0', 'p3', 'p1']); for (const [index, player] of frame.voters.entries()) { const vote = voteVictoryContinuation(frame, {event: 'continue', player, choice: 'continue', ...(index === 0 ? {extraStrongholds: 1} : {})}); if (vote.outcome === 'pending') frame = vote.frame; else { assert.equal(index, 3); assert.deepEqual(vote, {outcome: 'continue', extraStrongholds: 1}); } } assert.throws(() => createVictoryContinuation({event: 'x', turn: 2, resume: 'mentat', order: ['p0', 'p1', 'p2', 'p3'], sides: [['p0', 'p1', 'p2'], ['p2', 'p3']]})); });