Allow Voice to name Portable Snooper

This commit is contained in:
Quintine committed 2026-10-27 22:49:34 +11:00
1 parent 0902289592
commit 1f92affea7
13 files changed
+381 -6

No files matched your search

+10 -3
View File
@@ -225,12 +225,19 @@ void test('late defense uses public revealed threats without opponent hand infer
),
);
}
const traitor = view(g, difficulty);
traitor.players[0].traitors = ['guild-0'];
assert.deepEqual(botActions(traitor)[0], {
// Build the authoritative traitor before revealing, so the server's
// projected call choices agree with the bot's actual held card.
const traitorGame = fixture();
traitorGame.players[0].traitors = ['guild-0'];
const revealedTraitor = reveal(traitorGame);
const traitor = view(revealedTraitor, difficulty);
assert.ok(traitor.battle!.traitorCallChoices?.includes(true));
const traitorAction = botActions(traitor)[0];
assert.deepEqual(traitorAction, {
type: 'traitorCall',
call: true,
});
assert.doesNotThrow(() => applyAction(revealedTraitor, 'p', traitorAction));
}
});
+9
View File
@@ -93,6 +93,15 @@ function finish(g: Game) {
for (const id of ['r', 'g'])
if (g.battle?.traitorCalls[id] === undefined)
g = send(g, id, { type: 'traitorCall', call: false });
// Fresh Richese battles retain their shared final Sapho opportunity after
// traitor declarations. Finish that actual native window before asserting
// outcomes or winner/loser card-cleanup decisions.
while (g.battle?.outcomeWindow && !g.battle.outcomeWindow.closed) {
const window = g.battle.outcomeWindow;
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id));
assert.ok(actor, 'The final battle window retains an unfinished combatant');
g = send(g, actor, { type: 'battleOutcomeReady', event: window.event });
}
return g;
}
function cleanup(g: Game) {
+5
View File
@@ -42,6 +42,11 @@ void test('only the canonical persisted Portable identity gains the poison-defen
assert.ok(defaultVoiceMatch(portable, 'snooper'));
assert.ok(playedVoiceMatch(portable, 'defense', 'snooper'));
assert.equal(playedVoiceMatch(portable, 'weapon', 'snooper'), false);
assert.ok(defaultVoiceMatch(portable, 'portableSnooper'));
assert.ok(playedVoiceMatch(portable, 'defense', 'portableSnooper'));
assert.equal(playedVoiceMatch(portable, 'weapon', 'portableSnooper'), false);
assert.equal(defaultVoiceMatch(get('snooper'), 'portableSnooper'), false);
assert.equal(defaultVoiceMatch({ ...portable, id: 'forged' }, 'portableSnooper'), false);
});
void test('Portable stops ordinary poison and weapon Chemistry but no Tooth, Lasgun, projectile or Artillery', () => {
for (const kind of ['poison', 'chemistry'] as const)
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import * as jsxRuntime from 'react/jsx-runtime';
import ts from 'typescript';
import { BattlePreparation } from '../components/battle-preparation';
import { createGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import * as leaders from '../game/leader-control';
import * as cards from '../game/cards';
import * as battleCards from '../game/battle-cards';
import * as battleSlots from '../game/battle-card-slots';
import * as skills from '../game/leader-skill-combat';
import * as mirror from '../game/mirror-weapon-mode';
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
function elements(node: ReactNode): Element[] {
if (Array.isArray(node)) return node.flatMap(elements);
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
return [node, ...elements(node.props.children)];
}
/** Controlled public view for the actual Voice component. The engine tests
* exercise the real battle and authority; this is not a browser/hydration test. */
function projection(borrowed = false): GameView {
const game = createGame('VOICEPORTABLEUI', newPlayer('bg', 'Bene Gesserit', 'beneGesserit'), true, ['choam']);
game.players.push(newPlayer('e', 'Emperor', 'emperor'));
Object.assign(game, { status: 'playing', phase: 6, turn: 2, active: 'bg', storm: 18, order: ['bg', 'e'] });
for (const seat of game.players) {
seat.forces = { 'arrakeen:10': 2 }; seat.reserves = 18; seat.hand = []; seat.traitors = [];
}
game.battle = { event: 'voice-ui-battle', territory: 'arrakeen', attacker: 'bg', defender: 'e',
prepared: true, plans: {}, revealed: false, traitorCalls: {},
preparation: { kind: 'voice', owner: 'bg', beneficiary: 'bg' } };
const view = viewGame(game, 'bg');
if (borrowed) {
view.battle!.preparation = { kind: 'nexusVoice', owner: 'bg', beneficiary: 'bg' };
view.nexusBorrowedVoice = { event: 'borrowed-voice-ui', owner: 'bg', target: 'e', canUse: true, blocked: null,
choices: battleCards.VOICE_KINDS.flatMap(kind => [{ kind, must: false }, { kind, must: true }]) } as NonNullable<GameView['nexusBorrowedVoice']>;
}
return view;
}
/** Re-render with persistent hooks so callbacks come from the real component. */
function controller(game: GameView) {
const states: unknown[] = [], sent: Action[] = [];
let cursor = 0;
const exports: { BattlePreparation?: typeof BattlePreparation } = {};
runInNewContext(ts.transpileModule(readFileSync(new URL('../components/battle-preparation.tsx', import.meta.url), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX },
}).outputText, {
exports,
require(name: string) {
if (name === 'react/jsx-runtime') return jsxRuntime;
if (name === 'react') return { useState(initial: unknown) {
const index = cursor++; if (!(index in states)) states[index] = initial;
return [states[index], (next: unknown) => { states[index] = next; }];
} };
if (name === '@/components/ui/button') return { Button: 'button' };
if (name === '@/components/ui/input') return { Input: 'input' };
if (name === '@/game/leader-control') return leaders;
if (name === '@/game/cards') return cards;
if (name === '@/game/battle-cards') return battleCards;
if (name === '@/game/battle-card-slots') return battleSlots;
if (name === '@/game/leader-skill-combat') return skills;
if (name === '@/game/mirror-weapon-mode') return mirror;
throw new Error(`Unexpected import ${name}`);
},
});
assert.ok(exports.BattlePreparation);
const render = () => { cursor = 0; return elements(exports.BattlePreparation!({ game, busy: false,
send: async action => { sent.push(action); } })); };
const select = (id: string, value: string) => {
const node = render().find(element => element.props.id === id); assert.ok(node);
assert.ok(elements(node).some(option => option.type === 'option' && option.props.value === value));
(node.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
};
const requireCard = () => {
const input = render().find(element => element.type === 'input' && element.props.type === 'checkbox'); assert.ok(input);
(input.props.onChange as (event: { target: { checked: boolean } }) => void)({ target: { checked: true } });
};
const submit = (label: string) => {
const button = render().find(element => element.type === 'button' && element.props.children === label); assert.ok(button);
assert.equal(button.props.disabled, false); (button.props.onClick as () => void)();
};
return { render, sent, select, requireCard, submit };
}
void test('ordinary Voice exposes distinct Portable Snooper and poison-defense choices and sends the chosen command', () => {
const game = projection(), html = renderToStaticMarkup(createElement(BattlePreparation, { game, busy: false, send: async () => {} }));
assert.match(html, /<option value="portableSnooper">Portable Snooper<\/option>/);
assert.match(html, /<option value="snooper">Poison defense<\/option>/);
const ui = controller(game);
ui.select('voice-kind', 'portableSnooper'); ui.requireCard(); ui.submit('Use the Voice');
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent)), [{ type: 'voice', kind: 'portableSnooper', must: true }]);
const ordinary = controller(game); ordinary.select('voice-kind', 'snooper'); ordinary.submit('Use the Voice');
assert.deepEqual(JSON.parse(JSON.stringify(ordinary.sent)), [{ type: 'voice', kind: 'snooper', must: false }]);
});
void test('controlled borrowed Voice projection offers and sends the same named Portable command', () => {
const game = projection(true), ui = controller(game);
ui.select('borrowed-voice-kind', 'portableSnooper'); ui.requireCard(); ui.submit('Spend Nexus · Use the Voice');
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent)), [{ type: 'nexusBorrowedVoice', event: 'borrowed-voice-ui',
use: true, kind: 'portableSnooper', must: true }]);
});
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
advanceSemutaAdvisorBattle,
createSemutaAdvisorBattleFixture,
semutaAdvisorBattleInventory,
semutaAdvisorBattlePlayer,
} from './fixture-semuta-advisor-battle';
const portable = 'richese-portable-snooper';
/** Start from the fixture's real Advanced setup, paid Semuta acquisition,
* movements and unchosen Arrakeen battle. The supplement below is deliberately
* controlled: it makes BG and Harkonnen allies and conserves original cards
* when transferring one ordinary Snooper and Portable Snooper from their
* original zones to Atreides. Neither acquisition nor alliance is claimed as
* naturally earned; a previously removed cache card can be staged back. */
function source() {
const fixture = createSemutaAdvisorBattleFixture();
let game = structuredClone(fixture.beforeBattle);
const bg = semutaAdvisorBattlePlayer(game, fixture.bg);
const ally = semutaAdvisorBattlePlayer(game, fixture.harkonnen);
const target = semutaAdvisorBattlePlayer(game, fixture.atreides);
assert.equal(bg.ally, null);
assert.equal(ally.ally, null);
bg.ally = ally.id;
ally.ally = bg.id;
const original = semutaAdvisorBattleInventory(game);
const pools = [game.richeseCache!, game.richeseRemoved ?? [], game.deck, game.discard,
...game.players.map(player => player.hand)];
const transfer = (predicate: (card: Game['deck'][number]) => boolean) => {
const pool = pools.find(cards => cards.some(predicate));
assert.ok(pool, 'The original physical card must exist');
const card = pool.splice(pool.findIndex(predicate), 1)[0];
target.hand.push(card);
return card.id;
};
const ordinary = transfer(card => card.kind === 'snooper');
assert.equal(transfer(card => card.id === portable), portable);
assert.deepEqual(semutaAdvisorBattleInventory(game), original);
game = applyAction(game, fixture.choice.chooser, fixture.battleAction);
game = advanceSemutaAdvisorBattle(game, state => state.battle?.preparation?.kind === 'voice' &&
!state.response && !state.decision && !state.phaseOpening);
assert.equal(game.battle!.preparation!.owner, fixture.bg);
assert.equal(game.battle!.preparation!.beneficiary, fixture.harkonnen);
return { fixture, game, target: fixture.atreides, ally: fixture.harkonnen,
bg: fixture.bg, ordinary, original };
}
function command(game: Game, bg: string, kind: string, must: boolean) {
game = applyAction(game, bg, { type: 'voice', kind, must });
return advanceSemutaAdvisorBattle(game, state => !!state.battle &&
!state.response && !state.decision && !state.phaseOpening &&
!state.battle.preparation && state.battle.preLeader?.closed !== false);
}
function plan(game: Game, actor: string, defense: string | null): Game {
const player = semutaAdvisorBattlePlayer(game, actor);
const leader = player.leaders.find(candidate => !candidate.dead && !candidate.capturedBy);
assert.ok(leader);
return applyAction(game, actor, { type: 'battlePlan', dial: 0, support: 0,
leader: leader.id, weapon: null, defense });
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function planAction(game: Game, actor: string, defense: string | null): Action {
const leader = semutaAdvisorBattlePlayer(game, actor).leaders.find(candidate => !candidate.dead && !candidate.capturedBy);
assert.ok(leader);
return { type: 'battlePlan', dial: 0, support: 0, leader: leader.id, weapon: null, defense };
}
function inventory(game: Game, original: string[]) {
assert.deepEqual(semutaAdvisorBattleInventory(game), original);
const targetCards = game.players.flatMap(player => player.hand).filter(card => card.id === portable);
assert.equal(targetCards.length, 1);
assert.equal(game.richeseCache!.some(card => card.id === portable), false);
assert.equal(game.discard.some(card => card.id === portable), false);
}
void test('Advanced Voice can require the physical Portable specifically, not an ordinary Snooper', () => {
const { game: initial, bg, target, ally, ordinary, original } = source();
let game = command(initial, bg, 'portableSnooper', true);
assert.deepEqual(game.battle!.voice, { target, kind: 'portableSnooper', must: true });
reject(game, target, planAction(game, target, null));
reject(game, target, planAction(game, target, ordinary));
game = plan(game, target, portable);
assert.equal(game.battle!.plans[target].defense, portable);
inventory(game, original);
game = plan(game, ally, null);
assert.equal(game.battle!.revealed, true);
assert.equal(game.battle!.plans[target].defense, portable);
assert.equal(game.battle!.lateDefense?.[target], undefined);
inventory(game, original);
});
void test('Advanced Voice forbidding Portable still permits an ordinary Snooper, but prevents later Portable', () => {
const { game: initial, bg, target, ally, ordinary, original } = source();
const voiced = command(initial, bg, 'portableSnooper', false);
reject(voiced, target, planAction(voiced, target, portable));
let normal = plan(voiced, target, ordinary);
normal = plan(normal, ally, null);
assert.equal(normal.battle!.revealed, true);
assert.equal(normal.battle!.plans[target].defense, ordinary);
inventory(normal, original);
let late = plan(voiced, target, null);
late = plan(late, ally, null);
const event = late.battle!.event!;
assert.equal(late.battle!.revealed, true);
reject(late, target, { type: 'portableSnooper', card: portable, event });
assert.equal(late.battle!.lateDefense?.[target], undefined);
inventory(late, original);
});
void test('when Portable is absent a named require does not invent the card, and ordinary Snooper remains legal', () => {
const { game: initial, bg, target, ordinary, original } = source();
const player = semutaAdvisorBattlePlayer(initial, target);
const index = player.hand.findIndex(card => card.id === portable);
assert.ok(index >= 0);
initial.richeseCache!.push(player.hand.splice(index, 1)[0]);
assert.deepEqual(semutaAdvisorBattleInventory(initial), original);
let game = command(initial, bg, 'portableSnooper', true);
reject(game, target, planAction(game, target, portable));
game = plan(game, target, ordinary);
assert.equal(game.battle!.plans[target].defense, ordinary);
assert.equal(game.richeseCache!.filter(card => card.id === portable).length, 1);
assert.deepEqual(semutaAdvisorBattleInventory(game), original);
});
void test('ordinary Snooper Voice retains its existing broader poison-defense prohibition', () => {
const { game: initial, bg, target, ally, ordinary, original } = source();
const voiced = command(initial, bg, 'snooper', false);
reject(voiced, target, planAction(voiced, target, ordinary));
reject(voiced, target, planAction(voiced, target, portable));
let game = plan(voiced, target, null);
game = plan(game, ally, null);
reject(game, target, { type: 'portableSnooper', card: portable, event: game.battle!.event! });
inventory(game, original);
});
void test('Voice forbidding a shield leaves the actual late Portable defense available', () => {
const { game: initial, bg, target, ally, original } = source();
let game = command(initial, bg, 'shield', false);
game = plan(game, target, null);
game = plan(game, ally, null);
const plans = structuredClone(game.battle!.plans);
game = applyAction(game, target, { type: 'portableSnooper', card: portable, event: game.battle!.event! });
assert.deepEqual(game.battle!.plans, plans);
assert.equal(game.battle!.lateDefense?.[target], portable);
inventory(game, original);
});
void test('all four bot profiles obey named Portable Voice in native planning and the late window', () => {
const { game: initial, bg, target, ally, original } = source();
const required = command(initial, bg, 'portableSnooper', true);
const forbidden = command(initial, bg, 'portableSnooper', false);
for (const difficulty of DIFFICULTIES) {
const requiredView = viewGame(required, target);
requiredView.players.find(player => player.id === target)!.bot = difficulty;
const candidates = botActions(requiredView).filter(action => action.type === 'battlePlan');
const usesPortable = candidates.filter(action => action.defense === portable && action.leader);
assert.ok(usesPortable.length, `${difficulty} can submit a plan with the required actual card`);
for (const action of candidates) assert.doesNotThrow(() => applyAction(required, target, action));
const forbiddenView = viewGame(forbidden, target);
forbiddenView.players.find(player => player.id === target)!.bot = difficulty;
const allowed = botActions(forbiddenView).filter(action => action.type === 'battlePlan');
assert.ok(allowed.length, `${difficulty} can submit a plan under the prohibition`);
assert.ok(allowed.every(action => action.defense !== portable && action.weapon !== portable));
for (const action of allowed) assert.doesNotThrow(() => applyAction(forbidden, target, action));
// Controlled physical-card supplement: transfer an original poison weapon
// to the ally so late-defense policy sees a real revealed threat.
let late = structuredClone(forbidden);
const pools = [late.deck, late.discard, ...late.players.map(player => player.hand)];
const pool = pools.find(cards => cards.some(card => card.kind === 'poison'));
assert.ok(pool);
const poison = pool.splice(pool.findIndex(card => card.kind === 'poison'), 1)[0];
semutaAdvisorBattlePlayer(late, ally).hand.push(poison);
assert.deepEqual(semutaAdvisorBattleInventory(late), original);
late = plan(late, target, null);
const leader = semutaAdvisorBattlePlayer(late, ally).leaders.find(candidate => !candidate.dead && !candidate.capturedBy);
assert.ok(leader);
late = applyAction(late, ally, { type: 'battlePlan', dial: 0, support: 0,
leader: leader.id, weapon: poison.id, defense: null });
assert.equal(late.battle!.revealed, true);
const lateView = viewGame(late, target);
lateView.players.find(player => player.id === target)!.bot = difficulty;
assert.ok(botActions(lateView).every(action => action.type !== 'portableSnooper'),
`${difficulty} must not play the prohibited card late`);
inventory(late, original);
}
});