Allow late Sapho priority before final battle settlement
Fresh Richese-roster battles support clean aggressor changes after plans and revelation. Both combatants explicitly finish a public final opportunity regardless of held cards or result; an actual Sapho use clears both marks with a fresh event. Close priority before physical Stone allocation and other outcome settlement. Preserve plans, votes, participants, Habbanya priority and reserved physical cards. Keep old unstamped timing and discard signature bytes. Route all final vote exits through native Homeworld defense first. Add persistent controls and finite minimal bot readiness. Validation:16-file177/177, types/lint/build and eight original-action/rendered-table programmes. Actual cache1 and paid forces use40 Basic/47 Advanced source actions; genuine Stone1 turn1/Sapho1 turn2 uses86. Ordinary and mixed count2 ties change through sealed-plan/final-window Sapho, explicit revised acknowledgements and native losses. Sixteen minimal proposals are legal; controlled reservation and pure window/control cases are separate. Bounded review fixed the Harkonnen Nexus final-vote route; frozen feedback corrected four historical fixture assumptions. All checks then passed together. Final-window protocol is provisional. Direct late Semuta claim/capture, combined World/Nexus/Sapho, wider combinations, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
This commit is contained in:
1 parent
cb13a4d674
commit
84200ebc74
24 files changed
+1023
-40
No files matched your search
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, type ButtonHTMLAttributes, type MouseEvent, type ReactElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { BattleOutcomeWindowControls } from '../components/battle-outcome-window';
|
||||
import { createBattleOutcomeWindow, readyBattleOutcomeWindow, reviseBattleOutcomeWindow } from '../game/battle-outcome-window';
|
||||
|
||||
const identity = { event: 'original-public-battle', attacker: 'a', defender: 'd', turn: 2 };
|
||||
type Props = Parameters<typeof BattleOutcomeWindowControls>[0];
|
||||
function props(onReady: (event: string) => void): Props {
|
||||
return { window: createBattleOutcomeWindow(identity, 0), me: 'a', busy: false, onReady,
|
||||
players: [{ id: 'a', name: 'Atreides player' }, { id: 'd', name: 'Richese player' }, { id: 'spectator', name: 'Observer' }] };
|
||||
}
|
||||
const markup = (value: Props) => renderToStaticMarkup(createElement(BattleOutcomeWindowControls, value));
|
||||
function readyButton(value: Props): ReactElement<ButtonHTMLAttributes<HTMLButtonElement>> | null {
|
||||
const panel = BattleOutcomeWindowControls(value);
|
||||
const children = panel.props.children as (ReactElement<ButtonHTMLAttributes<HTMLButtonElement>> | false)[];
|
||||
return children.find(child => child && child.props.children === 'Finish final battle actions') || null;
|
||||
}
|
||||
const click = (value: Props) => readyButton(value)?.props.onClick?.({} as MouseEvent<HTMLButtonElement>);
|
||||
|
||||
void test('final battle controls persist public guidance and status until both players finish, with no automatic acknowledgement', () => {
|
||||
const sent: string[] = [], value = props(event => sent.push(event));
|
||||
const html = markup(value);
|
||||
assert.match(html, /aria-label="Final battle actions"/);
|
||||
assert.match(html, /<h3>Final battle actions<\/h3>/);
|
||||
assert.match(html, /Play any optional final battle card you want to use, then finish/);
|
||||
assert.match(html, /Battle resolution waits for both players/);
|
||||
assert.match(html, /Juice of Sapho changes the aggressor, both finished marks are cleared/);
|
||||
assert.match(html, /Atreides player: Choosing final actions/);
|
||||
assert.match(html, /Richese player: Choosing final actions/);
|
||||
assert.doesNotMatch(html, /autofocus|role="dialog"|role="alertdialog"/i);
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(readyButton(value)!.props.disabled, false);
|
||||
click(value);
|
||||
assert.deepEqual(sent, [value.window.event]);
|
||||
});
|
||||
|
||||
void test('busy, already finished and closed final battle controls cannot submit through their callback', () => {
|
||||
const sent: string[] = [], value = props(event => sent.push(event));
|
||||
const one = readyBattleOutcomeWindow(value.window, identity, 0, 'a', value.window.event);
|
||||
const both = readyBattleOutcomeWindow(one, identity, 0, 'd', one.event);
|
||||
for (const blocked of [{ ...value, busy: true }, { ...value, window: one }, { ...value, window: both }]) {
|
||||
assert.match(markup(blocked), /<button\b[^>]*\sdisabled=""[^>]*>Finish final battle actions/);
|
||||
click(blocked);
|
||||
}
|
||||
assert.deepEqual(sent, []);
|
||||
const defender = { ...value, me: 'd', window: one };
|
||||
assert.match(markup(defender), /Atreides player: Finished/);
|
||||
assert.equal(readyButton(defender)!.props.disabled, false);
|
||||
click(defender);
|
||||
assert.deepEqual(sent, [one.event]);
|
||||
});
|
||||
|
||||
void test('a public Sapho revision renders fresh marks and sends only the new event', () => {
|
||||
const sent: string[] = [], value = props(event => sent.push(event));
|
||||
const one = readyBattleOutcomeWindow(value.window, identity, 0, 'a', value.window.event);
|
||||
const revised = { ...value, window: reviseBattleOutcomeWindow(one, identity, 0, 1) };
|
||||
const html = markup(revised);
|
||||
assert.equal((html.match(/Choosing final actions/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(html, /: Finished/);
|
||||
assert.notEqual(revised.window.event, one.event);
|
||||
click(revised);
|
||||
assert.deepEqual(sent, [revised.window.event]);
|
||||
});
|
||||
|
||||
void test('noncombatants see the same public participant status without a finish action', () => {
|
||||
const sent: string[] = [], value = { ...props(event => sent.push(event)), me: 'spectator' };
|
||||
const html = markup(value);
|
||||
assert.match(html, /Atreides player: Choosing final actions/);
|
||||
assert.match(html, /Richese player: Choosing final actions/);
|
||||
assert.doesNotMatch(html, /<button/);
|
||||
assert.equal(readyButton(value), null);
|
||||
click(value);
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createBattleOutcomeWindow,
|
||||
validateBattleOutcomeWindow,
|
||||
readyBattleOutcomeWindow,
|
||||
reviseBattleOutcomeWindow,
|
||||
BattleOutcomeWindowError,
|
||||
type BattleOutcomeIdentity,
|
||||
type BattleOutcomeWindow,
|
||||
} from '../game/battle-outcome-window';
|
||||
|
||||
const identity: BattleOutcomeIdentity = { event: 'original-battle-event', attacker: 'a', defender: 'd', turn: 3 };
|
||||
const open = (revision = 2) => createBattleOutcomeWindow(identity, revision);
|
||||
|
||||
void test('a public final battle window closes only after both distinct combatants finish in either order', () => {
|
||||
for (const [first, second] of [['a', 'd'], ['d', 'a']]) {
|
||||
const state = open(), before = structuredClone(state);
|
||||
assert.equal(state.event, 'battleOutcome:["original-battle-event",3,2]');
|
||||
assert.equal(state.battle, identity.event);
|
||||
assert.deepEqual(state.ready, []);
|
||||
assert.equal(state.closed, false);
|
||||
validateBattleOutcomeWindow(state, identity, 2);
|
||||
Object.freeze(state.ready); Object.freeze(state);
|
||||
const one = readyBattleOutcomeWindow(state, identity, 2, first, state.event);
|
||||
assert.deepEqual(one.ready, [first]);
|
||||
assert.equal(one.closed, false);
|
||||
assert.notEqual(one.ready, state.ready);
|
||||
const both = readyBattleOutcomeWindow(one, identity, 2, second, one.event);
|
||||
assert.deepEqual(both.ready, [first, second]);
|
||||
assert.equal(both.closed, true);
|
||||
validateBattleOutcomeWindow(JSON.parse(JSON.stringify(both)), identity, 2);
|
||||
assert.deepEqual(state, before);
|
||||
assert.deepEqual(one.ready, [first]);
|
||||
assert.throws(() => readyBattleOutcomeWindow(both, identity, 2, first, both.event), /closed/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a public Sapho revision clears both finished marks and invalidates the previous opportunity', () => {
|
||||
const original = open(), one = readyBattleOutcomeWindow(original, identity, 2, 'd', original.event);
|
||||
const revised = reviseBattleOutcomeWindow(one, identity, 2, 3);
|
||||
assert.equal(revised.event, 'battleOutcome:["original-battle-event",3,3]');
|
||||
assert.notEqual(revised.event, one.event);
|
||||
assert.deepEqual(revised.ready, []);
|
||||
assert.equal(revised.closed, false);
|
||||
assert.deepEqual(one.ready, ['d']);
|
||||
assert.throws(() => readyBattleOutcomeWindow(revised, identity, 3, 'a', one.event), /expired/);
|
||||
const first = readyBattleOutcomeWindow(revised, identity, 3, 'd', revised.event);
|
||||
assert.equal(first.closed, false, 'The player who previously finished must finish again');
|
||||
assert.equal(readyBattleOutcomeWindow(first, identity, 3, 'a', first.event).closed, true);
|
||||
});
|
||||
|
||||
void test('stale, duplicate and noncombatant acknowledgements cannot consume a final battle opportunity', () => {
|
||||
const state = open(), one = readyBattleOutcomeWindow(state, identity, 2, 'a', state.event);
|
||||
const before = structuredClone(one);
|
||||
assert.throws(() => readyBattleOutcomeWindow(one, identity, 2, 'a', one.event), /already finished/);
|
||||
assert.throws(() => readyBattleOutcomeWindow(one, identity, 2, 'outsider', one.event), /combatant/);
|
||||
assert.throws(() => readyBattleOutcomeWindow(one, identity, 2, 'd', 'stale-event'), /expired/);
|
||||
assert.throws(() => readyBattleOutcomeWindow(one, identity, 1, 'd', one.event), /revision/);
|
||||
assert.throws(() => readyBattleOutcomeWindow(one, { ...identity, event: 'another-battle' }, 2, 'd', one.event));
|
||||
assert.deepEqual(one, before);
|
||||
});
|
||||
|
||||
void test('original identity, exact window shape and public ready closure must agree', () => {
|
||||
const changes: ((state: BattleOutcomeWindow) => void)[] = [
|
||||
state => { state.battle = 'another-battle'; },
|
||||
state => { state.turn++; },
|
||||
state => { state.attacker = 'outsider'; },
|
||||
state => { state.defender = state.attacker; },
|
||||
state => { state.revision++; },
|
||||
state => { state.event = 'stale-event'; },
|
||||
state => { state.ready = ['a', 'a']; },
|
||||
state => { state.ready = ['outsider']; },
|
||||
state => { state.ready = ['a', 'd']; },
|
||||
state => { state.closed = true; },
|
||||
state => { Object.assign(state, { version: 2 }); },
|
||||
state => { Object.assign(state, { privateCard: 'unrelated' }); },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const state = open(); change(state);
|
||||
assert.throws(() => validateBattleOutcomeWindow(state, identity, 2), BattleOutcomeWindowError);
|
||||
}
|
||||
assert.throws(() => validateBattleOutcomeWindow(open(), {
|
||||
...identity, attacker: identity.defender, defender: identity.attacker,
|
||||
}, 2), /combatants/);
|
||||
for (const invalid of [
|
||||
{ ...identity, event: '' }, { ...identity, attacker: '' }, { ...identity, defender: 'a' },
|
||||
...[0, -1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1].map(turn => ({ ...identity, turn })),
|
||||
{ ...identity, extra: true },
|
||||
]) assert.throws(() => createBattleOutcomeWindow(invalid as BattleOutcomeIdentity, 0), BattleOutcomeWindowError);
|
||||
});
|
||||
|
||||
void test('revisions advance exactly once only while the original final window is open', () => {
|
||||
const state = open();
|
||||
for (const next of [-1, 0, 2, 4, 2.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])
|
||||
assert.throws(() => reviseBattleOutcomeWindow(state, identity, 2, next), BattleOutcomeWindowError);
|
||||
assert.throws(() => reviseBattleOutcomeWindow(state, identity, 1, 2), /revision/);
|
||||
const one = readyBattleOutcomeWindow(state, identity, 2, 'a', state.event);
|
||||
const closed = readyBattleOutcomeWindow(one, identity, 2, 'd', state.event);
|
||||
assert.throws(() => reviseBattleOutcomeWindow(closed, identity, 2, 3), /closed/);
|
||||
for (const invalid of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])
|
||||
assert.throws(() => createBattleOutcomeWindow(identity, invalid), BattleOutcomeWindowError);
|
||||
const last = open(Number.MAX_SAFE_INTEGER);
|
||||
validateBattleOutcomeWindow(last, identity, Number.MAX_SAFE_INTEGER);
|
||||
assert.throws(() => reviseBattleOutcomeWindow(last, identity, Number.MAX_SAFE_INTEGER,
|
||||
Number.MAX_SAFE_INTEGER + 1), /exactly once/);
|
||||
});
|
||||
@@ -54,6 +54,11 @@ const clean = (game: Game) => !game.phaseOpening && !game.response && !game.deci
|
||||
|
||||
/** Consume only live native windows; ordinary setup choices come from real views. */
|
||||
export function nextRicheseStrongholdsNativeStep(game: Game): StrongholdFactionsNativeStep | null {
|
||||
if (clean(game) && game.battle?.outcomeWindow && !game.battle.outcomeWindow.closed) {
|
||||
const window = game.battle.outcomeWindow;
|
||||
const actor = [window.attacker, window.defender].find(id => !window.ready.includes(id));
|
||||
if (actor) return {actor, action: {type: 'battleOutcomeReady', event: window.event}};
|
||||
}
|
||||
if (!game.phaseOpening && !game.response && game.decision) {
|
||||
const decision = game.decision, actor = decision.player;
|
||||
const event = game.richeseBidding?.event;
|
||||
|
||||
@@ -7,6 +7,9 @@ export function saphoAggressorGame(options: Parameters<typeof saphoBattleOrderGa
|
||||
const choice = chooseSaphoBattleAction(game);
|
||||
let opened = applyAction(game,choice.player,choice.action);
|
||||
if(opened.decision?.kind==='fullPlanOffer') opened=applyAction(opened,opened.decision.player,{type:'decision',decline:true});
|
||||
// This historical controlled fixture exercises the unchanged unstamped protocol.
|
||||
// Fresh native late timing is covered by fixture-sapho-late-aggressor instead.
|
||||
delete opened.battle!.saphoTimingVersion;
|
||||
return opened;
|
||||
}
|
||||
export function aggressorAction(game: Game, owner = game.battle!.defender): Action {
|
||||
|
||||
@@ -137,7 +137,21 @@ export function chooseSaphoBattleAction(g: Game): {
|
||||
/** One real owner action, suitable for either applyAction or authenticated room.act. */
|
||||
export function nextSaphoBattleAction(
|
||||
g: Game,
|
||||
keepSapho = false,
|
||||
): { player: string; action: Action } | null {
|
||||
if (keepSapho && g.battle && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening) {
|
||||
const b = g.battle;
|
||||
const window = b.outcomeWindow ?? b.preLeader;
|
||||
if (window && !window.closed) {
|
||||
const player = [b.attacker, b.defender].find(id => !window.ready.includes(id));
|
||||
if (player) return {player, action: {type: b.outcomeWindow ? 'battleOutcomeReady' : 'battlePreparationReady', event: window.event}};
|
||||
}
|
||||
if (b.revealed) {
|
||||
const view = viewGame(g, b.attacker).battle!;
|
||||
const player = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
|
||||
if (player) return {player, action: {type: 'traitorCall', call: false}};
|
||||
}
|
||||
}
|
||||
if (
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
@@ -186,7 +200,7 @@ export function nextSaphoBattleAction(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function resolveSaphoBattle(state: Game): Game {
|
||||
export function resolveSaphoBattle(state: Game, keepSapho = false): Game {
|
||||
let g = state;
|
||||
assert.ok(g.battle);
|
||||
for (let step = 0; step < 120; step++) {
|
||||
@@ -195,7 +209,7 @@ export function resolveSaphoBattle(state: Game): Game {
|
||||
(g.phase !== 6 || viewGame(g, g.players[0].id).battleOrder?.current)
|
||||
)
|
||||
return g;
|
||||
const next = nextSaphoBattleAction(g);
|
||||
const next = nextSaphoBattleAction(g, keepSapho);
|
||||
assert.ok(
|
||||
next,
|
||||
'The selected battle must retain a finite owned continuation.',
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
const sapho = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(sapho);
|
||||
export const LATE_SAPHO = sapho.id;
|
||||
export const LATE_SAPHO_STONE = 'richese-stone-burner';
|
||||
export type SaphoLateStep = { actor: string; action: Action };
|
||||
export const saphoLatePlayer = (game: Game, actor = 'emperor') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const saphoLateClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function actSaphoLate(game: Game, actor: string, action: Action, actions: SaphoLateStep[] = []): Game {
|
||||
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
export function nextSaphoLateStep(game: Game, stone = false): SaphoLateStep | 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 = stone && game.turn === 1 ? LATE_SAPHO_STONE : LATE_SAPHO;
|
||||
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: false } };
|
||||
if (decision.kind === 'stoneBurner') return { actor: decision.player,
|
||||
action: { type: 'decision', event: decision.event, mode: 'ignore' } };
|
||||
}
|
||||
const lot = game.richeseAuction;
|
||||
if (saphoLateClean(game) && lot && !lot.outcome && [LATE_SAPHO, LATE_SAPHO_STONE].includes(lot.cardId) &&
|
||||
lot.active === 'emperor' && lot.bid === 0)
|
||||
return { actor: 'emperor', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceSaphoLate(game: Game, until: (state: Game) => boolean,
|
||||
actions: SaphoLateStep[] = [], stone = false): Game {
|
||||
for (let count = 0; count < 2200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original late Sapho programme ended before its next owned control');
|
||||
const step = nextSaphoLateStep(game, stone); assert.ok(step, 'Choose the actual private battle consequence before continuing');
|
||||
game = actSaphoLate(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original late Sapho programme did not reach its finite boundary');
|
||||
}
|
||||
|
||||
/** Original public host, paid cache Sapho and paid opposing Arrakeen forces.
|
||||
* Stone adds a genuine first-turn paid Stone and next-turn paid Sapho, with
|
||||
* accepted original circles keeping Richese the second-turn physical attacker.
|
||||
* Only unopened Spice positions and scalar lotteries are controlled. */
|
||||
export function createSaphoLateAggressorFixture(options: { advanced?: boolean; stone?: boolean } = {}) {
|
||||
const stone = options.stone ?? false, advanced = options.advanced ?? stone;
|
||||
assert.ok(!stone || advanced, 'The bounded mixed physical Stone programme uses Advanced Sardaukar');
|
||||
let game = createGame('SAPHOLATEAGGRESSOR', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: SaphoLateStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSaphoLate(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSaphoLate(game, until, actions, stone); };
|
||||
if (stone) {
|
||||
act('richese', { type: 'seatPosition', position: 4 });
|
||||
act('emperor', { type: 'seatPosition', position: 5 });
|
||||
}
|
||||
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), front: Game['spiceDeck'] = [];
|
||||
for (let count = 0; count < (stone ? 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);
|
||||
let acquiredStone: Game | null = null;
|
||||
if (stone) {
|
||||
reach(state => saphoLatePlayer(state).hand.some(card => card.id === LATE_SAPHO_STONE));
|
||||
acquiredStone = structuredClone(game);
|
||||
}
|
||||
reach(state => saphoLatePlayer(state).hand.some(card => card.id === LATE_SAPHO));
|
||||
const acquiredSapho = structuredClone(game), targetTurn = stone ? 2 : 1;
|
||||
const shipments: { before: Game; step: SaphoLateStep; after: Game }[] = [], shipped = new Set<string>();
|
||||
for (let count = 0; shipped.size < 2 && count < 600; count++) {
|
||||
if (game.turn === targetTurn && game.phase === 5 && game.active && saphoLateClean(game) &&
|
||||
['richese', 'emperor'].includes(game.active) && !shipped.has(game.active)) {
|
||||
const actor = game.active, before = structuredClone(game), step: SaphoLateStep = { actor, action: {
|
||||
type: 'ship', territory: 'arrakeen', sector: 10, amount: stone && actor === 'emperor' ? 6 : 3,
|
||||
elite: stone && actor === 'emperor' ? 1 : 0, allyPayment: 0,
|
||||
} };
|
||||
act(step.actor, step.action); reach(saphoLateClean);
|
||||
shipments.push({ before, step, after: structuredClone(game) }); shipped.add(actor);
|
||||
} else {
|
||||
const step = nextSaphoLateStep(game, stone); assert.ok(step); act(step.actor, step.action);
|
||||
}
|
||||
}
|
||||
assert.equal(shipped.size, 2);
|
||||
reach(state => state.phase === 6 && !!state.active && saphoLateClean(state));
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
|
||||
.find(choice => choice.territory === 'arrakeen'); assert.ok(choice);
|
||||
const chooseBattle: SaphoLateStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory: 'arrakeen', target: choice.chooser === 'richese' ? 'emperor' : 'richese',
|
||||
} };
|
||||
act(chooseBattle.actor, chooseBattle.action);
|
||||
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && saphoLateClean(state));
|
||||
assert.equal(game.battle!.attacker, 'richese', 'Original paid source/circles keep Richese the physical attacker');
|
||||
assert.equal(game.battle!.defender, 'emperor');
|
||||
const plans: SaphoLateStep[] = ['richese', 'emperor'].map(actor => {
|
||||
const leader = saphoLatePlayer(game, actor).leaders.find(leader => leader.strength === 3 && !leader.dead && !leader.usedAt);
|
||||
assert.ok(leader, 'Use a genuinely dealt faction leader disc');
|
||||
return { actor, action: { type: 'battlePlan', leader: leader.id, dial: stone && actor === 'emperor' ? 3 : 1,
|
||||
support: advanced ? 1 : 0, weapon: stone && actor === 'emperor' ? LATE_SAPHO_STONE : null } };
|
||||
});
|
||||
return { game, lobby, setup, afterSetup, acquiredStone, acquiredSapho, shipments, beforeBattle, chooseBattle, plans,
|
||||
owner: 'emperor', opponent: 'richese', location: 'arrakeen:10', card: LATE_SAPHO, stone, advanced,
|
||||
sourceActionCount: actions.length, actions,
|
||||
staging: ['Original native host/setup, cache1 purchase(s), funded physical/typed shipments and actual chooseBattle create the source. Only unopened land Spice positions/scalar lotteries are selected. The Stone branch uses accepted Richese circle4/Emperor circle5 lobby choices before readiness; no postdeal hand, force, wallet, phase, battle stamp or receipt is installed.'] };
|
||||
}
|
||||
export type SaphoLateAggressorFixture = ReturnType<typeof createSaphoLateAggressorFixture>;
|
||||
export function saphoLateAggressorAction(game: Game, owner = 'emperor'): Action {
|
||||
const option = viewGame(game, owner).saphoOptions.find(option => option.scope === 'battleAggressor');
|
||||
assert.ok(option, 'The genuine owned late aggressor opportunity must be available');
|
||||
return { type: 'card', card: LATE_SAPHO, ...option };
|
||||
}
|
||||
export function saphoLateOutcomeReady(game: Game, actor: string): Action {
|
||||
const window = viewGame(game, actor).battle?.outcomeWindow; assert.ok(window && !window.closed);
|
||||
return { type: 'battleOutcomeReady', event: window.event };
|
||||
}
|
||||
|
||||
/** Leave the original outcome window open. One-plan Sapho keeps the sealed
|
||||
* opposing plan; final-window Sapho may follow its own actual acknowledgement
|
||||
* and must reset both acknowledgement marks without replaying any original vote. */
|
||||
export function prepareSaphoLateOutcome(fixture: SaphoLateAggressorFixture, options: {
|
||||
useAt?: 'none' | 'onePlan' | 'window'; readyBeforeUse?: boolean;
|
||||
} = {}) {
|
||||
const useAt = options.useAt ?? 'none';
|
||||
let game = structuredClone(fixture.game), used: Action | null = null, afterUse: Game | null = null;
|
||||
let beforeUse: Game | null = null, acknowledged: Game | null = null;
|
||||
const actions: SaphoLateStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSaphoLate(game, actor, action, actions); };
|
||||
act(fixture.plans[0].actor, fixture.plans[0].action);
|
||||
const onePlan = structuredClone(game);
|
||||
if (useAt === 'onePlan') {
|
||||
beforeUse = structuredClone(game); used = saphoLateAggressorAction(game, fixture.owner);
|
||||
act(fixture.owner, used); afterUse = structuredClone(game);
|
||||
}
|
||||
act(fixture.plans[1].actor, fixture.plans[1].action);
|
||||
const revealed = structuredClone(game);
|
||||
game = advanceSaphoLate(game, state => !!state.battle?.outcomeWindow && !state.battle.outcomeWindow.closed && saphoLateClean(state), actions, fixture.stone);
|
||||
const opened = structuredClone(game);
|
||||
if (useAt === 'window') {
|
||||
if (options.readyBeforeUse) {
|
||||
act(fixture.owner, saphoLateOutcomeReady(game, fixture.owner)); acknowledged = structuredClone(game);
|
||||
}
|
||||
beforeUse = structuredClone(game); used = saphoLateAggressorAction(game, fixture.owner);
|
||||
act(fixture.owner, used); afterUse = structuredClone(game);
|
||||
}
|
||||
return { game, onePlan, revealed, opened, beforeUse, used, afterUse, acknowledged, useAt, actions };
|
||||
}
|
||||
|
||||
/** Readies and later Stone/typed losses are chosen solely from actual owned
|
||||
* controls. No acknowledgement invents a new plan, weapon choice or vote. */
|
||||
export function completeSaphoLateAggressor(fixture: SaphoLateAggressorFixture,
|
||||
programme: ReturnType<typeof prepareSaphoLateOutcome>, undialed = 2) {
|
||||
let game = structuredClone(programme.game);
|
||||
const actions: SaphoLateStep[] = [], readies: SaphoLateStep[] = [], allocations: {
|
||||
before: Game; step: SaphoLateStep; after: Game;
|
||||
}[] = [];
|
||||
for (let count = 0; count < 700; count++) {
|
||||
if (!game.battle && saphoLateClean(game)) return { game, actions, readies, allocations };
|
||||
const decision = !game.response && !game.phaseOpening ? game.decision : null;
|
||||
const window = game.battle?.outcomeWindow;
|
||||
let step: SaphoLateStep | null;
|
||||
if (window && !window.closed && saphoLateClean(game)) {
|
||||
const actor = [window.attacker, window.defender].find(actor => !window.ready.includes(actor)); assert.ok(actor);
|
||||
step = { actor, action: saphoLateOutcomeReady(game, actor) }; readies.push(structuredClone(step));
|
||||
} else if (decision?.kind === 'stoneAllocation') {
|
||||
const offer = viewGame(game, decision.player).battle!.stoneAllocation; assert.ok(offer && offer.totals.includes(undialed));
|
||||
step = { actor: decision.player, action: { type: 'decision', event: offer.event, undialed } };
|
||||
const before = structuredClone(game); game = actSaphoLate(game, step.actor, step.action, actions);
|
||||
allocations.push({ before, step, after: structuredClone(game) }); continue;
|
||||
} else if (decision?.kind === 'battleLosses') step = { actor: decision.player, action: { type: 'decision', choice: 0 } };
|
||||
else step = nextSaphoLateStep(game, fixture.stone);
|
||||
assert.ok(step, 'Complete the real late Sapho native consequence explicitly');
|
||||
game = actSaphoLate(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original late Sapho outcome did not complete its finite native continuation');
|
||||
}
|
||||
@@ -91,11 +91,20 @@ const play = (g: Game, extra: Partial<Action> = {}) =>
|
||||
...extra,
|
||||
});
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
const finishFinal = (state: Game) => {
|
||||
let g = state;
|
||||
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))!;
|
||||
g = applyAction(g, actor, {type: 'battleOutcomeReady', event: window.event});
|
||||
}
|
||||
return g;
|
||||
};
|
||||
const finish = (g: Game) =>
|
||||
applyAction(applyAction(g, 'q', { type: 'traitorCall', call: false }), 'p', {
|
||||
finishFinal(applyAction(applyAction(g, 'q', { type: 'traitorCall', call: false }), 'p', {
|
||||
type: 'traitorCall',
|
||||
call: false,
|
||||
});
|
||||
}));
|
||||
|
||||
void test('all twelve holders use Portable in normal and late plans in Basic and Advanced with ordinary winner retention', () => {
|
||||
for (const f of FACTIONS)
|
||||
@@ -208,6 +217,7 @@ void test('a losing late defender discards the physical card once while a traito
|
||||
g = play(g);
|
||||
g = applyAction(g, 'q', { type: 'traitorCall', call: true });
|
||||
g = applyAction(g, 'p', { type: 'traitorCall', call: false });
|
||||
g = finishFinal(g);
|
||||
assert.equal(g.players[0].leaders.find((l) => l.id === victim)!.dead, true);
|
||||
assert.equal(g.discard.filter((c) => c.id === card).length, 1);
|
||||
});
|
||||
@@ -57,13 +57,16 @@ void test('an unchosen allied Prescience prompt allows early Sapho but an actual
|
||||
assert.equal(inspecting.battle!.preLeader!.closed,false);
|
||||
assert.deepEqual(viewGame(inspecting,'b').saphoOptions,[]);reject(inspecting,'b',action);
|
||||
});
|
||||
void test('early priority feeds Stone pre-commit finishability before any hidden opposing plan exists',()=>{
|
||||
void test('legacy early priority feeds Stone pre-commit finishability before any hidden opposing plan exists',()=>{
|
||||
const initial=saphoAggressorGame({advanced:true,holder:'a'},g=>{
|
||||
g.order=['b','a','c','r'];g.active='b';
|
||||
const a=g.players[0],b=g.players[1];a.forces={'carthag:11':2};a.reserves=18;
|
||||
a.elites!.forces={'carthag:11':1};a.elites!.reserves=4;
|
||||
b.forces={'carthag:11':1};b.reserves=19;takeSaphoBattleCard(g,'b','richese-stone-burner');
|
||||
});
|
||||
// Explicit historical unstamped Stone protocol; fresh physical allocation is
|
||||
// covered by the original paid Stone/Sapho programmes.
|
||||
delete initial.battle!.stoneAllocationVersion;
|
||||
assert.equal(initial.battle!.attacker,'b');
|
||||
const plan={type:'battlePlan',dial:0,support:0,leader:initial.players[1].leaders[0].id,weapon:'richese-stone-burner'};
|
||||
// Original Guild tie priority makes every opposing allocation finishable.
|
||||
|
||||
@@ -49,7 +49,7 @@ void test('held Sapho exposes the legal battle-order action with its actual timi
|
||||
assert.match(html, new RegExp(`Choose battles ${mode} and`));
|
||||
assert.match(html, /Other players may still choose battles against you/);
|
||||
assert.match(html, /not the aggressor or tie advantage/);
|
||||
assert.match(html, /Later aggressor intervention[\s\S]*unfinished/);
|
||||
assert.match(html, /Late aggressor timing[\s\S]*provisional shared final opportunity/);
|
||||
assert.match(panel(view, true), /<button[^>]*disabled=""[^>]*>Choose battles/);
|
||||
const observer = game.players.find(player => player.id !== holder)!;
|
||||
assert.equal(panel(viewGame(game, observer.id)), '');
|
||||
|
||||
@@ -247,7 +247,7 @@ void test('a clean later boundary accepts remaining battles without reopening th
|
||||
assert.deepEqual(g.battle, current);
|
||||
// This human saves Sapho for the next boundary instead of taking the new AI aggressor option.
|
||||
g = applyAction(g, 'b', {type:'battlePreparationReady', event:g.battle!.event});
|
||||
g = resolveSaphoBattle(g);
|
||||
g = resolveSaphoBattle(g, true);
|
||||
assert.ok(g.players[1].hand.some((c) => c.id === SAPHO_BATTLE_CARD));
|
||||
reject(g, 'b', expired);
|
||||
const completed = structuredClone(g.lastBattleContext);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { canUsePlanetologistBattleSpecial } from '../game/leader-skill-combat';
|
||||
import {
|
||||
LATE_SAPHO_STONE, actSaphoLate, completeSaphoLateAggressor, createSaphoLateAggressorFixture,
|
||||
prepareSaphoLateOutcome, saphoLateAggressorAction, saphoLateOutcomeReady,
|
||||
saphoLatePlayer as player, type SaphoLateAggressorFixture,
|
||||
} from './fixture-sapho-late-aggressor';
|
||||
|
||||
function source(f: SaphoLateAggressorFixture) {
|
||||
assert.equal(f.game.battle!.saphoTimingVersion, 1);
|
||||
assert.equal(f.game.battle!.stoneAllocationVersion, 1);
|
||||
assert.equal(f.game.battle!.attacker, f.opponent);
|
||||
assert.equal(f.game.battle!.defender, f.owner);
|
||||
assert.ok(player(f.acquiredSapho).hand.some(card => card.id === f.card));
|
||||
assert.equal(f.actions.filter(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1).length, f.stone ? 2 : 1);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
for (const shipment of f.shipments) {
|
||||
const { actor, action } = shipment.step;
|
||||
assert.equal(player(shipment.before, actor).spice - player(shipment.after, actor).spice, action.amount);
|
||||
assert.equal(player(shipment.before, actor).reserves - player(shipment.after, actor).reserves, action.amount);
|
||||
}
|
||||
assert.equal(f.game.battle!.plans[f.opponent], undefined);
|
||||
assert.equal(f.game.battle!.plans[f.owner], undefined);
|
||||
}
|
||||
|
||||
void test('genuine Basic and Advanced late Sapho changes an ordinary revealed tie only after both original outcome acknowledgements', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createSaphoLateAggressorFixture({ advanced });
|
||||
source(f);
|
||||
const baseline = prepareSaphoLateOutcome(f);
|
||||
assert.equal(baseline.game.battle!.outcomeWindow!.revision, 0);
|
||||
assert.deepEqual(baseline.game.battle!.outcomeWindow!.ready, []);
|
||||
assert.equal(player(baseline.game).tanks, player(f.game).tanks);
|
||||
const unchanged = completeSaphoLateAggressor(f, baseline);
|
||||
assert.equal(unchanged.game.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(unchanged.readies.length, 2);
|
||||
const late = prepareSaphoLateOutcome(f, { useAt: 'window', readyBeforeUse: true });
|
||||
assert.deepEqual(late.acknowledged!.battle!.outcomeWindow!.ready, [f.owner]);
|
||||
assert.notEqual(late.afterUse!.battle!.outcomeWindow!.event, late.beforeUse!.battle!.outcomeWindow!.event);
|
||||
assert.equal(late.afterUse!.battle!.outcomeWindow!.revision, 1);
|
||||
assert.deepEqual(late.afterUse!.battle!.outcomeWindow!.ready, []);
|
||||
assert.equal(late.afterUse!.battle!.outcomeWindow!.closed, false);
|
||||
assert.deepEqual(late.afterUse!.battle!.plans, late.beforeUse!.battle!.plans);
|
||||
assert.deepEqual(late.afterUse!.battle!.traitorCalls, late.beforeUse!.battle!.traitorCalls);
|
||||
assert.equal(late.afterUse!.battle!.attacker, f.opponent);
|
||||
assert.equal(late.afterUse!.battle!.defender, f.owner);
|
||||
assert.equal(late.afterUse!.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(late.afterUse!).tanks, player(f.game).tanks);
|
||||
const stale = saphoLateOutcomeReady(late.beforeUse!, f.opponent);
|
||||
assert.throws(() => actSaphoLate(late.afterUse!, f.opponent, stale), /event|stale|window|opportunity/i);
|
||||
const changed = completeSaphoLateAggressor(f, late);
|
||||
assert.equal(changed.readies.length, 2, 'Both participants must acknowledge the revised public opportunity');
|
||||
assert.equal(changed.game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(changed.game).forces[f.location], 2);
|
||||
assert.equal(player(changed.game, f.opponent).forces[f.location] ?? 0, 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('genuine Sapho after one opposing sealed plan preserves that plan and still opens the fixed final window without a held Sapho', () => {
|
||||
const f = createSaphoLateAggressorFixture();
|
||||
const programme = prepareSaphoLateOutcome(f, { useAt: 'onePlan' });
|
||||
assert.equal(programme.onePlan.battle!.revealed, false);
|
||||
assert.ok(programme.onePlan.battle!.plans[f.opponent]);
|
||||
assert.equal(programme.onePlan.battle!.plans[f.owner], undefined);
|
||||
assert.deepEqual(programme.afterUse!.battle!.plans, programme.beforeUse!.battle!.plans);
|
||||
assert.equal(player(programme.opened).hand.some(card => card.id === f.card), false);
|
||||
assert.equal(programme.opened.battle!.outcomeWindow!.closed, false);
|
||||
assert.deepEqual(programme.opened.battle!.outcomeWindow!.ready, []);
|
||||
assert.deepEqual(Object.keys(programme.opened.battle!.traitorCalls).sort(), [f.opponent, f.owner].sort());
|
||||
const completed = completeSaphoLateAggressor(f, programme);
|
||||
assert.equal(completed.game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(completed.readies.length, 2);
|
||||
assert.equal(completed.game.discard.filter(card => card.id === f.card).length, 1);
|
||||
});
|
||||
|
||||
void test('actual paid Stone then next-turn paid Sapho settles the mixed physical tie through the original final window and count menu', () => {
|
||||
const f = createSaphoLateAggressorFixture({ stone: true });
|
||||
source(f);
|
||||
assert.equal(f.acquiredStone!.turn, 1);
|
||||
assert.equal(f.acquiredSapho.turn, 2);
|
||||
assert.ok(player(f.acquiredStone!).hand.some(card => card.id === LATE_SAPHO_STONE));
|
||||
assert.equal(player(f.game).forces[f.location], 6);
|
||||
assert.equal(player(f.game).elites!.forces[f.location], 1);
|
||||
assert.ok(f.actions.some(step => step.actor === f.opponent && step.action.type === 'seatPosition' && step.action.position === 4));
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'seatPosition' && step.action.position === 5));
|
||||
const baseline = prepareSaphoLateOutcome(f), late = prepareSaphoLateOutcome(f, { useAt: 'window' });
|
||||
assert.equal(baseline.game.decision, null);
|
||||
assert.equal(late.game.decision, null);
|
||||
assert.equal(viewGame(late.game, f.owner).battle!.stoneAllocation, null, 'No outcome-sensitive physical menu precedes the final acknowledgements');
|
||||
assert.deepEqual(late.afterUse!.battle!.stoneBurner, late.beforeUse!.battle!.stoneBurner);
|
||||
assert.deepEqual(late.afterUse!.battle!.traitorCalls, late.beforeUse!.battle!.traitorCalls);
|
||||
let closed = baseline.game;
|
||||
for (const actor of [f.opponent, f.owner]) closed = actSaphoLate(closed, actor, saphoLateOutcomeReady(closed, actor));
|
||||
assert.equal(closed.battle!.outcomeWindow!.closed, true);
|
||||
assert.equal(closed.decision?.kind, 'stoneAllocation');
|
||||
assert.deepEqual(viewGame(closed, f.owner).battle!.stoneAllocation!.totals, [1, 2, 3]);
|
||||
assert.ok(player(closed).hand.some(card => card.id === f.card));
|
||||
assert.equal(viewGame(closed, f.owner).saphoOptions.some(option => option.scope === 'battleAggressor'), false,
|
||||
'The count context binds the final aggressor after the public opportunity closes');
|
||||
assert.throws(() => actSaphoLate(closed, f.owner, saphoLateAggressorAction(baseline.game)), /Sapho|opportunity|closed|expired/i);
|
||||
const before = completeSaphoLateAggressor(f, { ...baseline, game: closed }, 2);
|
||||
const after = completeSaphoLateAggressor(f, late, 2);
|
||||
assert.equal(before.game.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(after.game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(before.allocations[0].step.action.undialed, 2);
|
||||
assert.equal(after.allocations[0].step.action.undialed, 2);
|
||||
assert.equal(player(after.game).forces[f.location], 2);
|
||||
assert.equal((player(after.game).elites!.forces[f.location] ?? 0) + player(after.game).elites!.tanks, 1);
|
||||
assert.equal(after.game.discard.filter(card => card.id === f.card).length, 1);
|
||||
});
|
||||
|
||||
void test('all four minimal policies make legal Sapho-or-continue choices at the original revealed outcome window', () => {
|
||||
const f = createSaphoLateAggressorFixture(), programme = prepareSaphoLateOutcome(f);
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
for (const actor of [f.owner, f.opponent]) {
|
||||
const view = viewGame(programme.game, actor);
|
||||
view.players.find(player => player.id === actor)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'battleOutcomeReady' ||
|
||||
(action.type === 'card' && action.card === f.card && action.scope === 'battleAggressor'));
|
||||
assert.ok(action, `${difficulty} can use its actual aggressor option or acknowledge the fixed window`);
|
||||
const next = actSaphoLate(structuredClone(programme.game), actor, action);
|
||||
const completed = completeSaphoLateAggressor(f, { ...programme, game: next });
|
||||
assert.ok([f.owner, f.opponent].includes(completed.game.lastBattleContext!.winner!));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('controlled own plan and inspection reservations keep the physical Sapho unavailable for late spending', () => {
|
||||
const f = createSaphoLateAggressorFixture();
|
||||
const held = player(f.game).hand.find(card => card.id === f.card)!;
|
||||
const leader = String(f.plans[1].action.leader);
|
||||
assert.equal(canUsePlanetologistBattleSpecial({card: held, selectedLeader: leader,
|
||||
assignments: [{skill: 'planetologist', leader, faceUp: false, captured: false}]}), true);
|
||||
const action = saphoLateAggressorAction(f.game);
|
||||
// Controlled reservation probes only; these do not claim a full original
|
||||
// Planetologist/Sapho setup or an actual native Prescience disclosure.
|
||||
for (const reservation of ['plan', 'inspection']) {
|
||||
const game = structuredClone(f.game);
|
||||
if (reservation === 'plan') game.battle!.plans[f.owner] = {
|
||||
leader, dial: 1, support: 0, weapon: f.card, defense: null,
|
||||
};
|
||||
else game.battle!.prescience = {player: f.opponent, field: 'weapon', value: f.card};
|
||||
const before = structuredClone(game);
|
||||
assert.equal(viewGame(game, f.owner).saphoOptions.some(option => option.scope === 'battleAggressor'), false);
|
||||
assert.throws(() => actSaphoLate(game, f.owner, action), /Sapho|unavailable|committed/i);
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
});
|
||||
@@ -188,7 +188,7 @@ for (const advanced of [false, true]) for (const combo of combos) {
|
||||
assert.throws(() => applyAction(result.game, f.choam, { type: 'endMovement' }));
|
||||
});
|
||||
|
||||
void test(`${label}: only legacy, stale or readiness-closed Sapho controls survive untouched`, () => {
|
||||
void test(`${label}: stale and spent controls reject while fresh readiness preserves late timing`, () => {
|
||||
const f = createSemutaHomeworldBattleSaphoFixture(combo.scope, combo.mode, advanced);
|
||||
const scope = f.scope === 'aggressor' ? 'battleAggressor' : 'battleOrder';
|
||||
assert.equal(viewGame(f.game, f.owner).saphoOptions.find(option => option.scope === scope)?.mode,
|
||||
@@ -200,10 +200,17 @@ for (const advanced of [false, true]) for (const combo of combos) {
|
||||
const wrong = f.scope === 'aggressor' ? 'last' : f.mode === 'first' ? 'last' : 'first';
|
||||
assert.throws(() => applyAction(f.game, f.owner, { ...f.action, mode: wrong }),
|
||||
'Only the one legal position at this boundary is accepted');
|
||||
if (f.scope === 'aggressor')
|
||||
assert.deepEqual(viewGame(applyAction(f.game, f.owner,
|
||||
if (f.scope === 'aggressor') {
|
||||
assert.ok(viewGame(applyAction(f.game, f.owner,
|
||||
{ type: 'battlePreparationReady', event: f.game.battle!.event }), f.owner).saphoOptions
|
||||
.some(option => option.scope === 'battleAggressor'),
|
||||
'Fresh battles retain late intervention after pre-plan readiness');
|
||||
const legacy = structuredClone(f.game);
|
||||
delete legacy.battle!.saphoTimingVersion;
|
||||
assert.deepEqual(viewGame(applyAction(legacy, f.owner,
|
||||
{ type: 'battlePreparationReady', event: f.game.battle!.event }), f.owner).saphoOptions, [],
|
||||
'The owner\'s own readiness closes the early pre-plan aggressor window');
|
||||
'Unstamped battles retain readiness-closed early timing');
|
||||
}
|
||||
const other = f.game.players.find(seat => ![f.owner, f.holder].includes(seat.id))!.id;
|
||||
assert.throws(() => applyAction(f.game, other, f.action));
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user