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:
Quintine committed 2026-10-18 13:46:00 +11:00
1 parent cb13a4d674
commit 84200ebc74
24 files changed
+1023 -40

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
export type BattleOutcomeIdentity = {
event: string;
attacker: string;
defender: string;
turn: number;
};
export type BattleOutcomeWindow = {
version: 1;
event: string;
battle: string;
turn: number;
attacker: string;
defender: string;
revision: number;
ready: string[];
closed: boolean;
};
export class BattleOutcomeWindowError extends Error {
constructor(message: string) {
super(message);
this.name = 'BattleOutcomeWindowError';
}
}
function requireWindow(value: unknown, message: string): asserts value {
if (!value) throw new BattleOutcomeWindowError(message);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const count = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const exact = (value: unknown, keys: string): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
Object.keys(value).sort().join(',') === keys;
function validateIdentity(identity: BattleOutcomeIdentity): void {
requireWindow(
exact(identity, 'attacker,defender,event,turn') && identifier(identity.event) &&
identifier(identity.attacker) && identifier(identity.defender) &&
identity.attacker !== identity.defender && count(identity.turn) && identity.turn > 0,
'The final battle window needs its original battle, turn and distinct combatants.',
);
}
const windowEvent = (identity: BattleOutcomeIdentity, revision: number): string =>
`battleOutcome:${JSON.stringify([identity.event, identity.turn, revision])}`;
/** Public identities and acknowledgements only; no outcome or held-card query. */
export function createBattleOutcomeWindow(
identity: BattleOutcomeIdentity,
revision: number,
): BattleOutcomeWindow {
validateIdentity(identity);
requireWindow(count(revision), 'The final battle window needs a safe nonnegative revision.');
return {
version: 1, event: windowEvent(identity, revision), battle: identity.event,
turn: identity.turn, attacker: identity.attacker, defender: identity.defender,
revision, ready: [], closed: false,
};
}
export function validateBattleOutcomeWindow(
state: BattleOutcomeWindow,
identity: BattleOutcomeIdentity,
revision: number,
): void {
validateIdentity(identity);
requireWindow(
count(revision) &&
exact(state, 'attacker,battle,closed,defender,event,ready,revision,turn,version') &&
state.version === 1 && state.battle === identity.event && state.turn === identity.turn &&
state.attacker === identity.attacker && state.defender === identity.defender &&
state.revision === revision && state.event === windowEvent(identity, revision),
'The final battle window has changed its original battle, combatants or revision.',
);
requireWindow(
Array.isArray(state.ready) &&
state.ready.every(player => player === identity.attacker || player === identity.defender) &&
new Set(state.ready).size === state.ready.length &&
typeof state.closed === 'boolean' && state.closed === (state.ready.length === 2),
'The final battle window needs distinct combatant acknowledgements and a matching closed status.',
);
}
export function readyBattleOutcomeWindow(
state: BattleOutcomeWindow,
identity: BattleOutcomeIdentity,
revision: number,
player: string,
event: string,
): BattleOutcomeWindow {
validateBattleOutcomeWindow(state, identity, revision);
requireWindow(!state.closed, 'The final battle window is already closed.');
requireWindow(event === state.event, 'This final battle window event has expired.');
requireWindow(
player === identity.attacker || player === identity.defender,
'Only a combatant can finish final battle actions.',
);
requireWindow(!state.ready.includes(player), 'This combatant already finished final battle actions.');
const ready = [...state.ready, player];
return { ...state, ready, closed: ready.length === 2 };
}
/** A public Sapho use creates a fresh shared opportunity before resolution. */
export function reviseBattleOutcomeWindow(
state: BattleOutcomeWindow,
identity: BattleOutcomeIdentity,
previousRevision: number,
nextRevision: number,
): BattleOutcomeWindow {
validateBattleOutcomeWindow(state, identity, previousRevision);
requireWindow(!state.closed, 'The final battle window is already closed.');
requireWindow(
count(nextRevision) && nextRevision === previousRevision + 1,
'A final battle window revision must advance exactly once.',
);
return createBattleOutcomeWindow(identity, nextRevision);
}
+4
View File
@@ -3786,6 +3786,10 @@ function policyActions(g: GameView): Action[] {
}
if (g.phase === 6) {
const b = g.battle;
if (b?.outcomeWindow && !b.outcomeWindow.closed) {
if (![b.attacker, b.defender].includes(me.id) || b.outcomeWindow.ready.includes(me.id)) return [];
return [{type: 'battleOutcomeReady', event: b.outcomeWindow.event}];
}
const poison = g.residualPoison;
const opponent =
b?.attacker === me.id
+81 -15
View File
@@ -95,6 +95,7 @@ import { quoteHomeworldSubstitution } from './homeworld-substitution';
import { combatArmy, combatLocations, combatLocationName, homeworldBattleLocation, quoteCombatBoard, quoteCombatBoardContinuation } from './combat-location';
import { battleChooserEvent, quoteBattleChoosers, reorderBattleChoosers, validateBattleChooserOrder, BattleChooserOrderError, type BattleChooserOrder } from './battle-chooser-order';
import { battleTieOwner, changeSaphoAggressor, saphoAggressorEvent, validateSaphoAggressor, SaphoAggressorError, type SaphoAggressorState } from './sapho-aggressor';
import { createBattleOutcomeWindow, validateBattleOutcomeWindow, readyBattleOutcomeWindow, reviseBattleOutcomeWindow, BattleOutcomeWindowError, type BattleOutcomeWindow } from './battle-outcome-window';
import { quoteHomeworldCombatLoss } from './homeworld-combat-loss';
import { quoteHomeworldBattleRules } from './homeworld-battle-rules';
import type { HomeworldForces } from './homeworld-custody';
@@ -746,6 +747,9 @@ export type Battle = {
chooserEvent?: string;
saphoAggressor?: SaphoAggressorState;
saphoAggressorEvents?: string[];
/** Fresh Richese-roster battles allow late priority before physical settlement. */
saphoTimingVersion?: 1;
outcomeWindow?: BattleOutcomeWindow;
/** Only newly created supported battles enter the pre-plan Mentat question. */
mentatQuestionVersion?: 1;
mentatQuestionEvent?: string;
@@ -5494,6 +5498,11 @@ function saphoAggressorDiscardSignature(g: Game, c: SaphoAggressorDiscardContinu
voice: battle.voice, prescience: battle.prescience,
fullPlan: battle.fullPlan, nexusInspection: battle.nexusInspection,
truthPromises: battle.truthPromises,
...(battle.saphoTimingVersion === 1 ? {
saphoTimingVersion: battle.saphoTimingVersion, outcomeWindow: battle.outcomeWindow,
traitorCalls: battle.traitorCalls, poisonTooth: battle.poisonTooth,
stoneBurner: battle.stoneBurner, lateDefense: battle.lateDefense,
} : {}),
},
players: g.players.map(p => ({ id: p.id, faction: p.faction, ally: p.ally })),
});
@@ -6504,10 +6513,8 @@ function treacheryDiscardIntegrity(g: Game) {
const c = continuation, entry = batch.entries[0], battle = g.battle;
requireRule(
g.phase === 6 && battle && battle.event === c.battleEvent &&
battle.preLeader?.event === c.battleEvent && !battle.preLeader.closed &&
!battle.preLeader.ready.includes(c.player) &&
battle.preLeader?.event === c.battleEvent && saphoAggressorTimingOpen(g, c.player) &&
[battle.attacker, battle.defender].includes(c.player) &&
!battle.revealed && Object.keys(battle.plans).length === 0 &&
battle.saphoAggressor?.uses.at(-1)?.event === c.saphoEvent &&
battle.saphoAggressor.uses.at(-1)?.player === c.player &&
battle.saphoAggressorEvents?.includes(c.saphoEvent) &&
@@ -6518,7 +6525,7 @@ function treacheryDiscardIntegrity(g: Game) {
!c.resume.response && !c.resume.decision &&
!c.resume.pendingKarama && !c.resume.phaseOpening &&
c.stateSignature === saphoAggressorDiscardSignature(g, c),
'The used Juice of Sapho no longer matches its pre-plan aggressor choice.',
'The used Juice of Sapho no longer matches its unresolved aggressor choice.',
);
saphoAggressorIntegrity(g);
} else if (continuation?.kind === 'residualPoisonDiscard') {
@@ -9934,7 +9941,7 @@ function decideHarkonnenNexusBetrayal(g: Game,id: string,action: Action): void {
g.nexusHarkonnenBetrayalCursor = closed.cursor;
g.pendingNexusHarkonnenBetrayal = null;
if (traitorVoters(g,g.battle!).every(voter => g.battle!.traitorCalls[voter] !== undefined))
advanceBattleOutcome(g);
finishBattleVotes(g);
}
function drawHarkonnenNexusBetrayalReplacement(g: Game): void {
const due = g.pendingNexusHarkonnenReplacement;
@@ -13051,10 +13058,36 @@ function battleAggressor(g: Game) {
const b = g.battle!;
return saphoAggressorRule(() => validateSaphoAggressor(b.saphoAggressor, b.saphoAggressorEvents, b, g.turn));
}
function battleOutcomeRule<T>(calculate: () => T): T {
try { return calculate(); }
catch (error) { if (error instanceof BattleOutcomeWindowError) throw new RuleError(error.message); throw error; }
}
function battleOutcomeIdentity(g: Game) {
const b = g.battle!;
requireRule(b.event, 'The final battle opportunity needs its original battle event.');
return {event: b.event, attacker: b.attacker, defender: b.defender, turn: g.turn};
}
/** Stage only. Never infer a public opportunity from held cards or an outcome. */
function saphoAggressorTimingOpen(g: Game, player: string) {
const b = g.battle;
if (!b?.event || b.preLeader?.event !== b.event) return false;
if (b.saphoTimingVersion === 1)
return !b.outcomeWindow?.closed && !g.pendingAuditor && !g.pendingCapture &&
!g.pendingTech && !g.pendingFaceDance && !g.pendingChoamBattleIncome &&
!g.pendingWinnerDiscards && !g.moritaniRetention && !g.pendingIxSubstitution;
return !b.preLeader.closed && !b.preLeader.ready.includes(player) &&
!b.revealed && Object.keys(b.plans).length === 0 &&
!b.voice && !b.prescience && !b.fullPlan && !b.nexusInspection && !b.truthPromises?.length;
}
function saphoAggressorIntegrity(g: Game) {
const b = g.battle;
if (!b) return;
battleAggressor(g);
if (b.outcomeWindow) {
requireRule(b.saphoTimingVersion === 1 && b.revealed && g.status === 'playing' && g.phase === 6,
'The final battle opportunity needs its original revealed battle.');
battleOutcomeRule(() => validateBattleOutcomeWindow(b.outcomeWindow!, battleOutcomeIdentity(g), b.saphoAggressor?.uses.length ?? 0));
}
if (!b.saphoAggressor) return;
requireRule(g.status === 'playing' && g.phase === 6 && b.preLeader?.event === b.event &&
g.players.some(p => p.id === b.attacker) && g.players.some(p => p.id === b.defender),
@@ -19494,7 +19527,9 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
!richeseCardDefinition(card) ||
p.hand.filter((c) => c.id === card.id).length !== 1 ||
transferCardBlock(g, p, card) ||
giftReserved(g, p.id, card.id)
giftReserved(g, p.id, card.id) ||
(g.battle?.plans[p.id] && [g.battle.plans[p.id].weapon, g.battle.plans[p.id].defense, g.battle.plans[p.id].leader].includes(card.id)) ||
(g.battle && committedPlanElements(g.battle, p.id).some(element => element.value === card.id))
)
return [];
const options: SaphoOption[] = [];
@@ -19508,10 +19543,7 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
}
}
const battle = g.battle;
if (g.phase === 6 && battle?.event && battle.preLeader?.event === battle.event &&
!battle.preLeader.closed && !battle.preLeader.ready.includes(p.id) &&
!battle.revealed && Object.keys(battle.plans).length === 0 &&
!battle.voice && !battle.prescience && !battle.fullPlan && !battle.nexusInspection && !battle.truthPromises?.length &&
if (g.phase === 6 && battle?.event && saphoAggressorTimingOpen(g, p.id) &&
!g.pendingTreacheryDiscard && [battle.attacker, battle.defender].includes(p.id) &&
battleAggressor(g) !== p.id)
options.push({scope: 'battleAggressor',
@@ -19583,7 +19615,9 @@ function playSapho(g: Game, p: Player, action: Action) {
const used = discard(g, p, 'richese-juice-of-sapho');
b.saphoAggressor = next;
(b.saphoAggressorEvents ??= []).push(option.event);
log(g, `${p.name} discarded Juice of Sapho to become this battle’s aggressor before plans. They now win ordinary ties; a Habbanya Stronghold advantage still takes precedence. Battle participants and later battle choices stay unchanged.`,
if (b.outcomeWindow)
b.outcomeWindow = battleOutcomeRule(() => reviseBattleOutcomeWindow(b.outcomeWindow!, battleOutcomeIdentity(g), next.uses.length - 1, next.uses.length));
log(g, `${p.name} discarded Juice of Sapho to become this battle’s aggressor. They now win ordinary ties; a Habbanya Stronghold advantage still takes precedence. Battle participants, committed plans and later battle choices stay unchanged.${b.outcomeWindow ? ' Both players must finish the revised final battle opportunity.' : ''}`,
{faction: p.faction, name: 'Juice of Sapho'});
if (g.semutaPreview) {
const continuation: SaphoAggressorDiscardContinuation = {
@@ -23371,6 +23405,7 @@ function nextRevealedDecision(g: Game) {
function homeworldRevealPending(g: Game): boolean {
const b = g.battle;
if (g.status !== 'playing' || g.phase !== 6 || !b?.revealed || !b.territory.startsWith('homeworld:') || g.decision || g.response ||
b.outcomeWindow ||
g.truthtrance || g.phaseOpening || g.pendingNullentropy || g.pendingTreacheryDiscard ||
g.pendingRicheseGift || g.pendingExchange || g.pendingKarama || g.summonedWorm) return false;
return traitorVoters(g, b).every((id) => b.traitorCalls[id] !== undefined);
@@ -23387,6 +23422,11 @@ function advanceHomeworldReveal(g: Game): boolean {
} else advanceBattleOutcome(g);
return true;
}
/** All final vote/response exits retain native Homeworld late-defense ordering. */
function finishBattleVotes(g: Game) {
if (g.battle!.territory.startsWith('homeworld:')) advanceHomeworldReveal(g);
else advanceBattleOutcome(g);
}
function traitorVoters(g: Game, b: Battle) {
const home = homeworldBattleLocation(g, b.territory);
if (home) return [b.attacker, b.defender].filter((id) => id === home.native);
@@ -23746,6 +23786,15 @@ function offerStoneAllocation(g: Game): boolean {
}
function advanceBattleOutcome(g: Game) {
const b = g.battle!;
if (b.saphoTimingVersion === 1) {
if (!b.outcomeWindow) {
b.outcomeWindow = battleOutcomeRule(() => createBattleOutcomeWindow(battleOutcomeIdentity(g), b.saphoAggressor?.uses.length ?? 0));
log(g, 'Both combatants may finish their final battle actions before the outcome and physical losses are applied. This public opportunity does not depend on held cards.',
{faction: getPlayer(g, b.attacker).faction, name: 'Final battle actions'});
}
battleOutcomeRule(() => validateBattleOutcomeWindow(b.outcomeWindow!, battleOutcomeIdentity(g), b.saphoAggressor?.uses.length ?? 0));
if (!b.outcomeWindow.closed) return;
}
if (offerStoneAllocation(g)) return;
const quote = currentBattleResolutionQuote(g);
if (quote.smugglerAllocation || quote.sandmasterSelection) {
@@ -23821,6 +23870,8 @@ function jacurutuDialIntegrity(g: Game) {
}
function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
const b = g.battle!;
requireRule(b.saphoTimingVersion !== 1 || b.outcomeWindow?.closed,
'Both combatants must finish final battle actions before resolving losses.');
requireRule(!!b.diplomatRetreat === !!retreat, 'Resolve the pending Diplomat retreat before battle losses.');
// Quote before any withdrawal: fixedLosses retains the original ceil(E/2)
// physical commitment, never one recalculated from Ecaz's diminished board.
@@ -27006,7 +27057,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
if (
traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined)
)
advanceBattleOutcome(g);
finishBattleVotes(g);
} else if (response.kind === 'richesePurchaseIncome') {
const pending = g.pendingRichesePurchaseIncome;
requireRule(
@@ -31107,6 +31158,20 @@ function applyActionInner(
);
return g;
}
if (t === 'battleOutcomeReady') {
const b = g.battle;
requireRule(Object.keys(action).sort().join(',') === 'event,type' &&
g.phase === 6 && b?.saphoTimingVersion === 1 && b.revealed && b.outcomeWindow &&
traitorVoters(g, b).every(player => b.traitorCalls[player] !== undefined),
'No shared final battle opportunity is waiting for you.');
requireRule(saphoCleanWindow(g) && !g.pendingTreacheryDiscard,
'Finish the current interaction before finishing final battle actions.');
b.outcomeWindow = battleOutcomeRule(() => readyBattleOutcomeWindow(b.outcomeWindow!, battleOutcomeIdentity(g),
b.saphoAggressor?.uses.length ?? 0, id, stringField(action.event)));
log(g, `${p.name} finished final battle actions${b.outcomeWindow.closed ? '; both combatants are ready for the outcome' : ''}.`);
if (b.outcomeWindow.closed) advanceBattleOutcome(g);
return g;
}
if (
t === 'card' &&
p.hand.some((c) => c.id === action.card && c.effect === 'residualPoison')
@@ -34479,7 +34544,7 @@ function applyActionInner(
...(g.leaderSkills && ordinaryLeaderSkillModeSupported(g) ? { diplomatRetreatVersion: 1 as const } : {}),
...(g.ecazTreachery ? { harassAllocationVersion: 1 as const } : {}),
...(byFaction(g, 'richese')
? { preLeader: { event: battleEvent, ready: [], closed: false } }
? { saphoTimingVersion: 1 as const, preLeader: { event: battleEvent, ready: [], closed: false } }
: {}),
...(noFieldPlayers.length ? { noFieldPlayers } : {}),
...(mixedNoFieldPlayers ? { mixedNoFieldPlayers } : {}),
@@ -34705,8 +34770,7 @@ function applyActionInner(
if (
traitorVoters(g, b).every((voter) => b.traitorCalls[voter] !== undefined)
)
if (!b.territory.startsWith('homeworld:')) advanceBattleOutcome(g);
else advanceHomeworldReveal(g);
finishBattleVotes(g);
return g;
}
if (t === 'card') {
@@ -36082,6 +36146,8 @@ export function viewGame(state: Game, id: string) {
: null,
event: b.event ?? null,
preLeader: b.preLeader ?? null,
saphoTimingVersion: b.saphoTimingVersion ?? null,
outcomeWindow: b.outcomeWindow ? structuredClone(b.outcomeWindow) : null,
territory: b.territory,
attacker: b.attacker,
aggressor: battleAggressor(g),
+14 -7
View File
@@ -491,18 +491,18 @@ export const RULE_TOPICS: RuleTopic[] = [
coverage: 'Partial',
developmentStage: 'Prototyped',
summary:
'Choose an available order change or become battle aggressor before plans, then discard Sapho. Later intervention and other timing modes remain unfinished.',
'Choose an available order change or become aggressor in an unresolved battle, then discard Sapho. Fresh Richese battles include a provisional shared final opportunity; other timing modes remain unfinished.',
steps: [
'In a current Once Around auction, first or last can reorder remaining bidders while you have not yet bid. Earlier bids stay committed; a position you already hold is unavailable. You still get only one bidding opportunity and must outbid the current high bid to win.',
'In ordinary cyclic bidding or native Advanced normal Black Market, provisional first/last changes the current lot only, anchored at the live next bidder. Later circuits use that local cycle. Keep standing bids, allied funding and passes; a passed holder waits for a real raise, and the high bidder has no pending bid to reorder. Every purchase still requires a strict raise. Full effective-cycle no-ops cannot consume the card. The next lot uses its normal physical opener.',
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. Once Around completed opportunities cannot repeat. A cyclic pass stays spent until a genuine raise reopens it; a finished lot cannot reopen.',
'The movement controls change complete shipment and movement turns at a boundary before the current player begins. First moves you ahead of the remaining unstarted turns, including after earlier players have fully finished. In Advanced, the Guild must have finished or be absent. Last stays after the Guild even if the Guild later chooses to wait.',
'Between completed battles, choose first or last among remaining battle choosers. The priority lasts for the rest of the Battle phase. You still need a real unresolved battle. Others may choose battles against you before your own turn; last is not immunity. This scope changes the chooser, not each battle’s aggressor or tie advantage.',
'During your open pre-plan preparation, use Sapho to become the current battle’s aggressor before declaring ready. You win ordinary ties, while the Habbanya Stronghold advantage still takes precedence. Physical participants, plans and later battle-choice order do not swap. The accepted priority survives refresh.',
'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.',
'Finish existing shipment, movement, card preparation and pending decisions before changing order. Your hand panel lists only currently available choices. A reserved card or a position you already hold cannot be selected.',
'Completed bids and turns stay completed. Storm order, committed funding and movement counters do not reset. Refreshing preserves the same remaining opportunities and any declared last position.',
'In the explicitly opted-in Semuta preview, clean Once Around and movement-order discards pause after their bidder/turn queues reorder; a clean between-battles chooser discard pauses after changing battle priority but before the next chooser acts; a clean pre-plan aggressor discard pauses after changing ordinary tie priority. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unsealed battle then continues once.',
'These are the supported development controls, not additional printed restrictions. Cyclic lot duration and next-opportunity eligibility are explicit provisional conventions. Later aggressor intervention, other phase ordering and intervention during a partly completed combined turn remain unfinished. Silent bids are simultaneous; their storm-order tie rule is unchanged.',
'In the explicitly opted-in Semuta preview, clean Once Around and movement-order discards pause after their bidder/turn queues reorder; a clean between-battles chooser discard pauses after changing battle priority but before the next chooser acts; an aggressor discard pauses after changing ordinary tie priority. Fresh late continuations also bind revealed choices and the public final revision; old continuation bytes remain unchanged. Every seat receives the same neutral offer. Claim or decline moves only the used card or leaves it discarded; each committed queue or unresolved battle continues once. Direct combined late claim/capture proof remains open.',
'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure and cyclic lot duration are provisional conventions. Other phase ordering and partly completed combined-turn intervention remain unfinished. Silent bids retain simultaneous timing and storm tie order.',
],
example:
'Atreides bids 2. Before taking its bid, the Emperor uses Sapho to go last. Richese bids 3, then the Emperor must bid at least 4 to win. Atreides does not receive another bid.',
@@ -517,13 +517,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Finite Once Around, provisional current-lot cyclic ordinary/normal Black Market, clean combined-turn and remaining Battle-phase ordering. Local cycles preserve passes/funding; both native consumers retain the original following opener. Authoritative chooser/participant separation, full-hand eligibility and last-over-Guild protection remain. Other timing modes stay unfinished.',
'Finite Once Around, provisional current-lot cyclic ordinary/normal Black Market, clean combined-turn and remaining Battle-phase ordering, and clean late aggressor in fresh Richese battles. Public final readiness closes before physical allocation/settlement; plans, votes, original participants and committed cards remain. Local cycles preserve passes/funding and native following openers. Other timing modes remain unfinished.',
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Owner-only first/last and pre-plan aggressor actions, public aggressor/tie priority and remaining battle order, owner-relative targets, readable inspector and timing explanations. Cyclic first/last explains current-lot duration and pending-bid limits; later aggressor controls remain unfinished.',
'Owner-only first/last and clean unresolved-battle aggressor actions, public aggressor/tie priority and remaining battle order. Persistent final controls show both combatants and fresh-event readiness; a public Sapho use clears both finish marks. Cyclic controls retain current-lot duration and pending-bid limits. Older battles keep early timing.',
},
{
area: 'AI',
@@ -541,8 +541,15 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'18 October cyclic scope:10-file134/134, types/lint/build and eight original-action/rendered-table continuations pass. Actual cache1, Basic/Advanced ordinary bidding, native Advanced Black Market and paid full hand preserve prior1,2/3/4 raises, one payment4 and original next opener or final-lot Revival. Eight minimal policy proposals are legal; actual passes need a real raise. Controlled pure-cycle/post-sale quotes are separate. Fixture opener/completion and external smoke assumptions were repaired; only runtime was rerun against identical source after types/lint/build. No browser click, visual/deployed, direct combined Semuta claim/capture, full-mode or final-assurance acceptance.',
'18 October late aggressor:16-file177/177, types/lint/build and eight original-action/rendered-table programmes pass. Actual cache1/paid forces use40 Basic/47 Advanced source actions; native Stone1 turn1/Sapho1 turn2 uses86. Sealed-plan and revised-final-window uses preserve plans/votes; both final acknowledgements precede real count2 and settlement. Sixteen minimal proposals are legal. Controlled reservation probes are separate. Related Homeworld final-vote routing is repaired; no direct combined World/Nexus/Sapho or late Semuta claim/capture proof. Earlier cyclic scope:10-file134/134, types/lint/build and eight original-action/rendered-table continuations pass. Actual cache1, Basic/Advanced ordinary bidding, native Advanced Black Market and paid full hand preserve prior1,2/3/4 raises, one payment4 and original next opener or final-lot Revival. Eight minimal policy proposals are legal; actual passes need a real raise. Controlled pure-cycle/post-sale quotes are separate. Fixture opener/completion and external smoke assumptions were repaired; only runtime was rerun against identical source after types/lint/build. No browser click, visual/deployed, direct combined Semuta claim/capture, full-mode or final-assurance acceptance.',
evidence: [
'game/battle-outcome-window.ts',
'components/battle-outcome-window.tsx',
'tests/battle-outcome-window.test.ts',
'tests/battle-outcome-window-controls.test.tsx',
'tests/fixture-sapho-late-aggressor.ts',
'tests/sapho-late-aggressor-engine.test.ts',
'docs/SAPHO_AGGRESSOR.md',
'game/sapho-cyclic-auction.ts',
'components/juice-of-sapho.tsx',
'tests/sapho-cyclic-auction.test.ts',