import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { coalitionGroups, coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; /** Controlled Basic Nexus and empty group ledger; no host-facing group option is * enabled. Pair formation and all subsequent negotiations use real actions. */ function fixture(): Game { const game = createGame('GROUPNEXUS', newPlayer('a', 'Atreides', 'atreides'), false); game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'), newPlayer('d', 'Harkonnen', 'harkonnen')); game.status = 'playing'; game.phase = 1; game.turn = 2; game.nexus = true; game.order = ['a', 'b', 'c', 'd']; game.ready = []; game.coalitionState = emptyCoalitionState(); return game; } function act(game: Game, id: string, action: Action): Game { return applyAction(game, id, action); } function originalPair(): Game { let game = fixture(); game = act(game, 'a', {type: 'alliance', target: 'b'}); game = act(game, 'b', {type: 'alliance', target: 'a'}); assert.equal(game.players[0].ally, 'b'); assert.equal(game.players[1].ally, 'a'); return game; } function propose(game: Game): Game { const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair); return act(game, 'a', {type: 'coalitionPropose', groupId: pair.id, entrant: 'c', expectedRevision: game.coalitionState!.revision}); } function answer(game: Game, id: string, accept = true): Game { const proposal = game.coalitionState!.proposal; assert.ok(proposal); return act(game, id, {type: 'coalitionAnswer', event: proposal.event, nexus: proposal.nexus, turn: proposal.turn, accept, expectedRevision: game.coalitionState!.revision}); } function rejected(game: Game, id: string, action: Action): void { const before = structuredClone(game); assert.throws(() => act(game, id, action)); assert.deepEqual(game, before, 'A rejected negotiation leaves its original state unchanged.'); } void test('native pair expands only after three separate group approvals', () => { const original = originalPair(); let game = propose(original); const proposal = game.coalitionState!.proposal; assert.ok(proposal); assert.ok(proposal.event && proposal.nexus); assert.equal(proposal.turn, game.turn); assert.deepEqual(proposal.approvals, ['a']); assert.equal(coalitionGroups(game.players, game.coalitionState)[0].members.length, 2); game = answer(game, 'b'); assert.deepEqual(game.coalitionState!.proposal!.approvals, ['a', 'b']); assert.equal(coalitionGroups(game.players, game.coalitionState)[0].members.length, 2); game = answer(game, 'c'); assert.equal(game.coalitionState!.proposal, null); assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a', 'b', 'c']); assert.deepEqual(game.players.slice(0, 3).map(player => player.ally), [null, null, null]); assert.equal(game.players[3].ally, null); assert.deepEqual(game.ready, []); }); void test('any participant may decline and the original pair remains unchanged', () => { const original = originalPair(); const pending = propose(original); const declined = answer(pending, 'c', false); assert.equal(declined.coalitionState!.proposal, null); assert.deepEqual(coalitionOf(declined.players, declined.coalitionState, 'a')!.members, ['a', 'b']); assert.equal(declined.players[0].ally, 'b'); assert.equal(declined.players[1].ally, 'a'); assert.equal(declined.players[2].ally, null); }); void test('stale revision, wrong event, outsider and duplicate approval cannot mutate an invitation', () => { let pending = propose(originalPair()); const invitation = pending.coalitionState!.proposal!; const expectedRevision = pending.coalitionState!.revision; const action = {type: 'coalitionAnswer', event: invitation.event, nexus: invitation.nexus, turn: invitation.turn, accept: true, expectedRevision}; rejected(pending, 'b', {...action, expectedRevision: expectedRevision - 1}); rejected(pending, 'b', {...action, event: 'stale-event'}); rejected(pending, 'b', {...action, nexus: 'stale-nexus'}); rejected(pending, 'd', action); rejected(pending, 'a', action); pending = answer(pending, 'b'); rejected(pending, 'c', action); }); void test('a pending invitation blocks Nexus readiness and other ordinary alliance actions', () => { const pending = propose(originalPair()); rejected(pending, 'a', {type: 'ready'}); rejected(pending, 'b', {type: 'alliance'}); rejected(pending, 'd', {type: 'alliance', target: 'c'}); }); void test('leaving a triple keeps the other two allied, and ordinary pair break remains valid', () => { let game = propose(originalPair()); game = answer(game, 'b'); game = answer(game, 'c'); const triple = coalitionOf(game.players, game.coalitionState, 'a')!; const age = triple.edges.find(edge => edge.members[0] === 'a' && edge.members[1] === 'b')!.formedTurn; game = act(game, 'c', {type: 'coalitionLeave', expectedRevision: game.coalitionState!.revision}); const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair); assert.deepEqual(pair.members, ['a', 'b']); assert.equal(pair.edges[0].formedTurn, age); assert.equal(game.players[0].ally, 'b'); assert.equal(game.players[1].ally, 'a'); assert.equal(game.players[2].ally, null); game = act(game, 'a', {type: 'alliance'}); assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null); assert.equal(game.players[1].ally, null); }); void test('each seated player sees the public group negotiation and its current revision', () => { const pending = propose(originalPair()); const proposal = pending.coalitionState!.proposal!; for (const seat of pending.players) { const view = viewGame(pending, seat.id).coalitionNexus; assert.ok(view); assert.equal(view.me, seat.id); assert.equal(view.revision, pending.coalitionState!.revision); assert.deepEqual(view.proposal, proposal); assert.deepEqual(view.groups.map(group => group.members), [['a', 'b']]); assert.deepEqual(view.players.map(player => player.id), ['a', 'b', 'c', 'd']); } }); for (const difficulty of DIFFICULTIES) void test(`${difficulty} gives a legal group answer and uninvolved bots wait`, () => { const pending = propose(originalPair()); const proposal = pending.coalitionState!.proposal!; for (const id of ['b', 'c']) { const view = viewGame(pending, id); view.players.find(player => player.id === id)!.bot = difficulty; const action = botActions(view)[0]; assert.ok(action); assert.deepEqual(action, {type: 'coalitionAnswer', event: proposal.event, nexus: proposal.nexus, turn: proposal.turn, accept: true, expectedRevision: pending.coalitionState!.revision}); const after = act(pending, id, action); assert.ok(after.coalitionState!.proposal?.approvals.includes(id)); } for (const id of ['a', 'd']) { const view = viewGame(pending, id); view.players.find(player => player.id === id)!.bot = difficulty; assert.deepEqual(botActions(view), [], 'The approved proposer and outsider wait while the invitation is pending.'); } const approved = answer(pending, 'b'); const view = viewGame(approved, 'b'); view.players.find(player => player.id === 'b')!.bot = difficulty; assert.deepEqual(botActions(view), [], 'A player who already consented waits for the remaining participant.'); }); void test('closed or earlier Nexus cannot be resumed using an otherwise current group invitation', () => { const pending = propose(originalPair()); const invitation = pending.coalitionState!.proposal!; const action: Action = {type: 'coalitionAnswer', event: invitation.event, nexus: invitation.nexus, turn: invitation.turn, accept: true, expectedRevision: pending.coalitionState!.revision}; const closed = structuredClone(pending); closed.nexus = false; rejected(closed, 'b', action); const laterTurn = structuredClone(pending); laterTurn.turn++; rejected(laterTurn, 'b', action); });