Resolve ambiguous Stone battles with sealed physical totals

Add a provisional post-reveal physical-count commitment for new outcome-changing Basic/Advanced Stone and copied Mirror battles. Keep all original sides sealed until complete and retain matching later typed losses. Preserve legacy, ordinary and invariant-winner timing. Carry the same constraint through card cleanup and native casualty consumers.

Validation: frozen 16 files146/146, types/lint/build and14 original-action/rendered-control programmes; four minimal policies make both owned choices legally. Source timing is explicitly provisional. Wider combinations, assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
This commit is contained in:
Quintine committed 2026-10-18 12:13:06 +11:00
1 parent 677ad7aa3c
commit 6ccfaa1b4d
22 files changed
+1247 -83

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
export const ALLOCATION_STONE = 'richese-stone-burner';
export const ALLOCATION_MIRROR = 'richese-mirror-weapon';
export type StoneBurnerAllocationStep = { actor: string; action: Action };
export type StoneBurnerAllocationOptions = {
advanced?: boolean;
mirror?: boolean;
comparison?: 'ambiguous' | 'invariant' | 'normal';
/** A genuine Advanced Emperor/Ix battle requires both sealed counts. */
mixedOpponent?: boolean;
/** Reverse the two human combatants' original join/seat order. */
reverseSeats?: boolean;
/** Original Advanced shipment choice; keeps the default programme unchanged. */
ownElite?: 1 | 2;
};
export const stoneBurnerAllocationPlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export const stoneBurnerAllocationClean = (game: Game) => !game.response && !game.phaseOpening &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function actStoneBurnerAllocation(game: Game, actor: string, action: Action,
actions: StoneBurnerAllocationStep[] = []): Game {
actions.push({ actor, action: structuredClone(action) });
return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
}
/** Original cache offers are explicit choices. One cache Stone is paid for by
* the opponent; the optional next-turn Mirror is retained by its native owner
* through the printed unbid-cache choice. Neither card is moved by staging. */
function nextStoneBurnerAllocationStep(game: Game): StoneBurnerAllocationStep | null {
if (!game.response && !game.phaseOpening && game.decision) {
const decision = game.decision;
if (decision.kind === 'stoneAllocation' || decision.kind === 'battleLosses') return null;
if (decision.kind === 'richeseCache') {
const id = game.turn === 1 ? ALLOCATION_STONE : ALLOCATION_MIRROR;
const card = game.richeseCache!.find(card => card.id === id) ?? game.richeseCache![0]; assert.ok(card);
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
card: card.id, method: 'onceAround', direction: 'clockwise' } };
}
if (decision.kind === 'richeseUnbid') return { actor: decision.player, action: { type: 'decision',
event: game.richeseBidding!.event, keep: game.richeseAuction!.cardId === ALLOCATION_MIRROR } };
}
const lot = game.richeseAuction;
const buyer = game.players.find(player => player.faction === 'emperor')?.id ??
game.players.find(player => player.faction === 'ixians')?.id;
if (stoneBurnerAllocationClean(game) && lot && !lot.outcome && lot.cardId === ALLOCATION_STONE &&
lot.active === buyer && lot.bid === 0)
return { actor: lot.active!, action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceStoneBurnerAllocation(game: Game, until: (state: Game) => boolean,
actions: StoneBurnerAllocationStep[] = []): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine Stone programme ended before its next owned control');
const step = nextStoneBurnerAllocationStep(game);
assert.ok(step, `Choose the real ${game.decision?.kind ?? 'Battle Plan'} explicitly`);
game = actStoneBurnerAllocation(game, step.actor, step.action, actions);
}
throw Error('The original Stone allocation programme did not reach its finite boundary');
}
/** Genuine host/private setup, paid native cache Stone, typed funded arrivals
* and original revealed plans. Basic Ix dial2 uses one Cyborg or four Suboids;
* Advanced Emperor dial3/support1 uses three or five actual counters. */
export function createStoneBurnerAllocationFixture(options: StoneBurnerAllocationOptions = {}) {
const advanced = options.advanced ?? !!options.mixedOpponent, comparison = options.comparison ?? 'ambiguous';
assert.ok(!options.mixedOpponent || (advanced && !options.mirror && comparison === 'ambiguous'),
'The bounded two-count programme uses Advanced Emperor/Ix and the original Stone');
const owner = advanced ? 'emperor' : 'ixians', opponent = options.mixedOpponent ? 'ixians' : 'richese';
let game = createGame('STONEBURNERALLOCATION', newPlayer('richese', 'Richese', 'richese'), advanced,
advanced && !options.mixedOpponent ? ['choam'] : ['ix', 'choam']);
const seats: ('emperor' | 'ixians' | 'guild')[] = options.mixedOpponent
? options.reverseSeats ? ['ixians', 'emperor', 'guild'] : ['emperor', 'ixians', 'guild'] : [owner, 'guild'];
for (const faction of seats) joinGame(game, newPlayer(faction, faction, faction));
const actions: StoneBurnerAllocationStep[] = [];
const act = (actor: string, action: Action) => { game = actStoneBurnerAllocation(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceStoneBurnerAllocation(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
const front: Game['spiceDeck'] = [];
for (let count = 0; count < (options.mirror ? 2 : 1) * (advanced ? 2 : 1); count++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
reach(state => stoneBurnerAllocationPlayer(state, owner).hand.some(card => card.id === ALLOCATION_STONE));
const acquiredStone = structuredClone(game);
assert.ok(actions.some(step => step.actor === owner && step.action.type === 'richeseBid' && step.action.amount === 1),
'The canonical Stone must be acquired through its original paid native cache lot');
if (options.mirror) reach(state => state.turn === 2 &&
stoneBurnerAllocationPlayer(state, opponent).hand.some(card => card.id === ALLOCATION_MIRROR));
const acquiredMirror = options.mirror ? structuredClone(game) : null;
const turn = options.mirror ? 2 : 1, shipments: { before: Game; step: StoneBurnerAllocationStep; after: Game }[] = [];
const shipped = new Set<string>();
for (let count = 0; shipped.size < 2 && count < 500; count++) {
if (game.turn === turn && game.phase === 5 && stoneBurnerAllocationClean(game) && game.active &&
[owner, opponent].includes(game.active) && !shipped.has(game.active)) {
const actor = game.active, before = structuredClone(game), step: StoneBurnerAllocationStep = { actor, action: {
type: 'ship', territory: 'arrakeen', sector: 10,
amount: actor === owner || options.mixedOpponent ? 6 : advanced ? 3 : 4,
elite: actor === owner ? advanced ? options.ownElite ?? 1 : 2 : options.mixedOpponent ? 2 : 0, allyPayment: 0,
} };
act(actor, step.action); reach(stoneBurnerAllocationClean);
shipments.push({ before, step, after: structuredClone(game) }); shipped.add(actor);
} else {
const step = nextStoneBurnerAllocationStep(game); assert.ok(step);
act(step.actor, step.action);
}
}
assert.equal(shipped.size, 2);
reach(state => state.phase === 6 && !!state.active && stoneBurnerAllocationClean(state));
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
.find(choice => choice.territory === 'arrakeen'); assert.ok(choice);
const chooseBattle: StoneBurnerAllocationStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory: 'arrakeen', target: choice.chooser === owner ? opponent : owner,
} };
act(chooseBattle.actor, chooseBattle.action);
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && stoneBurnerAllocationClean(state));
const beforePlans = structuredClone(game), leaders = (actor: string) => stoneBurnerAllocationPlayer(game, actor).leaders
.filter(leader => !leader.dead && !leader.usedAt);
const ownLeader = leaders(owner).sort((a, b) => b.strength - a.strength)[0],
enemyLeader = leaders(opponent).sort((a, b) => a.strength - b.strength)[0]; assert.ok(ownLeader && enemyLeader);
const opponentDial = options.mixedOpponent ? 3.5 : comparison === 'invariant' ? advanced ? 3 : 4 : 1;
const plans: StoneBurnerAllocationStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: ownLeader.id, dial: advanced ? 3 : 2, support: advanced ? 1 : 0,
weapon: comparison === 'normal' ? null : ALLOCATION_STONE } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: opponentDial,
support: options.mixedOpponent ? 1 : advanced ? opponentDial : 0, weapon: options.mirror ? ALLOCATION_MIRROR : null } },
];
for (const step of plans) act(step.actor, step.action);
const revealed = structuredClone(game);
reach(state => !state.response && !state.phaseOpening &&
(state.decision?.kind === 'stoneAllocation' || state.decision?.kind === 'battleLosses' ||
(!state.battle && stoneBurnerAllocationClean(state))));
return { game, lobby, setup, afterSetup, acquiredStone, acquiredMirror, shipments, beforeBattle,
chooseBattle, beforePlans, plans, revealed, owner, opponent, advanced, comparison, mirror: options.mirror ?? false,
mixedOpponent: options.mixedOpponent ?? false, reverseSeats: options.reverseSeats ?? false,
ownElite: options.ownElite ?? 1,
location: 'arrakeen:10', actions,
staging: ['Only unopened scalar/deck lotteries are selected. The Stone is bought from its real native cache; optional Mirror is genuinely retained from the next original unbid cache lot. All forces and support are funded original actions.'] };
}
export type StoneBurnerAllocationFixture = ReturnType<typeof createStoneBurnerAllocationFixture>;
/** Every total comes from the authenticated current owner's actual view.
* Later ordinary casualty and Ixian substitution choices retain their native
* controls; choosing a total never installs a typed loss mix. */
export function completeStoneBurnerAllocation(fixture: StoneBurnerAllocationFixture,
undialed?: number | Readonly<Record<string, number>>, lossChoice = 0) {
let game = structuredClone(fixture.game);
const actions: StoneBurnerAllocationStep[] = [], committed: { before: Game; step: StoneBurnerAllocationStep; after: Game }[] = [];
for (let count = 0; count < 700; count++) {
if (!game.battle && stoneBurnerAllocationClean(game)) return { game, actions, committed };
const decision = game.decision;
let step: StoneBurnerAllocationStep | null;
if (!game.response && !game.phaseOpening && decision?.kind === 'stoneAllocation') {
const offer = viewGame(game, decision.player).battle!.stoneAllocation; assert.ok(offer);
const total = typeof undialed === 'object' ? undialed[offer.owner]
: offer.owner === fixture.owner ? undialed : offer.totals[0];
assert.ok(total !== undefined && offer.totals.includes(total));
step = { actor: decision.player, action: { type: 'decision', event: offer.event, undialed: total } };
const before = structuredClone(game);
game = actStoneBurnerAllocation(game, step.actor, step.action, actions);
committed.push({ before, step, after: structuredClone(game) });
continue;
} else if (!game.response && !game.phaseOpening && decision?.kind === 'battleLosses') {
const view = viewGame(game, decision.player), current = view.decision;
assert.ok(current?.kind === 'battleLosses');
const choice = Math.min(lossChoice, current.options.length - 1);
step = { actor: decision.player, action: { type: 'decision', choice } };
} else step = nextStoneBurnerAllocationStep(game);
assert.ok(step, 'The original Stone consequence needs its next owned native action');
game = actStoneBurnerAllocation(game, step.actor, step.action, actions);
}
throw Error('Original Stone allocation and typed consequences did not complete');
}
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createElement, type MouseEvent, type ReactElement, type ButtonHTMLAttributes } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { StoneBurnerAllocation } from '../components/stone-burner-allocation';
void test('Stone Burner allocation keeps explicit physical-total commitments and provisional sealed timing readable until chosen', () => {
const committed: number[] = [], props = { event: 'stone-original-event', totals: [5, 2, 5],
onCommit: (total: number) => committed.push(total), busy: false };
const html = renderToStaticMarkup(createElement(StoneBurnerAllocation, props));
assert.match(html, /aria-label="Stone Burner undialed force commitment"/);
assert.match(html, /Provisional timing/);
assert.match(html, /Choices stay sealed until everyone has committed/);
assert.match(html, /ordinary and elite losses afterward/);
assert.match(html, /Commit 2 undialed physical forces/);
assert.match(html, /Commit 5 undialed physical forces/);
assert.equal((html.match(/<button/g) ?? []).length, 2);
assert.deepEqual(committed, [], 'Rendering never commits or starts an automatic countdown');
const panel = StoneBurnerAllocation(props)!;
const buttons = panel.props.children[3].props.children as ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>[];
buttons[0].props.onClick!({} as MouseEvent<HTMLButtonElement>);
buttons[1].props.onClick!({} as MouseEvent<HTMLButtonElement>);
assert.deepEqual(committed, [2, 5]);
});
void test('busy Stone Burner commitments cannot submit through their callback, and a fresh event retains its own original choices', () => {
const committed: number[] = [], onCommit = (total: number) => committed.push(total);
const props = { event: 'first-original-event', totals: [1, 3], onCommit, busy: true };
const html = renderToStaticMarkup(createElement(StoneBurnerAllocation, props));
assert.equal((html.match(/<button\b[^>]*\sdisabled=""/g) ?? []).length, 2);
const panel = StoneBurnerAllocation(props)!;
const buttons = panel.props.children[3].props.children as ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>[];
for (const button of buttons) button.props.onClick!({} as MouseEvent<HTMLButtonElement>);
assert.deepEqual(committed, []);
const next = renderToStaticMarkup(createElement(StoneBurnerAllocation,
{ event: 'next-original-event', totals: [0, 4], onCommit, busy: false }));
assert.match(next, /data-event="next-original-event"/);
assert.match(next, /Commit 0 undialed physical forces/);
assert.match(next, /Commit 4 undialed physical forces/);
assert.doesNotMatch(next, /Commit 1 undialed|Commit 3 undialed/);
});
@@ -0,0 +1,203 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import {
ALLOCATION_MIRROR, ALLOCATION_STONE, actStoneBurnerAllocation, completeStoneBurnerAllocation,
createStoneBurnerAllocationFixture, stoneBurnerAllocationPlayer as player,
type StoneBurnerAllocationFixture,
} from './fixture-stone-burner-allocation';
function genuineSource(f: StoneBurnerAllocationFixture) {
assert.equal(f.beforePlans.battle!.stoneAllocationVersion, 1);
assert.ok(f.acquiredStone.players.find(seat => seat.id === f.owner)!.hand.some(card => card.id === ALLOCATION_STONE));
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
assert.equal(f.shipments.length, 2);
for (const shipment of f.shipments) {
assert.equal(player(shipment.after, shipment.step.actor).reserves,
player(shipment.before, shipment.step.actor).reserves - Number(shipment.step.action.amount));
assert.equal(player(shipment.after, shipment.step.actor).spice,
player(shipment.before, shipment.step.actor).spice - Number(shipment.step.action.amount),
'Each real off-planet stronghold shipment pays its original printed cost');
}
assert.equal(player(f.beforePlans, f.owner).forces[f.location], 6);
assert.equal(player(f.beforePlans, f.owner).elites!.forces[f.location], f.advanced ? f.ownElite : 2);
assert.equal(f.beforePlans.battle!.plans[f.owner], undefined, 'The new allocation is not an early Battle Plan field');
assert.equal(viewGame(f.revealed, f.owner).battle!.stoneAllocation, null,
'Revealed weapon modes and actual traitor decisions precede the new count commitment');
}
void test('genuine Basic mixed Ix Stone commits either legal physical total before deciding the winner', () => {
const f = createStoneBurnerAllocationFixture();
genuineSource(f);
assert.equal(f.game.decision?.kind, 'stoneAllocation');
assert.equal(f.game.decision?.player, f.owner);
const offer = viewGame(f.game, f.owner).battle!.stoneAllocation!;
assert.deepEqual(offer.totals, [2, 5]);
assert.equal(offer.selected, null);
assert.equal(viewGame(f.game, f.opponent).battle!.stoneAllocation, null,
'The other side receives no pending private commitment values');
const before = player(f.game, f.owner), enemy = player(f.game, f.opponent);
for (const total of offer.totals) {
const { game, committed } = completeStoneBurnerAllocation(f, total), after = player(game, f.owner);
assert.equal(committed.length, 1);
assert.equal(committed[0].step.action.undialed, total);
assert.equal(game.lastBattleContext!.winner, total === 5 ? f.owner : f.opponent);
assert.equal(after.reserves, before.reserves, 'A count commitment neither spends nor returns a reserve counter');
assert.equal(after.forces['hidden_mobile_stronghold:0'], before.forces['hidden_mobile_stronghold:0']);
assert.equal(after.elites!.forces['hidden_mobile_stronghold:0'], before.elites!.forces['hidden_mobile_stronghold:0']);
assert.equal(after.tanks, before.tanks + (total === 5 ? 1 : 6));
assert.equal(after.elites!.tanks, before.elites!.tanks + (total === 5 ? 1 : 2));
assert.equal(after.forces[f.location] ?? 0, total === 5 ? 5 : 0);
assert.equal(after.elites!.forces[f.location] ?? 0, total === 5 ? 1 : 0);
assert.equal(player(game, f.opponent).tanks, enemy.tanks + (total === 5 ? 4 : 1));
assert.equal(game.discard.filter(card => card.id === ALLOCATION_STONE).length, total === 5 ? 0 : 1);
}
});
void test('genuine Advanced mixed Sardaukar Stone retains three-or-five-counter alternatives and settles the selected physical result', () => {
const f = createStoneBurnerAllocationFixture({ advanced: true });
genuineSource(f);
const offer = viewGame(f.game, f.owner).battle!.stoneAllocation!;
assert.deepEqual(offer.totals, [1, 2, 3]);
assert.equal(f.plans[0].action.dial, 3);
assert.equal(f.plans[0].action.support, 1);
const before = player(f.game, f.owner), enemy = player(f.game, f.opponent);
for (const total of [1, 3]) {
const { game } = completeStoneBurnerAllocation(f, total), after = player(game, f.owner);
assert.equal(game.lastBattleContext!.winner, total === 3 ? f.owner : f.opponent);
assert.equal(after.tanks, before.tanks + (total === 3 ? 3 : 6));
assert.equal(after.elites!.tanks, before.elites!.tanks + 1);
assert.equal(after.forces[f.location] ?? 0, total === 3 ? 3 : 0);
assert.equal(after.elites!.forces[f.location] ?? 0, 0);
assert.equal(player(game, f.opponent).tanks, enemy.tanks + (total === 3 ? 3 : 1));
assert.equal(after.spice, before.spice - 1 + (total === 3 ? 2 : 0),
'Original support is paid once, then the surviving city occupant earns its ordinary Collection income');
for (const plan of f.plans)
assert.equal(player(game, plan.actor).leaders.find(leader => leader.id === plan.action.leader)!.dead, false,
'The genuinely selected ignore mode leaves leaders alive when no other weapon kills them');
}
});
void test('normal and invariant-winner original mixed Ix battles keep their source-backed later typed casualty choice', () => {
for (const comparison of ['normal', 'invariant'] as const) {
const f = createStoneBurnerAllocationFixture({ comparison });
genuineSource(f);
assert.equal(f.game.decision?.kind, 'battleLosses');
assert.equal(f.game.decision?.player, f.owner);
assert.equal(viewGame(f.game, f.owner).battle?.stoneAllocation ?? null, null);
assert.ok(f.actions.every(step => step.action.type !== 'decision' || step.action.undialed === undefined));
const decision = f.game.decision!;
assert.ok(decision.kind === 'battleLosses');
const physical = decision.options.map(option => option.normal + option.elite);
assert.deepEqual([...new Set(physical)].sort((a, b) => a - b), [1, 4]);
for (const index of [0, decision.options.length - 1]) {
const { game, committed } = completeStoneBurnerAllocation(f, undefined, index);
assert.deepEqual(committed, []);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).tanks, player(f.game, f.owner).tanks + physical[index]);
assert.equal(player(game, f.owner).forces[f.location], 6 - physical[index]);
}
}
});
void test('actual paid Stone and next-turn native unbid Mirror share the same count protocol without fabricating either held card', () => {
const f = createStoneBurnerAllocationFixture({ mirror: true });
genuineSource(f);
assert.equal(f.acquiredMirror!.turn, 2);
assert.ok(player(f.acquiredMirror!, f.opponent).hand.some(card => card.id === ALLOCATION_MIRROR));
assert.ok(f.actions.some(step => step.actor === f.opponent && step.action.type === 'decision' && step.action.keep === true));
const choices = f.actions.filter(step => step.action.type === 'decision' && step.action.mode === 'ignore');
assert.equal(choices.length, 2);
assert.ok(choices.some(step => step.actor === f.owner));
assert.ok(choices.some(step => step.actor === f.opponent));
const offer = viewGame(f.game, f.owner).battle!.stoneAllocation!;
assert.deepEqual(offer.totals, [2, 5]);
for (const total of offer.totals) {
const { game } = completeStoneBurnerAllocation(f, total);
assert.equal(game.lastBattleContext!.winner, total === 5 ? f.owner : f.opponent);
assert.equal(game.discard.filter(card => card.id === ALLOCATION_STONE).length, total === 5 ? 0 : 1);
assert.equal(game.discard.filter(card => card.id === ALLOCATION_MIRROR).length, total === 5 ? 1 : 0);
}
});
void test('genuine two-sided mixed Stone counts stay sealed until both commit in either original seating order', () => {
const queues: string[][] = [];
for (const reverseSeats of [false, true]) {
const f = createStoneBurnerAllocationFixture({ mixedOpponent: true, reverseSeats });
genuineSource(f);
const order = f.game.order.filter(actor => actor === f.owner || actor === f.opponent);
queues.push(order);
assert.equal(f.game.decision!.player, order[0]);
assert.equal(f.game.battle!.attacker, order[0]);
assert.deepEqual(viewGame(f.game, f.owner).battle!.stoneAllocation!.totals, [1, 2, 3]);
assert.deepEqual(viewGame(f.game, f.opponent).battle!.stoneAllocation!.totals, [2, 3]);
for (const selection of [{ emperor: 3, ixians: 2 }, { emperor: 1, ixians: 3 }]) {
const { game, committed } = completeStoneBurnerAllocation(f, selection);
assert.equal(committed.length, 2, 'Both original mixed sides commit even after a decisive first extreme');
assert.deepEqual(committed.map(entry => entry.step.actor), order);
const first = committed[0], waiting = first.after, next = order[1];
assert.equal(waiting.decision?.kind, 'stoneAllocation');
assert.equal(waiting.decision?.player, next);
const own = viewGame(waiting, order[0]).battle!.stoneAllocation!, rival = viewGame(waiting, next).battle!.stoneAllocation!;
assert.equal(own.selected, first.step.action.undialed);
assert.equal(rival.selected, null);
assert.deepEqual(Object.keys(rival).sort(), ['event', 'owner', 'player', 'selected', 'totals']);
const messages = waiting.log.slice(first.before.log.length).map(entry => entry.text);
assert.ok(messages.length > 0);
assert.ok(messages.every(message => !/\d/.test(message)), 'The first public commitment message reveals no selected count');
assert.deepEqual(player(waiting, f.owner).forces, player(f.game, f.owner).forces);
assert.deepEqual(player(waiting, f.opponent).forces, player(f.game, f.opponent).forces);
assert.equal(game.lastBattleContext!.winner, selection.emperor > selection.ixians ? f.owner : f.opponent);
const completedMessages = game.log.slice(f.game.log.length).map(entry => entry.text);
for (const actor of [f.owner, f.opponent])
assert.ok(completedMessages.some(message => message.includes(`${player(game, actor).name} ${selection[actor as keyof typeof selection]} undialed`)),
'Only the completed sealed queue publishes both original counts');
}
}
assert.deepEqual(queues, [['emperor', 'ixians'], ['ixians', 'emperor']],
'Actual reversed human joins change the original storm queue without a saved-order edit');
});
void test('all four minimal policies commit legal own counts from both sealed private views before original settlement', () => {
const f = createStoneBurnerAllocationFixture({ mixedOpponent: true });
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
let game = structuredClone(f.game);
const owners: string[] = [];
for (let count = 0; count < 2; count++) {
const decision = game.decision; assert.ok(decision?.kind === 'stoneAllocation');
const view = viewGame(game, decision.player), offer = view.battle!.stoneAllocation!;
view.players.find(player => player.id === decision.player)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'decision' && action.event === offer.event &&
typeof action.undialed === 'number'); assert.ok(action, `${difficulty} must answer its current owned count`);
assert.ok(offer.totals.includes(Number(action.undialed)));
assert.equal(offer.selected, null);
owners.push(decision.player);
game = actStoneBurnerAllocation(game, decision.player, action);
}
assert.deepEqual(owners, ['emperor', 'ixians']);
const completed = completeStoneBurnerAllocation({ ...f, game });
assert.ok([f.owner, f.opponent].includes(completed.game.lastBattleContext!.winner!));
}
});
void test('a genuinely committed Stone total retains both later winner choices of ordinary and Sardaukar losses', () => {
const f = createStoneBurnerAllocationFixture({ mixedOpponent: true, ownElite: 2 });
genuineSource(f);
assert.deepEqual(viewGame(f.game, f.owner).battle!.stoneAllocation!.totals, [2, 3, 4]);
const selection = { emperor: 3, ixians: 2 }, first = completeStoneBurnerAllocation(f, selection);
const later = first.committed.at(-1)!.after.decision;
assert.ok(later?.kind === 'battleLosses');
assert.equal(later.player, f.owner);
assert.deepEqual(later.options.map(option => [option.normal, option.elite]), [[2, 1], [1, 2]]);
const eliteLosses: number[] = [];
for (const index of [0, later.options.length - 1]) {
const { game } = completeStoneBurnerAllocation(f, selection, index), before = player(f.game, f.owner), after = player(game, f.owner);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(after.tanks, before.tanks + 3);
assert.equal(after.forces[f.location], 3);
assert.equal(after.elites!.tanks, before.elites!.tanks + later.options[index].elite);
eliteLosses.push(after.elites!.tanks - before.elites!.tanks);
}
assert.deepEqual(eliteLosses, [1, 2], 'The count commitment never preselects the winner’s typed physical loss mix');
});
+236
View File
@@ -0,0 +1,236 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck, ixBattleCards, leaders, type Card } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { casualtyOptions, type CombatForces } from '../game/combat';
import {
quoteBattleResolution, StoneBurnerAllocationRequiredError, BattleResolutionQuoteError,
type BattleResolutionInput, type ResolutionCombatant,
} from '../game/battle-resolution-quote';
import { quoteStoneBurnerAllocations, stoneBurnerCommittedLosses } from '../game/stone-burner-allocation';
import { stoneBurnerPlanBlock, stoneBurnerCompulsionBlock } from '../game/stone-burner';
import { quoteEcazOccupyBattle } from '../game/ecaz-occupy-battle';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { casualtyConstraintFields, committedCasualtyOptions } from '../game/battle-casualty-commitment';
const catalog = (): Card[] => [...baseDeck(), ...ixBattleCards(), ...richeseCards(), ...ecazTreacheryCards()];
const ordinary = (normal: number, freeSupport = false): CombatForces => ({ normal, elite: 0, eliteStrength: 1, freeSupport });
const emperor: CombatForces = { normal: 4, elite: 2, eliteStrength: 2, freeSupport: false };
function combatant(id: string, faction: ResolutionCombatant['faction'], forces: CombatForces, dial: number, support: number): ResolutionCombatant {
const leader = { ...leaders(faction)[0] };
return { id, faction, forces: { ...forces }, spice: 10, hand: [], leader,
plan: { dial, support, leader: leader.id, weapon: null, defense: null } };
}
function fixture(): BattleResolutionInput {
const attacker = combatant('a', 'emperor', emperor, 3, 1);
const defender = combatant('d', 'guild', ordinary(4), 1, 1);
const cards = catalog(), stone = cards.find(card => card.effect === 'stoneBurner')!;
attacker.hand = [stone]; attacker.plan.weapon = stone.id; attacker.stoneMode = 'ignore';
return { advanced: true, turn: 2, territory: 'carthag', attacker, defender,
participants: [attacker, defender].map(side => ({ id: side.id, faction: side.faction })),
voters: [attacker, defender].map(side => ({ id: side.id, beneficiary: side.id, called: false, traitors: [] })),
physicalCards: cards, pendingAuditorPresent: false, pendingRetentionPresent: false };
}
void test('a committed Stone physical loss count survives card cleanup and native aftercare without reopening other typed totals', () => {
const input = fixture();
const result = quoteBattleResolution({...input,stoneAllocations:{a:3}});
const commitment = result.casualties!;
assert.equal(commitment.physicalLossCount, 3);
const saved = JSON.parse(JSON.stringify(commitment));
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support,saved),saved.options);
assert.equal(new Set(saved.options.map((option: {normal:number;elite:number}) => option.normal + option.elite)).size,1);
assert.ok(saved.options.length > 1, 'Choosing a physical total still leaves the original typed winner choice');
assert.deepEqual(casualtyConstraintFields(saved),{physicalLossCount:3});
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support),
casualtyOptions(saved.forces,saved.dial,saved.support), 'Legacy unconstrained casualty choices remain complete');
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support,{physicalLossCount:21}),[]);
assert.deepEqual(committedCasualtyOptions(saved.forces,saved.dial,saved.support,{fullyCommitted:true,physicalLossCount:3}),[],
'A partial Stone commitment cannot replace the already fixed full Harass remainder');
});
void test('only outcome-ambiguous Stone asks for physical totals, allowing either winner and retaining typed alternatives within one committed total', () => {
const input = fixture(), before = structuredClone(input);
const choices = { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] };
assert.deepEqual(quoteStoneBurnerAllocations(input), choices);
assert.throws(() => quoteBattleResolution(input), StoneBurnerAllocationRequiredError);
const loser = quoteBattleResolution({ ...input, stoneAllocations: { a: 2 } });
assert.equal(loser.winner, 'd');
assert.deepEqual(loser.stone, { winner: 'defender', attacker: [2], defender: [3] });
assert.deepEqual(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { a: 2 } }, 'a'), [
{ normal: 3, elite: 1, paidNormal: 1, paidElite: 0 },
]);
assert.equal(stoneBurnerCommittedLosses(input, 'a'), null);
assert.equal(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { a: 2 } }, 'd'), null);
const winner = quoteBattleResolution({ ...input, stoneAllocations: { a: 3 } });
assert.equal(winner.winner, 'a');
assert.equal(winner.casualties!.physicalLossCount, 3);
assert.deepEqual(winner.stone, { winner: 'attacker', attacker: [3], defender: [3] });
assert.deepEqual(winner.casualties!.options, [
{ normal: 2, elite: 1, paidNormal: 0, paidElite: 1 },
{ normal: 1, elite: 2, paidNormal: 1, paidElite: 0 },
]);
for (const option of winner.casualties!.options) assert.equal(6 - option.normal - option.elite, 3);
assert.deepEqual(quoteStoneBurnerAllocations({ ...input, stoneAllocations: { a: 3 } }), choices);
assert.deepEqual(input, before);
});
void test('both variable sides must commit even when the first chosen total already fixes the winner', () => {
const input = fixture();
input.defender = { ...input.defender, forces: { ...emperor }, plan: { ...input.defender.plan, dial: 3, support: 1 } };
const required = { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }, { player: 'd', owner: 'd', totals: [2, 3, 4] }] };
assert.deepEqual(quoteStoneBurnerAllocations(input), required);
assert.deepEqual(quoteStoneBurnerAllocations({ ...input, stoneAllocations: { a: 4 } }), required);
assert.throws(() => quoteBattleResolution({ ...input, stoneAllocations: { a: 4 } }), StoneBurnerAllocationRequiredError);
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 4, d: 2 } }).winner, 'a');
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 2, d: 4 } }).winner, 'd');
input.aggressor = 'd';
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 3, d: 3 } }).winner, 'd');
});
void test('invariant Stone and ordinary battles keep their original winner-later allocation and gain no commitment roster', () => {
const input = fixture();
input.defender.forces = ordinary(1);
const options = casualtyOptions(input.attacker.forces, 3, 1);
assert.equal(quoteStoneBurnerAllocations(input), null);
assert.deepEqual(quoteBattleResolution(input).casualties!.options, options);
input.attacker.hand = []; input.attacker.plan.weapon = null; delete input.attacker.stoneMode;
assert.equal(quoteStoneBurnerAllocations(input), null);
const ordinaryResult = quoteBattleResolution(input);
assert.equal(ordinaryResult.stone, null);
assert.deepEqual(ordinaryResult.casualties!.options, options);
});
void test('Basic Cyborg/Suboid pools use the same physical commitment without granting Advanced strength or support', () => {
const input = fixture(); input.advanced = false;
input.attacker = combatant('a', 'ixians', { normal: 6, elite: 2, eliteStrength: 2, normalFixedHalf: true, freeSupport: true }, 2, 0);
const stone = catalog().find(card => card.effect === 'stoneBurner')!;
input.attacker.hand = [stone]; input.attacker.plan.weapon = stone.id; input.attacker.stoneMode = 'ignore';
input.defender = combatant('d', 'guild', ordinary(7, true), 1, 0);
input.participants = [input.attacker, input.defender].map(side => ({ id: side.id, faction: side.faction }));
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [4, 7] }] });
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 4 } }).winner, 'd');
const result = quoteBattleResolution({ ...input, stoneAllocations: { a: 7 } });
assert.equal(result.winner, 'a');
assert.deepEqual(result.casualties!.options, [{ normal: 0, elite: 1, paidNormal: 0, paidElite: 0 }]);
assert.deepEqual(result.payments, []);
});
void test('physical Mirror retains its own card while the copied Stone uses the same committed comparison', () => {
const input = fixture(), mirror = catalog().find(card => card.effect === 'mirrorWeapon')!;
input.defender.hand = [mirror]; input.defender.plan.weapon = mirror.id; input.defender.stoneMode = 'ignore';
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
const result = quoteBattleResolution({ ...input, stoneAllocations: { a: 2 } });
assert.equal(result.winner, 'd');
assert.ok(result.winnerCards.includes(mirror.id));
assert.ok(result.discarded.some(entry => entry.player === 'a' && entry.card === input.attacker.plan.weapon));
});
function occupyFixture(canceled = false): BattleResolutionInput {
const input = fixture();
const profile = quoteEcazOccupyBattle({ advanced: true, battleOrderActor: 'ecaz',
ecaz: { id: 'ecaz', faction: 'ecaz', ally: 'ally', forces: ordinary(5) },
ally: { id: 'ally', faction: 'emperor', ally: 'ecaz', forces: { ...emperor } },
lead: canceled ? 'ally' : 'ecaz', canceled });
const own = combatant(profile.lead, canceled ? 'emperor' : 'ecaz', profile.forces, profile.fixedEcazDial + 3, 1);
own.ally = canceled ? 'ecaz' : 'ally';
own.hand = input.attacker.hand; own.plan.weapon = input.attacker.plan.weapon; own.stoneMode = 'ignore';
input.attacker = own; input.ecazOccupy = profile;
input.defender = combatant('d', 'guild', ordinary(canceled ? 4 : 6), 1, 1);
input.participants = [{ id: 'ecaz', faction: 'ecaz', ally: 'ally' }, { id: 'ally', faction: 'emperor', ally: 'ecaz' }, { id: 'd', faction: 'guild' }];
input.voters = ['ecaz', 'ally'].map(id => ({ id, beneficiary: profile.lead, called: false, traitors: [] }))
.concat([{ id: 'd', beneficiary: 'd', called: false, traitors: [] }]);
return input;
}
void test('active Occupy asks the variable physical owner for the lead side total, retaining fixed Ecaz loss and same-total typed alternatives', () => {
const input = occupyFixture();
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'ecaz', owner: 'ally', totals: [4, 5, 6] }] });
const result = quoteBattleResolution({ ...input, stoneAllocations: { ecaz: 5 } });
assert.equal(result.winner, 'ecaz');
assert.deepEqual(result.fixedLosses, [{ owner: 'ecaz', normal: 3, elite: 0 }]);
assert.equal(result.casualties!.owner, 'ally');
assert.equal(result.casualties!.physicalLossCount, 3, 'The fixed Ecaz losses are outside the committed variable loss count');
assert.equal(result.casualties!.options.length, 2);
assert.deepEqual(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { ecaz: 5 } }, 'ecaz'), result.casualties!.options);
for (const option of result.casualties!.options) assert.equal(2 + 6 - option.normal - option.elite, 5);
assert.deepEqual(quoteBattleResolution({ ...input, stoneAllocations: { ecaz: 4 } }).destroyedArmies, ['ecaz', 'ally']);
assert.throws(() => quoteStoneBurnerAllocations({ ...input, stoneAllocations: { ally: 5 } }), /original side players/);
const canceled = occupyFixture(true);
assert.deepEqual(quoteStoneBurnerAllocations(canceled), { choices: [{ player: 'ally', owner: 'ally', totals: [2, 3, 4] }] });
assert.deepEqual(quoteBattleResolution({ ...canceled, stoneAllocations: { ally: 3 } }).fixedLosses, []);
});
void test('Ecaz Harass removes only its fixed undialed complement before variable totals, without changing the original mandatory loss', () => {
const input = occupyFixture(), own = input.attacker, profile = input.ecazOccupy!;
const harass = catalog().find(card => card.id === HARASS_WITHDRAW_CARD)!;
own.hand = [...own.hand, harass]; own.plan.defense = harass.id;
own.harassWithdraw = { blocked: null, forces: profile.ecazForces,
locations: { 'carthag:11': { normal: 5, elite: 0 } },
occupy: { cardUser: 'ecaz', ecaz: 'ecaz', ally: 'ally', canceled: false,
fixedEcazDial: 3, variableForces: profile.forces } };
input.defender.forces = ordinary(4);
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'ecaz', owner: 'ally', totals: [2, 3, 4] }] });
const result = quoteBattleResolution({ ...input, stoneAllocations: { ecaz: 3 } });
assert.deepEqual(result.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.deepEqual(result.fixedLosses, [{ owner: 'ecaz', normal: 3, elite: 0 }]);
assert.equal(result.casualties!.options.length, 2);
assert.deepEqual(stoneBurnerCommittedLosses({ ...input, stoneAllocations: { ecaz: 3 } }, 'ecaz'), result.casualties!.options);
for (const option of result.casualties!.options) assert.equal(6 - option.normal - option.elite, 3);
});
void test('temporary elite roles commit physical normal totals and never manufacture starred losses', () => {
const input = fixture(); input.attacker.forces = { normal: 6, elite: 0, temporaryElite: 2, eliteStrength: 2, freeSupport: false };
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
const result = quoteBattleResolution({ ...input, stoneAllocations: { a: 3 } });
assert.deepEqual(result.casualties!.options, [{ normal: 3, elite: 0, paidNormal: 1, paidElite: 0 }]);
});
void test('native World and reconstructed visible Richese pools retain the same physical total rule', () => {
const input = fixture(); input.territory = 'homeworld:emperor';
input.homeworld = { native: 'a', card: 'kaitain', side: 'high', nativeForces: { normal: 4, elite: 2 } };
input.voters = [input.voters[0]];
assert.deepEqual(quoteStoneBurnerAllocations(input), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
assert.equal(quoteBattleResolution({ ...input, stoneAllocations: { a: 3 } }).winner, 'a');
const revealed = fixture();
revealed.defender = combatant('d', 'richese', ordinary(4), 1, 1);
revealed.defender.noFieldAtTerritory = false;
revealed.participants = [revealed.attacker, revealed.defender].map(side => ({ id: side.id, faction: side.faction, noFieldAtTerritory: false }));
assert.deepEqual(quoteStoneBurnerAllocations(revealed), { choices: [{ player: 'a', owner: 'a', totals: [2, 3, 4] }] });
});
void test('successful traitors and Lasgun–shield explosions have no allocation opportunity', () => {
const input = fixture();
input.voters = input.voters.map((voter, index) => index ? voter : {
...voter, called: true, traitors: [input.defender.leader!.id] });
assert.equal(quoteStoneBurnerAllocations(input), null);
assert.equal(quoteBattleResolution(input).result, 'traitor');
const explosion = fixture(), cards = catalog(), shield = cards.find(card => card.kind === 'shield')!, lasgun = cards.find(card => card.kind === 'lasgun')!;
explosion.attacker.hand = [...explosion.attacker.hand, shield]; explosion.attacker.plan.defense = shield.id;
explosion.defender.hand = [lasgun]; explosion.defender.plan.weapon = lasgun.id;
assert.equal(quoteStoneBurnerAllocations(explosion), null);
assert.equal(quoteBattleResolution(explosion).result, 'explosion');
});
void test('invalid totals, unrelated keys, missing modes and unfunded support fail before offering an allocation', () => {
const input = fixture();
const invalid: Record<string, number>[] = [{ a: 1 }, { a: 2.5 }, { d: 3 }, { unrelated: 2 }];
for (const stoneAllocations of invalid)
assert.throws(() => quoteStoneBurnerAllocations({ ...input, stoneAllocations }), BattleResolutionQuoteError);
assert.throws(() => quoteStoneBurnerAllocations({ ...input, stoneAllocations: Object.create({ a: 3 }) as Record<string, number> }), /original side players/);
input.attacker.spice = 0;
assert.throws(() => quoteStoneBurnerAllocations(input), /funded/);
input.attacker.spice = 10; delete input.attacker.stoneMode;
assert.throws(() => quoteStoneBurnerAllocations(input), /revealed mode/);
});
void test('new-battle precommit opt-in removes only the old timing boundary and retains invalid-force/dial/zero-spice plan checks', () => {
const input = fixture();
assert.ok(stoneBurnerPlanBlock(input.attacker.forces, 3, 1, input.defender.forces, 'attacker'));
assert.equal(stoneBurnerPlanBlock(input.attacker.forces, 3, 1, input.defender.forces, 'attacker', 'attacker', undefined, true), null);
assert.ok(stoneBurnerPlanBlock(input.attacker.forces, 99, 1, input.defender.forces, 'attacker', 'attacker', undefined, true));
assert.ok(stoneBurnerPlanBlock({ ...input.attacker.forces, normal: -1 }, 3, 1, input.defender.forces, 'attacker', 'attacker', undefined, true));
assert.ok(stoneBurnerCompulsionBlock(ordinary(0), emperor, 'attacker'));
assert.equal(stoneBurnerCompulsionBlock(ordinary(0), emperor, 'attacker', 'attacker', undefined, true), null);
});
+2 -1
View File
@@ -90,10 +90,11 @@ void test('all profiles offer guarded low-dial Stone plans on both sides in Basi
}
});
void test('every public pool is checked and blocked, missing or noncanonical Stone inputs are not emitted', () => {
void test('legacy battles check every public pool and blocked, missing or noncanonical Stone inputs are not emitted', () => {
for (const difficulty of DIFFICULTIES) {
const g = fixture(true, true),
v = view(g, difficulty);
v.battle!.stoneBurnerContext!.allowAllocations = false;
v.battle!.ownForces = {
normal: 2,
elite: 1,
+15 -3
View File
@@ -147,7 +147,7 @@ void test('forged Stone roles reject before any hidden plan commitment', () => {
}
});
void test('Advanced Emperor outcome-changing allocation is rejected identically before or after an unrelated hidden opposing plan', () => {
void test('legacy Advanced Emperor guard remains hidden-plan independent while new battles admit later physical commitments', () => {
let g = fixture();
g.advanced = true;
g.players[0].faction = 'emperor';
@@ -162,6 +162,9 @@ void test('Advanced Emperor outcome-changing allocation is rejected identically
revived: 0,
};
g = prepare(start(g));
for (const state of [g, ...[0, 1, 2].map(dial => plan(g, 'g', {dial}))])
assert.ok(plan(state, 'r', {dial: 3, support: 1}).battle!.plans.r);
delete g.battle!.stoneAllocationVersion;
const attempt = (state: Game) => {
try {
plan(state, 'r', { dial: 3, support: 1 });
@@ -181,7 +184,7 @@ void test('Advanced Emperor outcome-changing allocation is rejected identically
assert.ok(valid.battle!.plans.r);
});
void test('public zero-spice compulsion guard is independent of Stone possession and leaves ordinary Voice decline available', () => {
void test('legacy zero-spice compulsion guard remains custody independent while new Stone has a finishable later commitment', () => {
let g = fixture();
g.advanced = true;
g.players[0].forces = { 'arrakeen:10': 1 };
@@ -201,6 +204,11 @@ void test('public zero-spice compulsion guard is independent of Stone possession
g.players[2].ally = 'g';
g.players[1].ally = 'e';
g = start(g);
const withoutStone = reload(g);
withoutStone.richeseCache!.push(withoutStone.players[0].hand.pop()!);
for (const state of [g, withoutStone]) assert.doesNotThrow(() =>
send(state, 'e', {type: 'voice', kind: 'stoneBurner', must: true}));
delete g.battle!.stoneAllocationVersion;
const error = (state: Game) => {
try {
send(state, 'e', { type: 'voice', kind: 'stoneBurner', must: true });
@@ -220,7 +228,7 @@ void test('public zero-spice compulsion guard is independent of Stone possession
assert.ok(plan(g, 'r', { weapon: null, dial: 0 }).battle!.plans.r);
});
void test('required Mirror Voice rejects unfinishable public zero-spice plans without testing card possession', () => {
void test('required Mirror Voice preserves the legacy guard and new count capability without testing card possession', () => {
let g = fixture();
g.advanced = true;
g.richeseCache!.push(g.players[0].hand.pop()!);
@@ -242,6 +250,10 @@ void test('required Mirror Voice rejects unfinishable public zero-spice plans wi
const withoutMirror = reload(g);
withoutMirror.richeseCache!.push(withoutMirror.players[0].hand.pop()!);
for (const state of [g, withoutMirror]) {
assert.doesNotThrow(() => send(state, 'e', {
type: 'voice', kind: 'mirrorWeapon', must: true,
}));
delete state.battle!.stoneAllocationVersion;
const before = structuredClone(state);
assert.throws(() => send(state, 'e', {
type: 'voice', kind: 'mirrorWeapon', must: true,