Allow Voice to name Portable Snooper
This commit is contained in:
1 parent
0902289592
commit
1f92affea7
13 files changed
+381
-6
No files matched your search
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user