Prototype private Mentat questions behind explicit preview opt-in
Connect the optional named-weapon question before skill posture and faction powers. Every accepted question waits for the same target-owned private card-show action, preserving held/absent privacy; matching cards are compulsory, otherwise the owner selects a fallback. Keep cards in hand and retain historical observations only for the questioner and target, without binding later plans. Support actual controls, inspectors, all four AI profiles and saved JSON/SQLite continuation. Normal activation remains disabled pending the user's uniform-response UX exception. The private flag is neither projected nor writable through actions. Legacy battles remain unchanged. Empty hands skip with an explicit unavailable receipt and no invented answer. Source and unresolved boundaries are documented, with a compact current-status dashboard preserving all goal, Git, subagent, usage and saved-game requirements. Independent review passed privacy, custody, sequencing and preview gating. Twenty-two focused tests cover canonical card roles, exact-match/fallback symmetry, target-only controls, captured lower-only behavior, stale/duplicate requests and concurrent SQLite writes. Typecheck, lint, 4605 offline tests, production build and 43 HTTP tests passed with stable source. The first broad run retained one outdated Mentat card-copy assertion; update it to verify private disclosure, custody, optional question and subsequent concealment. Gameplay source was unchanged by that test-only correction. Two genuine-setup sample games deliberately deal Mentat via a controlled initial skill shuffle. Basic: 100 actions, 3 JSON restores, turn-one Harkonnen win. Advanced: 507 actions, 14 restores, turn-five Harkonnen win. Each has one private observation, subsequent Atreides Prescience and no rejected candidate. These bounded force/skill custody samples do not establish general card-custody coverage, calibration or full module acceptance. After the reported outage all 665 opening rooms and original seat/recovery/receipt records were preserved (699 after integration). A fresh 666-room backup preceded checks; database integrity and foreign keys pass. The existing localhost server was reused; its live lock prevented a duplicate start. Existing Ecaz and Ixian browser seats restored their same hands and pending choices. A separate conserved staged Mentat preview restored its private history and posture, then legally concealed its leader before subsequent faction powers. Human/browser paths are distinguished from genuine-setup simulations. No database reset, recurring automation, public activation or publication. Verified source tree: 297ae06e0764d7cad525a802fe383a84971c3e9c5d6362c8cd0b79c5c3977caf Final source tree: 177c98415098ea42174a860d8b1fac4f7c7dd6c3e67c93b18330d1d0fc4edf0e The only post-verification edit removed one surplus newline at the end of the new developer document; its exact before/after content and fingerprints are retained privately. Staged diff checking caught this whitespace issue before commit. No application or test code changed.
This commit is contained in:
1 parent
903d861170
commit
9f1f783be4
21 files changed
+1717
-203
No files matched your search
@@ -80,8 +80,12 @@ export function leaderSkillBattle({ skill = 'suk-graduate' as LeaderSkillId, uns
|
||||
}
|
||||
Object.assign(game, { phase: 6, storm: 18, order: ['a', 'd'], active: 'a', ready: [], decision: null, response: null, phaseOpening: null });
|
||||
game = applyAction(game, 'a', { type: 'chooseBattle', territory, target: 'd' });
|
||||
while (game.decision?.kind === 'leaderSkillVisibility') {
|
||||
while (game.decision?.kind === 'leaderSkillVisibility' || game.decision?.kind === 'mentatQuestion') {
|
||||
const decision = game.decision;
|
||||
if (decision.kind === 'mentatQuestion') {
|
||||
game = applyAction(game, decision.player, { type: 'decision', event: decision.event, decline: true });
|
||||
continue;
|
||||
}
|
||||
game = applyAction(game, decision.player, { type: 'leaderSkillVisibility', event: decision.event,
|
||||
hide: decision.player === 'a' ? hide : false });
|
||||
}
|
||||
|
||||
@@ -83,7 +83,11 @@ void test('named economic, custody and information effects retain their source n
|
||||
);
|
||||
assert.match(
|
||||
leaderSkillCard('mentat').normal.join(' '),
|
||||
/specific weapon.*different Treachery Card.*does not require/,
|
||||
/may name a specific weapon.*privately show.*if held.*choose another Treachery Card/,
|
||||
);
|
||||
assert.match(
|
||||
leaderSkillCard('mentat').normal.join(' '),
|
||||
/stays in their hand.*need not be used in battle.*then move.*behind the shield/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { mentatQuestionGame, mentatReload } from './mentat-question-fixture';
|
||||
|
||||
function botView(game: Game, id: string, difficulty: (typeof DIFFICULTIES)[number]) {
|
||||
const view = viewGame(game, id);
|
||||
view.players.find(player => player.id === id)!.bot = difficulty;
|
||||
return view;
|
||||
}
|
||||
|
||||
void test('all profiles name a permitted weapon and show only a permitted physical card using their own private views', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = mentatQuestionGame();
|
||||
const named = botActions(botView(game, 'a', difficulty))[0];
|
||||
assert.ok(viewGame(game, 'a').mentat!.pending!.weapons.includes(named.weapon as string));
|
||||
const question = applyAction(mentatReload(game), 'a', named);
|
||||
assert.equal(question.decision?.kind, 'mentatQuestion');
|
||||
assert.equal(question.decision?.player, 'd');
|
||||
assert.equal(viewGame(question, 'a').mentat!.history.length, 0);
|
||||
const response = botActions(botView(question, 'd', difficulty))[0];
|
||||
assert.ok(viewGame(question, 'd').mentat!.pending!.cards.some(card => card.id === response.card));
|
||||
const shown = applyAction(mentatReload(question), 'd', response);
|
||||
assert.equal(shown.decision?.kind, 'leaderSkillVisibility');
|
||||
assert.equal(viewGame(shown, 'a').mentat!.history.length, 1);
|
||||
assert.deepEqual(shown.players.map(player => player.hand), game.players.map(player => player.hand));
|
||||
assert.deepEqual(viewGame(shown, 'o').mentat!.history, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the naming policy does not depend on hidden opponent cards and nonowners cannot act during either question stage', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const held = mentatQuestionGame({cards:['Crysknife','Snooper']});
|
||||
const absent = mentatQuestionGame({cards:['Chaumas','Shield']});
|
||||
const left = botActions(botView(held,'a',difficulty))[0];
|
||||
const right = botActions(botView(absent,'a',difficulty))[0];
|
||||
assert.equal(left.weapon,right.weapon);
|
||||
assert.deepEqual(botActions(botView(held,'d',difficulty)),[]);
|
||||
assert.deepEqual(botActions(botView(held,'o',difficulty)),[]);
|
||||
assert.ok(held.decision?.kind === 'mentatQuestion');
|
||||
const pending = applyAction(held,'a',{type:'decision',event:held.decision.event,weapon:'Crysknife'});
|
||||
assert.deepEqual(botActions(botView(pending,'a',difficulty)),[]);
|
||||
assert.deepEqual(botActions(botView(pending,'o',difficulty)),[]);
|
||||
const response = botActions(botView(pending,'d',difficulty))[0];
|
||||
assert.equal(response.card,held.players.find(player => player.id === 'd')!.hand.find(card => card.name === 'Crysknife')!.id);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MentatHistory, MentatQuestion } from '../components/mentat-question';
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
|
||||
context,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const card = { id: 'p1', name: 'Projectile', kind: 'projectile' as const };
|
||||
const otherCard = { id: 'w1', name: 'Worthless', kind: 'worthless' as const };
|
||||
|
||||
function view(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
me: 'a',
|
||||
players: [
|
||||
{ id: 'a', name: 'House A' },
|
||||
{ id: 'b', name: 'House B' },
|
||||
],
|
||||
decision: {
|
||||
kind: 'mentatQuestion',
|
||||
player: 'a',
|
||||
event: 'mentat-1',
|
||||
owner: 'a',
|
||||
target: 'b',
|
||||
stage: 'name',
|
||||
weapon: null,
|
||||
},
|
||||
mentat: {
|
||||
pending: {
|
||||
event: 'mentat-1',
|
||||
owner: 'a',
|
||||
target: 'b',
|
||||
player: 'a',
|
||||
stage: 'name',
|
||||
weapon: null,
|
||||
weapons: ['Projectile', 'Poison'],
|
||||
cards: [],
|
||||
blocked: null,
|
||||
},
|
||||
history: [],
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(MentatQuestion, { game, act() {}, busy }),
|
||||
);
|
||||
}
|
||||
|
||||
void test('Mentat owner sees naming controls and blocked empty-hand decline', () => {
|
||||
const html = markup(view());
|
||||
assert.match(html, /aria-label="Weapon to name for Mentat"/);
|
||||
assert.match(html, /Decline Mentat question/);
|
||||
const blocked = markup(
|
||||
view({
|
||||
mentat: {
|
||||
pending: {
|
||||
event: 'mentat-1', owner: 'a', target: 'b', player: 'a', stage: 'name',
|
||||
weapon: null, weapons: [], cards: [], blocked: 'Opponent has no card to show.',
|
||||
}, history: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.match(blocked, /Opponent has no card to show/);
|
||||
assert.doesNotMatch(blocked, /Choose a weapon/);
|
||||
});
|
||||
|
||||
void test('fallback cards are visible only to entitled target and inspectable', () => {
|
||||
const target = view({
|
||||
me: 'b',
|
||||
decision: {
|
||||
kind: 'mentatQuestion', player: 'b', event: 'mentat-1', owner: 'a', target: 'b',
|
||||
stage: 'reveal', weapon: 'Projectile',
|
||||
},
|
||||
mentat: {
|
||||
pending: {
|
||||
event: 'mentat-1', owner: 'a', target: 'b', player: 'b', stage: 'reveal',
|
||||
weapon: 'Projectile', weapons: [], cards: [card, otherCard], blocked: null,
|
||||
}, history: [],
|
||||
},
|
||||
});
|
||||
const html = markup(target);
|
||||
assert.match(html, /Show Projectile privately/);
|
||||
assert.match(html, /Show Worthless privately/);
|
||||
assert.match(html, /Inspect card/);
|
||||
const spectator = view({
|
||||
decision: {
|
||||
kind: 'mentatQuestion', player: 'b', event: 'mentat-1', owner: 'a', target: 'b',
|
||||
stage: 'reveal', weapon: 'Projectile',
|
||||
},
|
||||
mentat: {
|
||||
pending: {
|
||||
event: 'mentat-1', owner: 'a', target: 'b', player: 'b', stage: 'reveal',
|
||||
weapon: 'Projectile', weapons: [], cards: [card, otherCard], blocked: null,
|
||||
}, history: [],
|
||||
},
|
||||
});
|
||||
assert.equal(markup(spectator), '');
|
||||
});
|
||||
|
||||
void test('exact-match reveal keeps the private stage and offers the named card once', () => {
|
||||
const html = markup(view({
|
||||
me: 'b',
|
||||
decision: {
|
||||
kind: 'mentatQuestion', player: 'b', event: 'mentat-1', owner: 'a', target: 'b',
|
||||
stage: 'reveal', weapon: 'Projectile',
|
||||
},
|
||||
mentat: {
|
||||
pending: {
|
||||
event: 'mentat-1', owner: 'a', target: 'b', player: 'b', stage: 'reveal',
|
||||
weapon: 'Projectile', weapons: [], cards: [card], blocked: null,
|
||||
}, history: [],
|
||||
},
|
||||
}));
|
||||
assert.match(html, /Mentat card shown privately/);
|
||||
assert.match(html, /Show the named card privately/);
|
||||
assert.equal((html.match(/Show Projectile privately/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(html, /disabled=""/);
|
||||
});
|
||||
|
||||
void test('history is read-only and busy controls are disabled', () => {
|
||||
const pending = markup(view(), true);
|
||||
assert.match(pending, /disabled=""/);
|
||||
const history = renderToStaticMarkup(
|
||||
createElement(MentatHistory, {
|
||||
game: view({
|
||||
mentat: {
|
||||
pending: null,
|
||||
history: [{
|
||||
event: 'old', battle: 'b', turn: 4, territory: 'arrakeen', owner: 'a',
|
||||
target: 'b', leader: 'l', weapon: 'Projectile', card,
|
||||
}],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.match(history, /Your Mentat observations/);
|
||||
assert.match(history, /Later hand changes can make an observation stale/);
|
||||
assert.match(history, /Inspect card/);
|
||||
assert.doesNotMatch(history, /Show .* privately/);
|
||||
});
|
||||
|
||||
aliases.deregister();
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mock } from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
joinGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Game,
|
||||
} from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
export const mentatReload = (game: Game): Game =>
|
||||
JSON.parse(JSON.stringify(game)) as Game;
|
||||
export const mentatPlayer = (game: Game, id: string) =>
|
||||
game.players.find((player) => player.id === id)!;
|
||||
export function takeMentatCard(game: Game, player: string, name: string) {
|
||||
const pool =
|
||||
name === 'Stone Burner' || name === 'Mirror Weapon'
|
||||
? game.richeseCache!
|
||||
: game.deck;
|
||||
const index = pool.findIndex((card) => card.name === name);
|
||||
assert.ok(index >= 0, `Missing fixture card ${name}`);
|
||||
const [card] = pool.splice(index, 1);
|
||||
mentatPlayer(game, player).hand.push(card);
|
||||
return card;
|
||||
}
|
||||
/** Genuine public skill assignment and setup, followed by a conserved battle position. */
|
||||
export function mentatQuestionGame({
|
||||
advanced = false,
|
||||
choam = false,
|
||||
cards = ['Crysknife', 'Snooper'],
|
||||
begin = true,
|
||||
captured = false,
|
||||
preview = true,
|
||||
}: {
|
||||
advanced?: boolean;
|
||||
choam?: boolean;
|
||||
cards?: string[];
|
||||
begin?: boolean;
|
||||
captured?: boolean;
|
||||
preview?: boolean;
|
||||
} = {}): Game {
|
||||
const native = choam ? 'richese' : 'emperor';
|
||||
let game = createGame(
|
||||
'MENTATQA',
|
||||
newPlayer('a', 'Mentat owner', native),
|
||||
advanced,
|
||||
choam ? ['choam'] : [],
|
||||
);
|
||||
joinGame(
|
||||
game,
|
||||
newPlayer('d', 'Questioned opponent', captured ? 'harkonnen' : 'atreides'),
|
||||
);
|
||||
joinGame(game, newPlayer('o', 'Observer', 'guild'));
|
||||
for (const player of game.players)
|
||||
game = applyAction(game, player.id, { type: 'ready' });
|
||||
// Server-only review fixture opt-in; ordinary Leader Skills starts remain unchanged.
|
||||
if (preview) game.mentatQuestionPreview = true;
|
||||
const index = LEADER_SKILL_CARDS.findIndex((card) => card.id === 'mentat');
|
||||
let cursor = LEADER_SKILL_CARDS.length - 1;
|
||||
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||||
array[0] = cursor-- === index ? 0 : 0xffffffff;
|
||||
return array;
|
||||
});
|
||||
try {
|
||||
game = initializeLeaderSkillsGameForAudit(game);
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
assert.ok(game.leaderSkills!.offers.a.cards.includes('mentat'));
|
||||
for (let step = 0; game.status === 'setup' && step < 80; step++) {
|
||||
let progress = false;
|
||||
for (const player of game.players) {
|
||||
const offer = game.leaderSkills!.offers[player.id];
|
||||
const actions =
|
||||
game.setupStage === 'leaderSkills' && offer
|
||||
? [
|
||||
{
|
||||
type: 'leaderSkill',
|
||||
event: offer.event,
|
||||
skill: player.id === 'a' ? 'mentat' : offer.cards[0],
|
||||
leader: `${player.faction}-0`,
|
||||
},
|
||||
]
|
||||
: game.setupStage === 'traitors' && player.traitorChoices.length
|
||||
? [{ type: 'traitor', leader: player.traitorChoices[0] }]
|
||||
: botActions(viewGame(game, player.id));
|
||||
for (const action of actions)
|
||||
try {
|
||||
game = applyAction(game, player.id, action);
|
||||
progress = true;
|
||||
break;
|
||||
} catch {}
|
||||
if (progress) break;
|
||||
}
|
||||
assert.ok(progress, `Setup stalled at ${game.setupStage}`);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
for (const player of game.players) {
|
||||
game.deck.push(...player.hand);
|
||||
Object.assign(player, {
|
||||
hand: [],
|
||||
forces: player.id === 'o' ? {} : { 'arrakeen:10': 5 },
|
||||
reserves: player.id === 'o' ? 20 : 15,
|
||||
tanks: 0,
|
||||
spice: 20,
|
||||
});
|
||||
}
|
||||
takeMentatCard(game, 'a', 'Baliset');
|
||||
takeMentatCard(game, 'o', 'Karama');
|
||||
for (const name of cards) takeMentatCard(game, 'd', name);
|
||||
if (captured) mentatPlayer(game, 'a').leaders[0].capturedBy = 'd';
|
||||
Object.assign(game, {
|
||||
phase: 6,
|
||||
turn: 2,
|
||||
storm: 18,
|
||||
active: 'a',
|
||||
order: ['a', 'd', 'o'],
|
||||
ready: [],
|
||||
decision: null,
|
||||
response: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
return begin
|
||||
? applyAction(game, 'a', {
|
||||
type: 'chooseBattle',
|
||||
territory: 'arrakeen',
|
||||
target: 'd',
|
||||
})
|
||||
: game;
|
||||
}
|
||||
export function finishMentatPosture(game: Game, hide = true): Game {
|
||||
while (game.decision?.kind === 'leaderSkillVisibility') {
|
||||
const decision = game.decision;
|
||||
game = applyAction(game, decision.player, {
|
||||
type: 'leaderSkillVisibility',
|
||||
event: decision.event,
|
||||
hide: decision.player === 'a' ? hide : false,
|
||||
});
|
||||
}
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { mentatQuestionGame } from './mentat-question-fixture';
|
||||
|
||||
const clock: Rooms.RoomsClock = { now: () => 57_000, sleep: async () => {} };
|
||||
const hash = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
function snapshot(db: DatabaseSync) {
|
||||
return { rooms: db.prepare('SELECT * FROM rooms ORDER BY code').all(), seats: db.prepare('SELECT * FROM seats ORDER BY player_id').all() };
|
||||
}
|
||||
function resources(game: Game) {
|
||||
return { players: game.players, deck: game.deck, discard: game.discard, skills: game.leaderSkills };
|
||||
}
|
||||
async function fixture(t: test.TestContext, advanced = false) {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const game = mentatQuestionGame({advanced});
|
||||
game.version = 51;
|
||||
const code = game.code;
|
||||
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run(code, JSON.stringify(game), game.version, 56_000);
|
||||
const tokens = Object.fromEntries(game.players.map(player => [player.id, hash(`mentat-test:${player.id}`)]));
|
||||
const auths: Record<string, Rooms.SeatAuth> = {};
|
||||
for (const player of game.players) {
|
||||
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)').run(hash(tokens[player.id]),code,player.id);
|
||||
auths[player.id] = await store.rooms.authenticate(code,tokens[player.id]);
|
||||
}
|
||||
assert.ok(game.decision?.kind === 'mentatQuestion');
|
||||
return {...store, code, tokens, auths, initial:game, event:game.decision.event};
|
||||
}
|
||||
async function views(f: Awaited<ReturnType<typeof fixture>>, game: Game) {
|
||||
const room = f.restart(), before = snapshot(f.sqlite);
|
||||
for (const id of ['a','d','o']) {
|
||||
const auth = await room.authenticate(f.code,f.tokens[id]);
|
||||
const view = await room.readSeatView(f.code,auth);
|
||||
assert.deepEqual(view,viewGame(game,id));
|
||||
for (const rival of view.players.filter(player => player.id !== id)) {
|
||||
assert.equal(rival.hand,undefined);
|
||||
assert.equal(rival.traitors,undefined);
|
||||
}
|
||||
if (id === 'o') {
|
||||
assert.deepEqual(view.mentat!.history,[]);
|
||||
assert.deepEqual(view.mentat!.pending?.cards ?? [],[]);
|
||||
assert.deepEqual(view.mentat!.pending?.weapons ?? [],[]);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(snapshot(f.sqlite),before);
|
||||
}
|
||||
|
||||
void test('saved Mentat naming and mandatory private disclosure restore without moving cards or starting faction powers early', async t => {
|
||||
for (const advanced of [false,true]) {
|
||||
const f = await fixture(t,advanced), before = snapshot(f.sqlite), held = structuredClone(resources(f.initial));
|
||||
const name = {type:'decision',event:f.event,weapon:'Lasgun'};
|
||||
await views(f,f.initial);
|
||||
await assert.rejects(f.rooms.act(f.code,f.auths.d,51,name,clock));
|
||||
await assert.rejects(f.rooms.act(f.code,f.auths.a,50,name,clock),/table changed/);
|
||||
await assert.rejects(f.rooms.act(f.code,f.auths.a,51,{...name,event:'old-event'},clock));
|
||||
assert.deepEqual(snapshot(f.sqlite),before);
|
||||
assert.equal(f.writes.length,0);
|
||||
await f.restart().act(f.code,f.auths.a,51,name,clock);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.version,52);
|
||||
assert.ok(pending.decision?.kind === 'mentatQuestion' && pending.decision.stage === 'reveal');
|
||||
assert.equal(pending.decision.player,'d');
|
||||
assert.equal(pending.battle!.preparation,undefined);
|
||||
assert.deepEqual(resources(pending),held);
|
||||
assert.equal(viewGame(pending,'a').mentat!.history.length,0);
|
||||
await views(f,pending);
|
||||
const eligible = viewGame(pending,'d').mentat!.pending!.cards;
|
||||
assert.equal(eligible.length,2);
|
||||
const card = eligible.find(candidate => candidate.name === 'Snooper')!;
|
||||
const show = {type:'decision',event:f.event,card:card.id};
|
||||
const reserved = snapshot(f.sqlite);
|
||||
await assert.rejects(f.rooms.act(f.code,f.auths.a,52,show,clock));
|
||||
await assert.rejects(f.rooms.act(f.code,f.auths.d,52,{...show,card:'not-held'},clock));
|
||||
assert.deepEqual(snapshot(f.sqlite),reserved);
|
||||
await f.restart().act(f.code,f.auths.d,52,show,clock);
|
||||
const answered = await f.restart().readRoom(f.code);
|
||||
assert.equal(answered.version,53);
|
||||
assert.equal(answered.decision?.kind,'leaderSkillVisibility');
|
||||
assert.deepEqual(resources(answered),held);
|
||||
assert.equal(viewGame(answered,'a').mentat!.history[0].card.id,card.id);
|
||||
assert.equal(viewGame(answered,'d').mentat!.history[0].card.id,card.id);
|
||||
assert.deepEqual(viewGame(answered,'o').mentat!.history,[]);
|
||||
await views(f,answered);
|
||||
const saved = snapshot(f.sqlite);
|
||||
await assert.rejects(f.rooms.act(f.code,f.auths.d,52,show,clock),/table changed/);
|
||||
await assert.rejects(f.rooms.act(f.code,f.auths.d,53,show,clock));
|
||||
assert.deepEqual(snapshot(f.sqlite),saved);
|
||||
assert.deepEqual(f.writes.map(write => write.changes),[1,1]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('competing private fallback submissions commit one historical observation and retain the same physical hand', async t => {
|
||||
const f = await fixture(t), held = structuredClone(resources(f.initial));
|
||||
await f.rooms.act(f.code,f.auths.a,51,{type:'decision',event:f.event,weapon:'Lasgun'},clock);
|
||||
const pending = await f.rooms.readRoom(f.code);
|
||||
const cards = viewGame(pending,'d').mentat!.pending!.cards;
|
||||
assert.equal(cards.length,2);
|
||||
let release!: () => void;
|
||||
const wait = new Promise<void>(resolve => { release = resolve; });
|
||||
let arrivals = 0;
|
||||
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await wait; };
|
||||
const outcomes = await Promise.allSettled(cards.map(card => f.restart().act(f.code,f.auths.d,52,{type:'decision',event:f.event,card:card.id},clock)));
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(outcomes.filter(outcome => outcome.status === 'fulfilled').length,1);
|
||||
assert.equal(outcomes.filter(outcome => outcome.status === 'rejected').length,1);
|
||||
assert.deepEqual(f.writes.slice(1).map(write => write.changes).sort((a,b) => a-b),[0,1]);
|
||||
const answered = await f.restart().readRoom(f.code);
|
||||
assert.equal(answered.version,53);
|
||||
assert.deepEqual(resources(answered),held);
|
||||
assert.equal(answered.mentatHistory!.length,1);
|
||||
assert.equal(answered.mentatHistoryEvents!.length,1);
|
||||
assert.ok(cards.some(card => card.id === viewGame(answered,'a').mentat!.history[0].card.id));
|
||||
await views(f,answered);
|
||||
});
|
||||
@@ -0,0 +1,548 @@
|
||||
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 {
|
||||
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, []));
|
||||
}
|
||||
assert.throws(() => mentatHand([baseDeck()[0], baseDeck()[0]], []));
|
||||
assert.throws(() => mentatHand([{ ...baseDeck()[0], name: 'Lasgun' }], []));
|
||||
assert.throws(() => mentatHand(ixDeck().slice(0, 1), []));
|
||||
});
|
||||
|
||||
void test('Mentat requires a private preview opt-in and never exposes or accepts that flag through a player view or action', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const choam of [false, true]) {
|
||||
const ordinary = mentatQuestionGame({ advanced, choam, preview: false });
|
||||
assert.equal(ordinary.mentatQuestionPreview, undefined);
|
||||
assert.equal(ordinary.battle!.mentatQuestionVersion, undefined);
|
||||
assert.equal(ordinary.battle!.mentatQuestion, undefined);
|
||||
assert.equal(ordinary.decision?.kind, 'leaderSkillVisibility');
|
||||
assert.equal(viewGame(ordinary, 'a').mentat.pending, null);
|
||||
assert.equal(
|
||||
applyAction(ordinary, 'a', {
|
||||
type: 'advanceBots',
|
||||
mentatQuestionPreview: true,
|
||||
}).mentatQuestionPreview,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
const preview = mentatReload(mentatQuestionGame());
|
||||
assert.equal(preview.mentatQuestionPreview, true);
|
||||
assert.equal(
|
||||
normalizeAutomaticGame(preview).decision?.kind,
|
||||
'mentatQuestion',
|
||||
);
|
||||
for (const id of ['a', 'd', 'o'])
|
||||
assert.equal('mentatQuestionPreview' in viewGame(preview, id), false);
|
||||
delete preview.mentatQuestionPreview;
|
||||
for (const operation of [
|
||||
() => viewGame(preview, 'a'),
|
||||
() => normalizeAutomaticGame(preview),
|
||||
() => applyAction(preview, 'a', { type: 'advanceBots' }),
|
||||
])
|
||||
assert.throws(operation);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
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, []);
|
||||
});
|
||||
Reference in new issue
Block a user