Files
Dune/tests/mentat-question.test.ts
Quintine fde44baab5 Compose standalone E3 Skills and independent cards with native power defaults
Restore fresh Ecaz coalition and Advanced Moritani initialization; retain printed native continuations and selected-family card eligibility. Validate Mentat fallback hands against explicitly selected independent cards. Frozen25-file274/274, types/lint/build and genuine runtime/mobile entry proofs pass. No save retrofit or deployment.
2026-10-17 20:36:01 +11:00

576 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof view.decision>,
{ 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, []);
});