import test from 'node:test'; import assert from 'node:assert/strict'; import { applyAction, createGame, initializeBaseGameForAudit, newPlayer, viewGame, type Game, } from '../game/engine'; import { botActions } from '../game/bots'; import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; function fixture(advanced = false, sisterhood = true, fremen = true) { const g = createGame( 'STAGEDSETUPBOT', newPlayer('b', 'First seat', sisterhood ? 'beneGesserit' : 'emperor'), advanced, ); g.players.push( newPlayer('f', 'Second seat', fremen ? 'fremen' : 'guild'), newPlayer('h', 'Harkonnen', 'harkonnen'), ); g.players.forEach((p) => { p.ready = true; }); // Advanced exercises the explicit offline initializer, never weakens the public start gate. return advanced ? initializeBaseGameForAudit(g) : applyAction(g, 'b', { type: 'start' }); } function candidates(g: Game, id: string, difficulty: Difficulty) { const view = viewGame(g, id); view.players.find((p) => p.id === id)!.bot = difficulty; const before = structuredClone(view), actions = botActions(view); assert.deepEqual(view, before); for (const action of actions) assert.doesNotThrow( () => applyAction(g, id, action), `${difficulty}/${id}: ${JSON.stringify(action)}`, ); return actions; } function predict(g: Game, difficulty: Difficulty) { const actions = candidates(g, 'b', difficulty); assert.deepEqual( actions.map((a) => a.type), ['predict'], ); return applyAction(g, 'b', actions[0]); } function traitors(initial: Game, difficulty: Difficulty) { let g = initial; for (let n = 0; g.setupStage === 'traitors' && n < 6; n++) { const pending = viewGame(g, 'b').setupPending; assert.ok(pending.length > 0); const id = pending[0], actions = candidates(g, id, difficulty); assert.deepEqual( actions.map((a) => a.type), ['traitor'], ); g = applyAction(g, id, actions[0]); } assert.notEqual(g.setupStage, 'traitors'); return g; } void test('all profiles honor prediction before private cards, traitors before forces, and automatic final dealing', () => { for (const difficulty of DIFFICULTIES) for (const advanced of [false, true]) { let g = fixture(advanced); assert.equal(g.setupStage, 'prediction'); assert.deepEqual(viewGame(g, 'b').setupPending, ['b']); assert.ok( g.players.every( (p) => !p.hand.length && !p.traitorChoices.length && !p.traitors.length, ), ); assert.ok( g.players.every( (p) => p.spice === 0 && Object.keys(p.forces).length === 0, ), ); assert.deepEqual(candidates(g, 'f', difficulty), []); assert.deepEqual(candidates(g, 'h', difficulty), []); g = predict(g, difficulty); assert.equal(g.setupStage, 'traitors'); assert.ok(g.players.every((p) => p.hand.length === 0)); assert.deepEqual( candidates(g, 'h', difficulty), [], 'Harkonnen already keeps its dealt traitors without a confirmation.', ); const first = candidates(g, 'b', difficulty)[0]; assert.equal(first.type, 'traitor'); g = applyAction(g, 'b', first); assert.deepEqual( candidates(g, 'b', difficulty), [], 'A finished traitor selection cannot skip ahead to advisor setup.', ); g = traitors(g, difficulty); assert.equal(g.setupStage, 'forces'); assert.deepEqual(viewGame(g, 'b').setupPending, ['f']); assert.deepEqual( candidates(g, 'b', difficulty), [], 'Fremen place before the Advanced starting advisor.', ); const placement = candidates(g, 'f', difficulty); assert.deepEqual( placement.map((a) => a.type), ['fremenSetup'], ); assert.ok(g.players.every((p) => p.hand.length === 0)); g = applyAction(g, 'f', placement[0]); if (advanced) { assert.equal(g.setupStage, 'forces'); assert.deepEqual(viewGame(g, 'f').setupPending, ['b']); const advisors = candidates(g, 'b', difficulty); assert.ok( advisors.length > 0 && advisors.every((a) => a.type === 'advisorSetup'), ); g = applyAction(g, 'b', advisors[0]); } assert.equal(g.status, 'playing'); assert.equal(viewGame(g, 'b').setupStage, null); assert.deepEqual(viewGame(g, 'b').setupPending, []); assert.ok( g.players.every((p) => p.hand.length > 0), 'Starting cards are dealt without another bot acknowledgement.', ); } }); void test('absent Bene Gesserit or Fremen skips only the corresponding setup step for every profile', () => { for (const difficulty of DIFFICULTIES) for (const advanced of [false, true]) { let noSisterhood = fixture(advanced, false); assert.equal(noSisterhood.setupStage, 'traitors'); assert.equal( candidates(noSisterhood, 'b', difficulty)[0].type, 'traitor', ); noSisterhood = traitors(noSisterhood, difficulty); assert.equal(noSisterhood.setupStage, 'forces'); noSisterhood = applyAction( noSisterhood, 'f', candidates(noSisterhood, 'f', difficulty)[0], ); assert.equal(noSisterhood.status, 'playing'); let noFremen = predict(fixture(advanced, true, false), difficulty); noFremen = traitors(noFremen, difficulty); if (advanced) { assert.deepEqual(viewGame(noFremen, 'f').setupPending, ['b']); noFremen = applyAction( noFremen, 'b', candidates(noFremen, 'b', difficulty)[0], ); } assert.equal(noFremen.status, 'playing'); } }); void test('public stage fences stale private controls without giving an AI an out-of-stage candidate', () => { for (const difficulty of DIFFICULTIES) { const g = fixture(true); for (const id of ['b', 'f']) { const view = viewGame(g, id), me = view.players.find((p) => p.id === id)!; me.bot = difficulty; me.traitorChoices = ['harkonnen-0']; const actions = botActions(view); assert.deepEqual( actions.map((a) => a.type), id === 'b' ? ['predict'] : [], ); for (const action of actions) assert.doesNotThrow(() => applyAction(g, id, action)); } } }); void test('public setup ownership contains only IDs and rival prediction/traitor identities cannot change a bot choice', () => { for (const difficulty of DIFFICULTIES) { const g = predict(fixture(true), difficulty); const baseline = candidates(g, 'f', difficulty); const view = viewGame(g, 'f'); assert.ok( view.setupPending.every((id) => g.players.some((p) => p.id === id)), ); assert.equal(view.players[0].prediction, undefined); assert.equal(view.players[0].traitorChoices, undefined); const changed = structuredClone(g); changed.players[0].prediction = { faction: 'harkonnen', turn: 9 }; changed.players[0].traitorChoices.reverse(); changed.players[2].traitors.reverse(); assert.deepEqual(candidates(changed, 'f', difficulty), baseline); assert.deepEqual(viewGame(changed, 'f').setupPending, view.setupPending); } }); void test('legacy setup without a stage retains traitor, prediction and public Fremen wait behavior', () => { for (const difficulty of DIFFICULTIES) { const g = createGame( 'LEGACYSETUPBOT', newPlayer('b', 'Sisterhood', 'beneGesserit'), true, ); g.players.push(newPlayer('f', 'Fremen', 'fremen')); g.status = 'setup'; g.players[0].traitorChoices = ['fremen-0']; assert.equal(viewGame(g, 'b').setupStage, null); assert.deepEqual(viewGame(g, 'b').setupPending, []); const chosen = applyAction(g, 'b', candidates(g, 'b', difficulty)[0]); assert.equal(candidates(chosen, 'b', difficulty)[0].type, 'predict'); const predicted = applyAction( chosen, 'b', candidates(chosen, 'b', difficulty)[0], ); assert.deepEqual(candidates(predicted, 'b', difficulty), []); assert.equal(candidates(predicted, 'f', difficulty)[0].type, 'fremenSetup'); } });