Support named ordinary cards in Advanced Voice

This commit is contained in:
Quintine committed 2026-10-28 00:08:16 +11:00
1 parent b00d720e40
commit a244aeb5f8
20 files changed
+514 -41

No files matched your search

+3
View File
@@ -48,6 +48,9 @@ void test('actual Voice use, exact command and explicit Decline match independen
assert.equal(battlePowerMatches({ kind: 'voice', use: false }, decline), true);
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: true }, decline), false);
assert.equal(battlePowerMatches({ kind: 'prescience', use: false }, decline), false);
const named: BattlePowerDeclaration = { power: 'voice', use: true, cardType: 'named:Maula Pistol', must: true };
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'named:Maula Pistol', must: true }, named), true);
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: true }, named), false);
});
void test('Prescience predicates refer to use or requested field, never the inspected answer', () => {
@@ -85,6 +85,22 @@ function fixture(advanced: boolean): NexusBorrowedVoiceFixture {
return built[key]!;
}
void test('Advanced borrowed Voice preserves a specifically named ordinary card through its original response', () => {
const f = fixture(true);
const first = applyAction(reload(f.game), f.atreides,
{ type: 'nexusBorrowedVoice', event: offer(f.game, f.atreides).event, use: false });
const option = offer(first, f.holder);
assert.ok(option.choices.some(choice => choice.kind === 'named:Maula Pistol' && choice.must));
const declared = applyAction(first, f.holder, {
type: 'nexusBorrowedVoice', event: option.event, use: true, kind: 'named:Maula Pistol', must: true,
});
assert.deepEqual(declared.battle!.voice, { target: f.atreides, kind: 'named:Maula Pistol', must: true });
const settled = settledWindow(declared);
assert.equal(frame(settled).stage, 'active');
assert.equal(settled.battle!.voice?.kind, 'named:Maula Pistol');
nexusBorrowedVoiceInventory(settled);
});
for (const advanced of [false, true]) {
const band = advanced ? 'Advanced' : 'Basic';
+1 -1
View File
@@ -199,7 +199,7 @@ void test('declarations reject missing/noncanonical commands, nonbooleans and wr
const frame = createNexusBorrowedVoice(context);
const invalid: unknown[] = [
null, undefined, {}, { ...command, target: 'a' }, { ...command, target: 'foreign' },
{ ...command, kind: 'karama' }, { ...command, kind: 'portableSnooper' },
{ ...command, kind: 'karama' }, { ...command, kind: 'named:Imaginary Card' },
{ ...command, kind: 'Poison' }, { ...command, kind: null },
{ target: 'd', must: true }, { target: 'd', kind: 'poison' },
{ ...command, must: 'false' }, { ...command, must: 0 },
+103
View File
@@ -0,0 +1,103 @@
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 battle projection; engine tests exercise actual combat. */
function projection(advanced: boolean, borrowed = false): GameView {
const game = createGame('VOICENAMEUI', newPlayer('bg', 'Bene Gesserit', 'beneGesserit'), advanced);
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.voiceChoices(advanced).flatMap(kind => [{ kind, must: false }, { kind, must: true }]) } as NonNullable<GameView['nexusBorrowedVoice']>;
}
return view;
}
/** Execute real event handlers with persistent hooks; no browser is implied. */
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); } })); };
return { render, sent };
}
void test('named ordinary battle cards appear only in Advanced Voice choices', () => {
const render = (advanced: boolean) => renderToStaticMarkup(createElement(BattlePreparation, {
game: projection(advanced), busy: false, send: async () => {},
}));
assert.match(render(true), /value="named:Maula Pistol"/);
assert.match(render(true), />Maula Pistol \(specific card\)</);
assert.match(render(true), /value="projectile"/);
assert.doesNotMatch(render(false), /value="named:Maula Pistol"/);
assert.match(render(false), /value="projectile"/);
});
void test('native and borrowed Voice send the chosen exact card name and honor offered choices', () => {
for (const borrowed of [false, true]) {
const ui = controller(projection(true, borrowed));
const select = ui.render().find(element => element.props.id === (borrowed ? 'borrowed-voice-kind' : 'voice-kind'))!;
assert.ok(elements(select).some(option => option.type === 'option' && option.props.value === 'named:Maula Pistol'));
(select.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'named:Maula Pistol' } });
const checkbox = ui.render().find(element => element.type === 'input' && element.props.type === 'checkbox')!;
(checkbox.props.onChange as (event: { target: { checked: boolean } }) => void)({ target: { checked: true } });
const button = ui.render().find(element => element.type === 'button' && element.props.children === (borrowed ? 'Spend Nexus · Use the Voice' : 'Use the Voice'))!;
assert.equal(button.props.disabled, false);
(button.props.onClick as () => void)();
const expected = borrowed ? { type: 'nexusBorrowedVoice', event: 'borrowed-voice-ui', use: true, kind: 'named:Maula Pistol', must: true }
: { type: 'voice', kind: 'named:Maula Pistol', must: true };
assert.deepEqual(JSON.parse(JSON.stringify(ui.sent)), [expected]);
}
});
+164
View File
@@ -0,0 +1,164 @@
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';
/** Original Advanced setup, shipments and battle choice. The explicit test
* supplement forms a BG/Harkonnen alliance and moves existing physical cards
* between zones; it does not claim these acquisitions occurred naturally. */
function source(names: string[]) {
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);
// Return the target's actual cards before staging the requested original
// physical cards, so the named-absence cases do not depend on a shuffled deal.
game.deck.push(...target.hand.splice(0));
const pools = [game.richeseCache!, game.richeseRemoved ?? [], game.deck, game.discard,
...game.players.map(player => player.hand)];
const ids = names.map(name => {
const pool = pools.find(cards => cards.some(card => card.name === name && !target.hand.some(held => held.id === card.id)));
assert.ok(pool, `An original physical ${name} card must exist`);
const index = pool.findIndex(card => card.name === name && !target.hand.some(held => held.id === card.id));
const card = pool.splice(index, 1)[0];
target.hand.push(card);
return card.id;
});
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 { game, target: fixture.atreides, ally: fixture.harkonnen, bg: fixture.bg, ids, original };
}
function command(game: Game, actor: string, kind: string, must: boolean) {
game = applyAction(game, actor, { 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, fields: Partial<{ leader: string | null; weapon: string | null; defense: string | null }> = {}): Action {
const player = semutaAdvisorBattlePlayer(game, actor);
const leader = player.leaders.find(candidate => !candidate.dead && !candidate.capturedBy);
return { type: 'battlePlan', dial: 0, support: 0, leader: leader?.id ?? null,
weapon: null, defense: null, ...fields };
}
function reject(game: Game, actor: string, action: Action, reason?: RegExp) {
const before = structuredClone(game);
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
else assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('Advanced Voice requires or forbids one named projectile without conflating another', () => {
const { game: initial, bg, target, ids: [maula, crysknife], original } = source(['Maula Pistol', 'Crysknife']);
const required = command(initial, bg, 'named:Maula Pistol', true);
assert.deepEqual(required.battle!.voice, { target, kind: 'named:Maula Pistol', must: true });
reject(required, target, plan(required, target));
reject(required, target, plan(required, target, { weapon: crysknife }));
const complied = applyAction(required, target, plan(required, target, { weapon: maula }));
assert.equal(complied.battle!.plans[target].weapon, maula);
assert.deepEqual(semutaAdvisorBattleInventory(complied), original);
const forbidden = command(initial, bg, 'named:Maula Pistol', false);
reject(forbidden, target, plan(forbidden, target, { weapon: maula }));
const other = applyAction(forbidden, target, plan(forbidden, target, { weapon: crysknife }));
assert.equal(other.battle!.plans[target].weapon, crysknife);
assert.deepEqual(semutaAdvisorBattleInventory(other), original);
});
void test('missing a named card permits another projectile, while the old generic command still matches both', () => {
const { game: initial, bg, target, ids: [crysknife], original } = source(['Crysknife']);
const required = command(initial, bg, 'named:Maula Pistol', true);
const accepted = applyAction(required, target, plan(required, target, { weapon: crysknife }));
assert.equal(accepted.battle!.plans[target].weapon, crysknife);
assert.deepEqual(semutaAdvisorBattleInventory(accepted), original);
const category = command(initial, bg, 'projectile', false);
reject(category, target, plan(category, target, { weapon: crysknife }));
assert.doesNotThrow(() => applyAction(category, target, plan(category, target)));
});
void test('a named Worthless command accepts its physical card in either legal battle slot', () => {
const { game: initial, bg, target, ids: [baliset, kulon] } = source(['Baliset', 'Kulon']);
const required = command(initial, bg, 'named:Baliset', true);
reject(required, target, plan(required, target, { weapon: kulon }));
const weapon = applyAction(required, target, plan(required, target, { weapon: baliset }));
assert.equal(weapon.battle!.plans[target].weapon, baliset);
const defense = applyAction(required, target, plan(required, target, { defense: baliset }));
assert.equal(defense.battle!.plans[target].defense, baliset);
const forbidden = command(initial, bg, 'named:Baliset', false);
reject(forbidden, target, plan(forbidden, target, { defense: baliset }));
assert.doesNotThrow(() => applyAction(forbidden, target, plan(forbidden, target, { defense: kulon })));
});
void test('a named Cheap Hero prohibition preserves a different hero and permits no leader when none is legal', () => {
const pair = source(['Cheap Hero', 'Cheap Heroine']);
const forbidden = command(pair.game, pair.bg, 'named:Cheap Hero', false);
const controlled = structuredClone(forbidden);
for (const leader of semutaAdvisorBattlePlayer(controlled, pair.target).leaders) {
leader.dead = true;
leader.deaths = Math.max(leader.deaths, 1);
}
reject(controlled, pair.target, plan(controlled, pair.target, { leader: pair.ids[0] }));
reject(controlled, pair.target, plan(controlled, pair.target, { leader: null }));
const heroine = applyAction(controlled, pair.target, plan(controlled, pair.target, { leader: pair.ids[1] }));
assert.equal(heroine.battle!.plans[pair.target].leader, pair.ids[1]);
const one = source(['Cheap Hero']);
const lastHero = command(one.game, one.bg, 'named:Cheap Hero', false);
for (const leader of semutaAdvisorBattlePlayer(lastHero, one.target).leaders) {
leader.dead = true;
leader.deaths = Math.max(leader.deaths, 1);
}
const noLeader = applyAction(lastHero, one.target, plan(lastHero, one.target, { leader: null }));
assert.equal(noLeader.battle!.plans[one.target].leader, null);
});
void test('named Shield means the printed name, so either physical copy satisfies the requirement', () => {
const { game: initial, bg, target, ids: [first, second] } = source(['Shield', 'Shield']);
assert.notEqual(first, second);
const required = command(initial, bg, 'named:Shield', true);
reject(required, target, plan(required, target));
for (const defense of [first, second]) {
const next = applyAction(required, target, plan(required, target, { defense }));
assert.equal(next.battle!.plans[target].defense, defense);
}
});
void test('Basic rejects a named card command before committing Voice', () => {
// Controlled mode downgrade at the genuine Voice boundary isolates the new
// mode gate; it does not claim this Advanced fixture is a native Basic game.
const { game, bg } = source(['Maula Pistol']);
game.advanced = false;
reject(game, bg, { type: 'voice', kind: 'named:Maula Pistol', must: true }, /Naming an individual card requires Advanced rules/);
});
void test('all bot profiles can make legal plans under a named projectile command', () => {
const { game: initial, bg, target, ids: [maula] } = source(['Maula Pistol', 'Crysknife']);
for (const must of [true, false]) {
const voiced = command(initial, bg, 'named:Maula Pistol', must);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(voiced, target);
view.players.find(player => player.id === target)!.bot = difficulty;
const actions = botActions(view).filter(action => action.type === 'battlePlan');
assert.ok(actions.length, `${difficulty} can make a plan under named Voice`);
if (must) assert.ok(actions.every(action => action.weapon === maula));
else assert.ok(actions.every(action => action.weapon !== maula));
for (const action of actions) assert.doesNotThrow(() => applyAction(voiced, target, action));
}
}
});
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck, ixBattleCards, ixStandardCards, type Card } from '../game/cards';
import {
VOICE_KINDS, VOICE_NAMED_CARDS, voiceChoices, voiceNamedCard,
defaultVoiceMatch, playedVoiceMatch, battleCardLabel,
} from '../game/battle-cards';
const face = (name: string): Card => {
const card = [...baseDeck(), ...ixStandardCards(), ...ixBattleCards()].find(item => item.name === name);
assert.ok(card, `Expected original ${name} card`);
return card;
};
void test('specific-name commands are canonical public Advanced choices, not Basic categories or arbitrary card IDs', () => {
assert.deepEqual(voiceChoices(false), VOICE_KINDS);
for (const name of VOICE_NAMED_CARDS) {
assert.ok(voiceChoices(true).includes(`named:${name}`));
assert.equal(voiceNamedCard(`named:${name}`), name);
}
assert.equal(voiceNamedCard('named:Truthtrance'), null);
assert.equal(voiceNamedCard('named:Poison Tooth'), null);
assert.equal(voiceNamedCard('named:treachery-1'), null);
assert.equal(voiceNamedCard('poison'), null);
assert.equal(battleCardLabel('named:Maula Pistol'), 'Maula Pistol (specific card)');
assert.equal(battleCardLabel('poison'), 'Poison weapon');
});
void test('specific-name Voice distinguishes different ordinary faces with the same role', () => {
const pistol = face('Maula Pistol');
const knife = face('Crysknife');
assert.equal(defaultVoiceMatch(pistol, 'named:Maula Pistol'), true);
assert.equal(defaultVoiceMatch(knife, 'named:Maula Pistol'), false);
assert.equal(playedVoiceMatch(pistol, 'weapon', 'named:Maula Pistol'), true);
assert.equal(playedVoiceMatch(knife, 'weapon', 'named:Maula Pistol'), false);
assert.equal(playedVoiceMatch(pistol, 'defense', 'named:Maula Pistol'), false);
assert.equal(defaultVoiceMatch(pistol, 'projectile'), true);
assert.equal(defaultVoiceMatch(knife, 'projectile'), true);
// Matching the text alone cannot turn a card of another printed role into
// the named physical face.
assert.equal(defaultVoiceMatch({ ...pistol, kind: 'poison' }, 'named:Maula Pistol'), false);
assert.equal(playedVoiceMatch({ ...pistol, kind: 'poison' }, 'weapon', 'named:Maula Pistol'), false);
});
void test('named defense, Worthless and Cheap Hero commands honor legal battle slots and copies', () => {
const shields = [...baseDeck(), ...ixStandardCards()].filter(card => card.name === 'Shield');
assert.ok(shields.length >= 2);
for (const shield of shields) {
assert.equal(defaultVoiceMatch(shield, 'named:Shield'), true);
assert.equal(playedVoiceMatch(shield, 'defense', 'named:Shield'), true);
assert.equal(playedVoiceMatch(shield, 'weapon', 'named:Shield'), false);
}
const baliset = face('Baliset');
assert.equal(playedVoiceMatch(baliset, 'weapon', 'named:Baliset'), true);
assert.equal(playedVoiceMatch(baliset, 'defense', 'named:Baliset'), true);
assert.equal(playedVoiceMatch(baliset, 'leader', 'named:Baliset'), false);
assert.equal(defaultVoiceMatch(face('Kulon'), 'named:Baliset'), false);
const hero = face('Cheap Hero'), heroine = face('Cheap Heroine');
assert.equal(playedVoiceMatch(hero, 'leader', 'named:Cheap Hero'), true);
assert.equal(playedVoiceMatch(heroine, 'leader', 'named:Cheap Hero'), false);
assert.equal(playedVoiceMatch(hero, 'weapon', 'named:Cheap Hero'), false);
assert.equal(defaultVoiceMatch(heroine, 'hero'), true);
});
void test('generic special-card default and alternate Voice matching remain intact', () => {
const chemistry = face('Chemistry'), weirding = face('Weirding Way');
assert.equal(defaultVoiceMatch(chemistry, 'snooper'), true);
assert.equal(defaultVoiceMatch(chemistry, 'poison'), false);
assert.equal(playedVoiceMatch(chemistry, 'weapon', 'poison'), true);
assert.equal(defaultVoiceMatch(weirding, 'projectile'), true);
assert.equal(defaultVoiceMatch(weirding, 'shield'), false);
assert.equal(playedVoiceMatch(weirding, 'defense', 'shield'), true);
assert.equal(voiceNamedCard('named:Chemistry'), null);
});