import assert from 'node:assert/strict'; import test from 'node:test'; import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES } from '../game/bot-profiles'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; const player = (g: Game, id: string) => g.players.find(p => p.id === id)!; const copy = (g: Game): Game => JSON.parse(JSON.stringify(g)); /** A real Advanced public start and native setup. The reciprocal alliance and * clean movement boundary are staged; no saved room or database is touched. */ function guildTiming() { let g = createGame('EMPGUILDTIMING', newPlayer('e', 'Emperor', 'emperor'), true); joinGame(g, newPlayer('g', 'Guild', 'guild')); joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen')); for (const p of g.players) g = applyAction(g, p.id, {type:'ready'}); g = applyAction(g, 'e', {type:'start', advancedPreview:true}); for (let i = 0; i < 60 && g.status === 'setup'; i++) { const step = nextStrongholdFactionsNativeStep(g); assert.ok(step); g = applyAction(g, step.actor, step.action); } assert.equal(g.status, 'playing'); player(g, 'e').ally = 'g'; player(g, 'g').ally = 'e'; // Select the legitimate phase boundary that movementTurn would produce. Object.assign(g, { phase:5, turn:1, order:['e','g','h'], active:null, movementRemaining:['e','g','h'], guildTimingGranted:false, guildTimingLocked:false, decision:{kind:'guildTiming', player:'g', next:'e', following:'e'}, response:null, phaseOpening:null, ready:[], }); assert.equal(g.decision?.kind, 'guildTiming'); return g; } function allow(g: Game) { for (let i = 0; g.response && i < 15; i++) { const actor = g.players.find(p => !g.response!.passed.includes(p.id)); assert.ok(actor); g = applyAction(g, actor.id, {type:'passResponse'}); } assert.equal(g.response, null); return g; } function parent(g: Game) { return {decision:structuredClone(g.decision), phase:g.phase, turn:g.turn, active:g.active, order:[...g.order], movementRemaining:structuredClone(g.movementRemaining), guildTimingGranted:g.guildTimingGranted, guildTimingLocked:g.guildTimingLocked}; } function give(g: Game, amount = 2) { return applyAction(g, 'e', {type:'emperorGift', amount}); } function setAsideKaramas(g: Game) { for (const p of g.players) { const cards = p.hand.filter(card => card.effect === 'karama'); p.hand = p.hand.filter(card => card.effect !== 'karama'); g.deck.push(...cards); } } function holdCounter(g: Game) { setAsideKaramas(g); const index = g.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); const [card] = g.deck.splice(index, 1); player(g, 'h').hand.push(card); return card.id; } function guildChooses(g: Game) { assert.equal(g.decision?.kind, 'guildTiming'); g = applyAction(g, 'g', {type:'decision', take:true}); if (g.response) g = allow(g); assert.equal(g.active, 'g'); assert.equal(g.phase, 5); return g; } void test('Advanced Guild timing shows only the Emperor a legal gift and resumes the exact choice after allowing', () => { let g = guildTiming(); holdCounter(g); const original = parent(g), emperor = player(g, 'e').spice, guild = player(g, 'g').spice; assert.deepEqual(viewGame(g, 'e').emperorGuildTimingGift, {recipient:'g', maximum:emperor}); assert.equal(viewGame(g, 'g').emperorGuildTimingGift, null); assert.equal(viewGame(g, 'h').emperorGuildTimingGift, null); g = give(g); assert.equal(g.response?.kind, 'emperorGift'); assert.equal(player(g, 'e').spice, emperor); assert.equal(player(g, 'g').spice, guild); assert.equal(viewGame(g, 'e').emperorGuildTimingGift, null); g = allow(copy(g)); assert.deepEqual(parent(g), original); assert.equal(player(g, 'e').spice, emperor - 2); assert.equal(player(g, 'g').spice, guild + 2); guildChooses(g); }); void test('a real Karama cancels the gift and restores the same Guild timing decision', () => { let g = guildTiming(); const counter = holdCounter(g), original = parent(g); const emperor = player(g, 'e').spice, guild = player(g, 'g').spice; g = give(g, 3); assert.equal(g.response?.kind, 'emperorGift'); g = applyAction(g, 'h', {type:'card', card:counter, mode:'cancel'}); assert.equal(g.response, null); assert.deepEqual(parent(g), original); assert.equal(player(g, 'e').spice, emperor); assert.equal(player(g, 'g').spice, guild); assert.equal(g.discard.filter(card => card.id === counter).length, 1); guildChooses(g); }); void test('other players and unfunded or invalid gifts cannot disturb the pending Guild decision', () => { const g = guildTiming(), maximum = player(g, 'e').spice; const attempts: [string, Action][] = [ ['g', {type:'emperorGift', amount:1}], ['h', {type:'emperorGift', amount:1}], ['e', {type:'emperorGift', amount:0}], ['e', {type:'emperorGift', amount:maximum + 1}], ]; for (const [actor, action] of attempts) { const before = copy(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before); } const unallied = copy(g); player(unallied, 'e').ally = null; player(unallied, 'g').ally = null; const before = copy(unallied); assert.equal(viewGame(unallied, 'e').emperorGuildTimingGift, null); assert.throws(() => give(unallied)); assert.deepEqual(unallied, before); }); void test('the pending gift remains bound to its original amount and Guild timing decision', () => { const original = guildTiming(); holdCounter(original); const pending = give(original, 2); const changedAmount = copy(pending); assert.ok(changedAmount.response); changedAmount.response.amount = 1; assert.throws(() => viewGame(changedAmount, 'g')); assert.throws(() => applyAction(changedAmount, 'h', {type:'passResponse'})); const changedDecision = copy(pending); assert.ok(changedDecision.decision?.kind === 'guildTiming'); changedDecision.decision.next = 'h'; assert.throws(() => viewGame(changedDecision, 'e')); assert.throws(() => applyAction(changedDecision, 'h', {type:'passResponse'})); assert.equal(viewGame(pending, 'h').response?.amount, undefined); }); void test('all four minimal bot profiles can resolve the gift response legally', () => { let g = guildTiming(); holdCounter(g); const original = parent(g); g = give(g, 1); for (const difficulty of DIFFICULTIES) { let trial = copy(g); const view = viewGame(trial, 'h'); view.players.find(p => p.id === 'h')!.bot = difficulty; const proposed = botActions(view); assert.deepEqual(proposed, [{type:'passResponse'}]); trial = applyAction(trial, 'h', proposed[0]); trial = allow(trial); assert.deepEqual(parent(trial), original); guildChooses(trial); } });