import assert from 'node:assert/strict'; import test, { mock } from 'node:test'; import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game, } from '../game/engine'; import { mentatHand, mentatWeaponNames, quoteMentatReveal, } from '../game/mentat-question'; import { baseDeck, ixDeck } from '../game/cards'; import { ecazTreacheryCards } from '../game/ecaz-cards'; import { finishMentatPosture, mentatPlayer, mentatQuestionGame, mentatReload, } from './mentat-question-fixture'; function reject(game: Game, owner: string, action: Action) { const before = JSON.stringify(game); assert.throws(() => applyAction(game, owner, action)); assert.equal(JSON.stringify(game), before); } function resources(game: Game) { return structuredClone({ players: game.players, deck: game.deck, discard: game.discard, cache: game.richeseCache, skills: game.leaderSkills, }); } function nameWeapon(game: Game, weapon = 'Crysknife') { return applyAction(game, 'a', { type: 'decision', event: game.battle!.mentatQuestionEvent, weapon, }); } function ask(game: Game, weapon = 'Crysknife') { const pending = nameWeapon(game, weapon), choice = viewGame(pending, 'd').mentat.pending!; return applyAction(pending, 'd', { type: 'decision', event: choice.event, card: choice.cards[0].id, }); } function finishPowers(game: Game) { game = finishMentatPosture(game); for ( let i = 0; i < 12 && (game.battle?.preparation || game.decision?.kind === 'fullPlanOffer'); i++ ) { game = game.decision?.kind === 'fullPlanOffer' ? applyAction(game, game.decision.player, { type: 'decision', decline: true, }) : applyAction(game, game.battle!.preparation!.owner, { type: 'declineBattlePower', }); } return game; } void test('Mentat names exact printed/default weapons and validates physical hands without permissive slot substitutes', () => { assert.equal(mentatWeaponNames([]).length, 9); const choam = mentatWeaponNames(['choam']); for (const name of [ 'Poison Tooth', 'Artillery Strike', 'Stone Burner', 'Mirror Weapon', ]) assert.ok(choam.includes(name)); const ix = mentatWeaponNames(['ix']); for (const name of [ 'Poison Blade', 'Hunter Seeker', 'Basilia Weapon', 'Weirding Way', ]) assert.ok(ix.includes(name)); for (const name of [ 'Chemistry', 'Baliset', 'Snooper', 'Cheap Hero', 'Residual Poison', 'poison', 'projectile', ]) { assert.ok(!choam.includes(name)); assert.ok(!ix.includes(name)); assert.throws(() => quoteMentatReveal(baseDeck().slice(0, 1), name, [], false)); } assert.throws(() => mentatHand([baseDeck()[0], baseDeck()[0]], [], false)); assert.throws(() => mentatHand([{ ...baseDeck()[0], name: 'Lasgun' }], [], false)); assert.throws(() => mentatHand(ixDeck().slice(0, 1), [], false)); }); void test('Mentat accepts selected independent Specials as fallback disclosures, never as named weapons or unselected cards', () => { const specials = ecazTreacheryCards(); const names = mentatWeaponNames(['ecaz']); for (const card of specials) { assert.ok(!names.includes(card.name)); assert.deepEqual(quoteMentatReveal([card], 'Lasgun', ['ecaz'], true).cards, [card]); assert.throws(() => quoteMentatReveal([card], 'Lasgun', ['ecaz'], false)); } }); for (const hide of [false, true]) void test(`independent-card Mentat disclosure keeps physical custody and resumes ${hide ? 'trained' : 'normal'} posture`, () => { let game = mentatQuestionGame({ independent: true, cards: ecazTreacheryCards().map(card => card.name) }); const before = resources(game); game = nameWeapon(game, 'Lasgun'); const pending = viewGame(game, 'd').mentat.pending!; assert.deepEqual(pending.cards.map(card => card.id), ecazTreacheryCards().map(card => card.id)); assert.deepEqual(viewGame(game, 'a').mentat.pending!.cards, []); assert.deepEqual(viewGame(game, 'o').mentat.pending!.cards, []); const shown = pending.cards.find(card => card.effect === 'harassWithdraw')!; game = applyAction(game, 'd', { type: 'decision', event: pending.event, card: shown.id }); assert.deepEqual(resources(game), before, 'Showing a card neither transfers nor reserves it'); assert.deepEqual(viewGame(game, 'a').mentat.history[0].card, shown); assert.deepEqual(viewGame(game, 'o').mentat.history, []); game = finishMentatPosture(game, hide); assert.equal(game.battle!.leaderSkillHidden!.a, hide); game = finishPowers(game); game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: hide ? 'emperor-0' : 'emperor-1', }); assert.equal(game.battle!.plans.a.leader, hide ? 'emperor-0' : 'emperor-1'); assert.equal(game.battle!.plans.d, undefined, 'The other combatant still owns its unsealed plan'); assert.ok(mentatPlayer(game, 'd').hand.some(card => card.id === shown.id)); }); void test('fresh supported Leader Skills battles offer a private Mentat question without a separate opt-in', () => { for (const advanced of [false, true]) for (const choam of [false, true]) { const game = mentatReload(mentatQuestionGame({ advanced, choam })); assert.equal(game.battle!.mentatQuestionVersion, 1); assert.equal(normalizeAutomaticGame(game).decision?.kind, 'mentatQuestion'); assert.equal(viewGame(game, 'a').mentat.pending!.stage, 'name'); assert.deepEqual(viewGame(game, 'd').mentat.pending!.weapons, []); assert.deepEqual(viewGame(game, 'o').mentat.history, []); } }); for (const advanced of [false, true]) for (const choam of [false, true]) void test(`genuine ${choam ? 'CHOAM' : 'base'} ${advanced ? 'Advanced' : 'Basic'} Mentat asks privately before posture and faction powers`, () => { const game = mentatQuestionGame({ advanced, choam, cards: [choam ? 'Poison Tooth' : 'Crysknife', 'Snooper'], }); const before = resources(game), event = game.battle!.mentatQuestionEvent; assert.equal(game.decision?.kind, 'mentatQuestion'); assert.equal(game.battle!.preparation, undefined); assert.equal(game.battle!.leaderSkillHidden, undefined); for (const owner of ['d', 'o']) assert.deepEqual(viewGame(game, owner).mentat.pending!.weapons, []); mock.method(globalThis.crypto, 'getRandomValues', () => { throw new Error('Unexpected disclosure RNG'); }); mock.method(globalThis.crypto, 'randomUUID', () => { throw new Error('Unexpected new disclosure event'); }); let done: Game; try { done = ask(mentatReload(game), choam ? 'Poison Tooth' : 'Crysknife'); } finally { mock.restoreAll(); } assert.deepEqual(resources(done), before); assert.equal(done.decision?.kind, 'leaderSkillVisibility'); assert.equal(done.battle!.preparation, undefined); const observed = viewGame(done, 'a').mentat.history[0]; assert.equal(observed.event, event); assert.equal(observed.card.id, mentatPlayer(game, 'd').hand[0].id); assert.deepEqual(viewGame(done, 'o').mentat.history, []); assert.ok( !JSON.stringify(viewGame(done, 'o')).includes(observed.card.id), ); assert.ok(!JSON.stringify(done.log).includes(observed.card.id)); assert.deepEqual(normalizeAutomaticGame(mentatReload(done)), done); done = finishMentatPosture(done); assert.equal(done.battle!.leaderSkillHidden!.a, true); assert.ok( done.battle!.preparation || done.decision?.kind === 'fullPlanOffer', ); reject(done, 'a', { type: 'decision', event, weapon: 'Lasgun' }); }); void test('absent weapon requires the opponent’s private choice of any other card, with no transfer, reservation or plan promise afterward', () => { let game = mentatQuestionGame(); const before = resources(game), event = game.battle!.mentatQuestionEvent; game = nameWeapon(game, 'Lasgun'); assert.equal(game.decision?.player, 'd'); assert.equal(game.decision?.kind, 'mentatQuestion'); for (const owner of ['a', 'o']) assert.deepEqual(viewGame(game, owner).mentat.pending!.cards, []); assert.deepEqual( viewGame(game, 'd').mentat.pending!.cards, mentatPlayer(game, 'd').hand, ); reject(game, 'a', { type: 'decision', event, card: mentatPlayer(game, 'd').hand[1].id, }); reject(game, 'd', { type: 'decision', event: 'stale', card: mentatPlayer(game, 'd').hand[1].id, }); reject(game, 'd', { type: 'decision', event, decline: true }); reject(game, 'd', { type: 'decision', event, card: 'missing' }); reject(game, 'o', { type: 'card', card: mentatPlayer(game, 'o').hand[0].id, mode: 'special', }); game = applyAction(mentatReload(game), 'd', { type: 'decision', event, card: mentatPlayer(game, 'd').hand[1].id, }); assert.equal(viewGame(game, 'a').mentat.history[0].card.name, 'Snooper'); assert.deepEqual(resources(game), before); game = finishPowers(game); game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0', }); game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'atreides-1', }); assert.equal(game.battle!.plans.d.defense, null); game = applyAction(game, 'a', { type: 'traitorCall', call: false }); game = applyAction(game, 'd', { type: 'traitorCall', call: false }); assert.equal(viewGame(game, 'a').mentat.history[0].card.name, 'Snooper'); assert.ok( mentatPlayer(game, 'd').hand.some((card) => card.name === 'Snooper'), ); assert.deepEqual(viewGame(mentatReload(game), 'o').mentat.history, []); }); void test('exact match and single-card fallback use the same private response; decline is unspent and empty hands skip without disclosure', () => { const single = mentatQuestionGame({ cards: ['Snooper'] }); assert.equal(nameWeapon(single).decision?.player, 'd'); assert.equal(nameWeapon(mentatQuestionGame()).decision?.player, 'd'); assert.equal(ask(single).battle!.mentatQuestion!.stage, 'answered'); const named = mentatQuestionGame(), before = resources(named); const declined = applyAction(named, 'a', { type: 'decision', event: named.battle!.mentatQuestionEvent, decline: true, }); assert.equal(declined.battle!.mentatQuestion!.stage, 'declined'); assert.deepEqual(resources(declined), before); assert.deepEqual(viewGame(declined, 'a').mentat.history, []); const empty = mentatQuestionGame({ cards: [] }); assert.equal(empty.battle!.mentatQuestion!.stage, 'unavailable'); assert.equal(empty.decision?.kind, 'leaderSkillVisibility'); assert.equal(viewGame(empty, 'a').mentat.pending, null); assert.deepEqual(viewGame(empty, 'a').mentat.history, []); assert.ok( empty.log.some((entry) => entry.text.includes('empty-hand ruling remains unresolved'), ), ); reject(empty, 'a', { type: 'decision', event: empty.battle!.mentatQuestionEvent, weapon: 'Crysknife', }); }); void test('a shown weapon need not be used; knowledge survives the selected native Mentat’s death and skill return', () => { let game = finishPowers(ask(mentatQuestionGame())); const observation = viewGame(game, 'a').mentat.history[0]; game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0', }); game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'atreides-1', weapon: observation.card.id, }); game = applyAction(game, 'a', { type: 'traitorCall', call: false }); game = applyAction(game, 'd', { type: 'traitorCall', call: false }); assert.ok( mentatPlayer(game, 'a').leaders.find((leader) => leader.id === 'emperor-0')! .dead, ); assert.ok( !game.leaderSkills!.assignments.some( (assignment) => assignment.skill === 'mentat', ), ); assert.deepEqual(viewGame(mentatReload(game), 'a').mentat.history, [ observation, ]); const noWeapon = finishPowers(ask(mentatQuestionGame())); const first = applyAction(noWeapon, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0', }); assert.equal( applyAction(first, 'd', { type: 'battlePlan', dial: 0, leader: 'atreides-1', }).battle!.plans.d.weapon, null, ); }); void test('saved Mentat decisions and observations reject missing, changed, duplicate or nested context before any view, action or normalization', () => { const name = mentatQuestionGame(), reveal = nameWeapon(name, 'Lasgun'), answered = ask(name); const cases: [Game, (game: Game) => void][] = [ [ name, (g) => { g.decision = null; }, ], [ name, (g) => { delete g.battle!.mentatQuestion; }, ], [ name, (g) => { delete g.battle!.mentatQuestionEvent; }, ], [ name, (g) => { delete g.battle!.mentatQuestionVersion; }, ], [ name, (g) => { g.battle!.mentatQuestion!.event = 'forged'; }, ], [ name, (g) => { mentatPlayer(g, 'd').hand.reverse(); }, ], [ name, (g) => { g.deck.push({ ...mentatPlayer(g, 'd').hand[0] }); }, ], [ reveal, (g) => { if (g.decision?.kind === 'mentatQuestion') g.decision.player = 'a'; }, ], [ reveal, (g) => { g.pendingExchange = { decision: g.decision, response: null }; g.decision = null; }, ], [ answered, (g) => { g.mentatHistory![0].card.name = 'Lasgun'; }, ], [ answered, (g) => { g.mentatHistoryEvents = []; }, ], [ answered, (g) => { g.decision = name.decision; }, ], ]; for (const [source, mutate] of cases) { const game = mentatReload(source); mutate(game); const before = JSON.stringify(game); for (const operation of [ () => viewGame(game, 'a'), () => normalizeAutomaticGame(game), () => applyAction(game, 'a', { type: 'advanceBots' }), ]) { assert.throws(operation); assert.equal(JSON.stringify(game), before); } } }); void test('legacy unmarked battles are not reopened and later new battles receive a fresh question event', () => { const legacy = mentatQuestionGame(); delete legacy.battle!.mentatQuestion; delete legacy.battle!.mentatQuestionEvent; delete legacy.battle!.mentatQuestionVersion; legacy.decision = { kind: 'leaderSkillVisibility', player: 'a', event: legacy.battle!.event!, }; assert.equal( viewGame(normalizeAutomaticGame(mentatReload(legacy)), 'a').mentat.pending, null, ); const continued = finishPowers(normalizeAutomaticGame(mentatReload(legacy))); assert.equal(continued.battle!.event, legacy.battle!.event); assert.equal(continued.battle!.mentatQuestionVersion, undefined); assert.equal(continued.battle!.mentatQuestion, undefined); assert.equal(continued.decision, null); assert.deepEqual(viewGame(continued, 'a').mentat.history, []); const planned = applyAction(continued, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0', }); assert.ok(planned.battle!.plans.a); assert.equal(planned.battle!.mentatQuestion, undefined); let game = finishPowers(ask(mentatQuestionGame())); const original = game.battle!.mentatQuestionEvent; game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-0', }); game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'atreides-1', }); game = applyAction(game, 'a', { type: 'traitorCall', call: false }); game = applyAction(game, 'd', { type: 'traitorCall', call: false }); // Conserved next-turn board staging exercises the real new-battle creation hook. for (const id of ['a', 'd']) { const p = mentatPlayer(game, id); p.forces = { 'arrakeen:10': 1 }; p.reserves = 19 - p.tanks; for (const leader of p.leaders) delete leader.usedAt; } Object.assign(game, { turn: 3, phase: 6, active: 'a', battle: null, decision: null, response: null, phaseOpening: null, }); game = applyAction(game, 'a', { type: 'chooseBattle', territory: 'arrakeen', target: 'd', }); assert.notEqual(game.battle!.mentatQuestionEvent, original); assert.equal(game.decision?.kind, 'mentatQuestion'); assert.equal(viewGame(game, 'a').mentat.history.length, 1); reject(game, 'a', { type: 'decision', event: original, weapon: 'Crysknife' }); }); void test('canonical Richese weapon disclosure uses the same private path and card conservation', () => { for (const name of ['Stone Burner', 'Mirror Weapon']) { const game = mentatQuestionGame({ choam: true, cards: [name, 'Snooper'] }), before = resources(game); const done = ask(game, name); assert.equal(viewGame(done, 'a').mentat.history[0].card.name, name); assert.deepEqual(resources(done), before); assert.deepEqual(viewGame(done, 'o').mentat.history, []); } }); void test('captured Mentat grants no question and retains its selected captured-leader lower bonus', () => { let game = mentatQuestionGame({ captured: true }); assert.equal(game.battle!.mentatQuestion, undefined); assert.equal(viewGame(game, 'd').mentat.pending, null); game = finishPowers(game); game = applyAction(game, 'a', { type: 'battlePlan', dial: 0, leader: 'emperor-1', }); game = applyAction(game, 'd', { type: 'battlePlan', dial: 0, leader: 'emperor-0', }); game = applyAction(game, 'a', { type: 'traitorCall', call: false }); game = applyAction(game, 'd', { type: 'traitorCall', call: false }); assert.ok( game.log.some((entry) => entry.text.includes('gained 2 battle strength from Mentat'), ), ); assert.deepEqual(viewGame(game, 'd').mentat.history, []); }); void test('exact match and absent weapon produce indistinguishable public question stages and hide response eligibility', () => { const held = mentatQuestionGame(), absent = mentatQuestionGame({ cards: ['Maula Pistol', 'Snooper'] }); const namedHeld = nameWeapon(held), namedAbsent = nameWeapon(absent); const publicPending = (game: Game, viewer: string) => { const view = viewGame(game, viewer); const { event: _event, ...pending } = view.mentat.pending!; assert.equal(view.decision!.kind, 'mentatQuestion'); const { event: _decisionEvent, ...decision } = view.decision as Extract< NonNullable, { kind: 'mentatQuestion' } >; return { pending, decision, history: view.mentat.history, log: game.log.at(-1)!.text, }; }; for (const viewer of ['a', 'o']) assert.deepEqual( publicPending(namedHeld, viewer), publicPending(namedAbsent, viewer), ); assert.equal(namedHeld.log.length - held.log.length, 1); assert.equal(namedAbsent.log.length - absent.log.length, 1); const legal = viewGame(namedHeld, 'd').mentat.pending!.cards; assert.equal(legal.length, 1); assert.equal(legal[0].name, 'Crysknife'); reject(namedHeld, 'd', { type: 'decision', event: namedHeld.battle!.mentatQuestionEvent, card: mentatPlayer(namedHeld, 'd').hand[1].id, }); assert.equal(viewGame(namedAbsent, 'd').mentat.pending!.cards.length, 2); assert.deepEqual(viewGame(namedHeld, 'a').mentat.pending!.cards, []); assert.deepEqual(viewGame(namedAbsent, 'a').mentat.pending!.cards, []); });