Connect Sapho first and last battle-choice order
Juice of Sapho can change the remaining Battle-phase chooser order at clean boundaries. A chooser may occupy the physical defender slot and select the actual other combatant. Physical storm order, attackers, defenders and tie advantage remain intact. Full-roster phase priority survives aftermath, newly eligible Face Dancers and saved continuation. Controls and all four AI profiles consume the same projected choices. Independent review verified private options, public geometry, exact physical disposal, rejected-action immutability, phase expiry and boundary-bound saved chooser integrity. Actual Basic/Advanced Face Dance regressions verify later replacement priority. Authenticated SQLite tests cover duplicate concurrent card requests, private restore and battle completion. Chooser/aggressor separation and phase duration remain explicit implementation interpretations; the separate aggressor mode and unresolved timing stay gated. Reinforcements remains disabled pending the documented reserve-cost question. Types, lint, 4792 offline tests, build and 43 HTTP tests pass with unchanged source. Six fixed genuine Basic/Advanced faction samples finish in 3672 accepted actions and 97 periodic private JSON restores. They retain 82 rejected candidates at existing guarded arrival combinations; subsequent legal candidates finish. This is bounded playability evidence, not complete compliance or AI calibration. The browser used Sapho first from a later physical circle, chose Emperor, sealed and resolved a battle, then refreshed into its next choice. Emperor retained the tie win. All 9 accepted actions replay exactly using the recorded random battle event; physical custody, all private JSON views and the original seat row pass. A private harness initially needed random-event replay binding and SQLite-row prototype normalization; no production fix or game reset was required. After the reported outage, the server responded and the staged QA game restored exactly. All 970 opening games and their original seat/recovery records remain preserved (1004 after checks), including all 971 rooms present immediately before checks. SQLite integrity and foreign keys pass. No restart, database reset or recurring automation was needed. Verified source tree: b699f80600f8e297595a8e756edf367a607ddd99707474c03231e6479bd2f389
This commit is contained in:
1 parent
04501567d0
commit
cb691e1b14
20 files changed
+1759
-35
No files matched your search
@@ -0,0 +1,227 @@
|
||||
import { reorderOrderedOpportunity } from './ordered-opportunity';
|
||||
|
||||
export type PhysicalBattlePair = {
|
||||
territory: string;
|
||||
attacker: string;
|
||||
defender: string;
|
||||
};
|
||||
export type BattleChooserChoice = PhysicalBattlePair & { chooser: string };
|
||||
export type BattleOrderUse = {
|
||||
event: string;
|
||||
afterBattle: string | null;
|
||||
player: string;
|
||||
mode: 'first' | 'last';
|
||||
eligible: string[];
|
||||
signature: string;
|
||||
};
|
||||
export type BattleChooserOrder = {
|
||||
version: 1;
|
||||
turn: number;
|
||||
physicalOrder: string[];
|
||||
priority: string[];
|
||||
uses: BattleOrderUse[];
|
||||
};
|
||||
export class BattleChooserOrderError extends Error {}
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new BattleChooserOrderError(message);
|
||||
}
|
||||
const ids = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) &&
|
||||
value.every((id) => typeof id === 'string' && id.length > 0) &&
|
||||
new Set(value).size === value.length;
|
||||
const same = (a: unknown, b: unknown) =>
|
||||
JSON.stringify(a) === JSON.stringify(b);
|
||||
const keys = (value: object, expected: string) =>
|
||||
Object.keys(value).sort().join(',') === expected;
|
||||
|
||||
/** Events name an uncommitted boundary, not a particular faction or hidden card. */
|
||||
export function battleChooserEvent(
|
||||
turn: number,
|
||||
afterBattle: string | null,
|
||||
revision: number,
|
||||
): string {
|
||||
return `battleOrder:${JSON.stringify([turn, afterBattle, revision])}`;
|
||||
}
|
||||
export function battleOrderUseSignature(
|
||||
use: Omit<BattleOrderUse, 'signature'> | BattleOrderUse,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
use.event,
|
||||
use.afterBattle,
|
||||
use.player,
|
||||
use.mode,
|
||||
use.eligible,
|
||||
]);
|
||||
}
|
||||
/** Each physical pair has one chooser; its original attacker/defender never swap. */
|
||||
export function quoteBattleChoosers(
|
||||
pairs: readonly PhysicalBattlePair[],
|
||||
physicalOrder: readonly string[],
|
||||
priority: readonly string[] = physicalOrder,
|
||||
): {
|
||||
choices: BattleChooserChoice[];
|
||||
remaining: string[];
|
||||
current: string | null;
|
||||
} {
|
||||
check(
|
||||
ids(physicalOrder) &&
|
||||
ids(priority) &&
|
||||
same([...physicalOrder].sort(), [...priority].sort()),
|
||||
'Battle chooser priority must preserve every physical player circle.',
|
||||
);
|
||||
check(
|
||||
Array.isArray(pairs) &&
|
||||
pairs.every(
|
||||
(pair) =>
|
||||
pair &&
|
||||
typeof pair.territory === 'string' &&
|
||||
pair.territory.length > 0 &&
|
||||
pair.attacker !== pair.defender &&
|
||||
physicalOrder.includes(pair.attacker) &&
|
||||
physicalOrder.includes(pair.defender),
|
||||
),
|
||||
'Choose only distinct, seated physical battle opponents.',
|
||||
);
|
||||
const remaining = priority.filter((id) =>
|
||||
pairs.some((pair) => pair.attacker === id || pair.defender === id),
|
||||
);
|
||||
const choices = pairs
|
||||
.map((pair) => ({
|
||||
...pair,
|
||||
chooser:
|
||||
priority.indexOf(pair.attacker) < priority.indexOf(pair.defender)
|
||||
? pair.attacker
|
||||
: pair.defender,
|
||||
}))
|
||||
.sort((a, b) => priority.indexOf(a.chooser) - priority.indexOf(b.chooser));
|
||||
return { choices, remaining, current: remaining[0] ?? null };
|
||||
}
|
||||
function changedPriority(priority: readonly string[], use: BattleOrderUse) {
|
||||
check(
|
||||
ids(use.eligible) &&
|
||||
use.eligible.length > 1 &&
|
||||
same(
|
||||
priority.filter((id) => use.eligible.includes(id)),
|
||||
use.eligible,
|
||||
),
|
||||
'The saved battle ordering needs its exact remaining eligible chooser sequence.',
|
||||
);
|
||||
check(
|
||||
use.eligible.includes(use.player) &&
|
||||
(use.mode === 'first' || use.mode === 'last') &&
|
||||
(use.mode === 'first' ? use.eligible[0] : use.eligible.at(-1)) !==
|
||||
use.player,
|
||||
'Juice of Sapho must change one remaining battle chooser position.',
|
||||
);
|
||||
const next = reorderOrderedOpportunity(
|
||||
{
|
||||
event: use.event,
|
||||
eligible: priority,
|
||||
remaining: priority,
|
||||
completed: [],
|
||||
current: priority[0],
|
||||
currentStarted: false,
|
||||
},
|
||||
{ event: use.event, player: use.player, position: use.mode },
|
||||
);
|
||||
// Keep the accepted phase priority if a previously uninvolved Face Dancer
|
||||
// later gains a battle. Actual eligibility always comes from current pairs.
|
||||
return [...next.remaining];
|
||||
}
|
||||
export function validateBattleChooserOrder(
|
||||
state: BattleChooserOrder,
|
||||
physicalOrder: readonly string[],
|
||||
turn: number,
|
||||
events: readonly string[],
|
||||
): void {
|
||||
check(
|
||||
state &&
|
||||
typeof state === 'object' &&
|
||||
keys(state, 'physicalOrder,priority,turn,uses,version') &&
|
||||
state.version === 1 &&
|
||||
state.turn === turn &&
|
||||
Number.isSafeInteger(turn) &&
|
||||
turn > 0 &&
|
||||
ids(state.physicalOrder) &&
|
||||
same(state.physicalOrder, physicalOrder) &&
|
||||
ids(state.priority) &&
|
||||
Array.isArray(state.uses) &&
|
||||
state.uses.length > 0 &&
|
||||
ids(events) &&
|
||||
same(
|
||||
events,
|
||||
state.uses.map((use) => use?.event),
|
||||
),
|
||||
'The saved battle chooser order lost its phase, physical order or independent use events.',
|
||||
);
|
||||
let priority = [...physicalOrder];
|
||||
for (const [index, use] of state.uses.entries()) {
|
||||
check(
|
||||
use &&
|
||||
typeof use === 'object' &&
|
||||
keys(use, 'afterBattle,eligible,event,mode,player,signature') &&
|
||||
(use.afterBattle === null ||
|
||||
(typeof use.afterBattle === 'string' &&
|
||||
use.afterBattle.length > 0)) &&
|
||||
use.event === battleChooserEvent(turn, use.afterBattle, index) &&
|
||||
use.signature === battleOrderUseSignature(use),
|
||||
'The saved Juice of Sapho use lost its original battle boundary.',
|
||||
);
|
||||
priority = changedPriority(priority, use);
|
||||
}
|
||||
check(
|
||||
same(priority, state.priority),
|
||||
'The battle chooser priority contradicts its played scheduling history.',
|
||||
);
|
||||
}
|
||||
export function reorderBattleChoosers(input: {
|
||||
state?: BattleChooserOrder;
|
||||
physicalOrder: readonly string[];
|
||||
turn: number;
|
||||
afterBattle: string | null;
|
||||
pairs: readonly PhysicalBattlePair[];
|
||||
event: string;
|
||||
player: string;
|
||||
mode: 'first' | 'last';
|
||||
}): BattleChooserOrder {
|
||||
if (input.state)
|
||||
validateBattleChooserOrder(
|
||||
input.state,
|
||||
input.physicalOrder,
|
||||
input.turn,
|
||||
input.state.uses.map((use) => use.event),
|
||||
);
|
||||
const revision = input.state?.uses.length ?? 0;
|
||||
check(
|
||||
input.event === battleChooserEvent(input.turn, input.afterBattle, revision),
|
||||
'This battle chooser opportunity has expired.',
|
||||
);
|
||||
const priority = input.state?.priority ?? input.physicalOrder;
|
||||
const use: BattleOrderUse = {
|
||||
event: input.event,
|
||||
afterBattle: input.afterBattle,
|
||||
player: input.player,
|
||||
mode: input.mode,
|
||||
eligible: quoteBattleChoosers(input.pairs, input.physicalOrder, priority)
|
||||
.remaining,
|
||||
signature: '',
|
||||
};
|
||||
use.signature = battleOrderUseSignature(use);
|
||||
const next: BattleChooserOrder = {
|
||||
version: 1,
|
||||
turn: input.turn,
|
||||
physicalOrder: [...input.physicalOrder],
|
||||
priority: changedPriority(priority, use),
|
||||
uses: [
|
||||
...(input.state?.uses ?? []).map((use) => structuredClone(use)),
|
||||
use,
|
||||
],
|
||||
};
|
||||
validateBattleChooserOrder(
|
||||
next,
|
||||
input.physicalOrder,
|
||||
input.turn,
|
||||
next.uses.map((use) => use.event),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
@@ -8,17 +8,18 @@ import {
|
||||
splitLocation,
|
||||
} from './board';
|
||||
|
||||
/** Public fighter locations and storm order determine which battle may be chosen. */
|
||||
/** Use the authoritative chooser, which can differ from the physical attacker. */
|
||||
export function botBattleChoices(g: GameView): Action[] {
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
if (g.active !== me.id) return [];
|
||||
if (Array.isArray(g.battleChoices))
|
||||
return g.battleChoices
|
||||
.filter((battle) => battle.attacker === me.id)
|
||||
.filter((battle) => (battle.chooser ?? battle.attacker) === me.id &&
|
||||
[battle.attacker, battle.defender].includes(me.id))
|
||||
.map((battle) => ({
|
||||
type: 'chooseBattle',
|
||||
territory: battle.territory,
|
||||
target: battle.defender,
|
||||
target: battle.attacker === me.id ? battle.defender : battle.attacker,
|
||||
}));
|
||||
// Legacy component fixtures may predate the authoritative combat frontier.
|
||||
const ownLocations = Object.keys(presenceByLocation(me));
|
||||
|
||||
+5
-1
@@ -1079,8 +1079,12 @@ function saphoAction(g: GameView): Action | null {
|
||||
me.reserves >= 3 &&
|
||||
(me.faction === 'fremen' || (me.spice ?? 0) >= 3) &&
|
||||
Object.values(g.spice).some((amount) => amount >= 6);
|
||||
const battleFirst = options.some(option => option.scope === 'battleOrder') &&
|
||||
(g.battleChoices ?? []).some(battle =>
|
||||
[battle.attacker, battle.defender].includes(me.id) &&
|
||||
gameTerritories(g).some(t => t.id === battle.territory && t.type === 'stronghold'));
|
||||
const preferred =
|
||||
options[0].scope === 'movement' && earlySpice ? 'first' : 'last';
|
||||
(options[0].scope === 'movement' && earlySpice) || battleFirst ? 'first' : 'last';
|
||||
const option =
|
||||
options.find((candidate) => candidate.mode === preferred) ?? options[0];
|
||||
return { type: 'card', card: card.id, ...option };
|
||||
|
||||
@@ -34,7 +34,7 @@ export function richeseCardActionBlock(card: VisibleCard): string | null {
|
||||
if (definition?.card.effect === 'nullentropyBox')
|
||||
return 'Use the Nullentropy Box panel to pay for a private search.';
|
||||
if (definition?.card.effect === 'juiceOfSapho')
|
||||
return 'Use the Juice of Sapho panel to choose an available Once Around or movement order change.';
|
||||
return 'Use the Juice of Sapho panel to choose an available Once Around, movement or battle-choice order change.';
|
||||
if (definition?.card.effect === 'distrans')
|
||||
return 'Choose a recipient and card in the Distrans transfer panel.';
|
||||
return definition && definition.card.effect !== 'karama'
|
||||
|
||||
+84
-8
@@ -58,6 +58,7 @@ import { currentJunctionOffer, junctionOfferEvent, junctionOfferIntegrity, junct
|
||||
import { homeworldAllianceBlock } from './homeworld-alliance';
|
||||
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 { quoteHomeworldCombatLoss } from './homeworld-combat-loss';
|
||||
import { quoteHomeworldBattleRules } from './homeworld-battle-rules';
|
||||
import type { HomeworldForces } from './homeworld-custody';
|
||||
@@ -603,6 +604,9 @@ export type DiplomatDefenseReceipt = DiplomatDefenseQuote & {
|
||||
signature: string;
|
||||
};
|
||||
export type Battle = {
|
||||
/** Scheduling owner; physical combat slots and aggressor remain independent. */
|
||||
chooser?: string;
|
||||
chooserEvent?: string;
|
||||
/** Only newly created supported battles enter the pre-plan Mentat question. */
|
||||
mentatQuestionVersion?: 1;
|
||||
mentatQuestionEvent?: string;
|
||||
@@ -1518,6 +1522,9 @@ export type Game = {
|
||||
movementRemaining: string[] | null;
|
||||
/** Juice of Sapho's last position lasts only through this combined-turn queue. */
|
||||
saphoMovementLast?: { event: string; turn: number; player: string } | null;
|
||||
/** Separate Battle-phase scheduling; never rewrites physical storm order. */
|
||||
battleOrder?: BattleChooserOrder;
|
||||
battleOrderUseEvents?: string[];
|
||||
guildTimingGranted: boolean;
|
||||
guildTimingLocked: boolean;
|
||||
ready: string[];
|
||||
@@ -12032,7 +12039,51 @@ function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
|
||||
export function battles(g: Game) {
|
||||
const quote = boardResolution(() => quoteCombatBoard(g));
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
return quote.battles;
|
||||
return battleOrderRule(() => quoteBattleChoosers(quote.battles, g.order, g.battleOrder?.priority)).choices;
|
||||
}
|
||||
function battleOrderRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) { if (error instanceof BattleChooserOrderError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function battleOrderIntegrity(g: Game) {
|
||||
requireRule(!!g.battleOrder === !!g.battleOrderUseEvents,
|
||||
'The saved battle chooser order is missing its independent use events.');
|
||||
if (!g.battleOrder) return;
|
||||
requireRule(g.status === 'playing' && g.phase === 6,
|
||||
'Juice of Sapho battle scheduling expires when the Battle Phase ends.');
|
||||
battleOrderRule(() => validateBattleChooserOrder(g.battleOrder!, g.order, g.turn, g.battleOrderUseEvents!));
|
||||
const cards = physicalTreacheryCards(g).filter(card => card.id === 'richese-juice-of-sapho');
|
||||
requireRule(cards.length === 1 && !!richeseCardDefinition(cards[0]) && cards[0].effect === 'juiceOfSapho',
|
||||
'The played battle scheduling card has lost its unique physical custody.');
|
||||
if (g.battle)
|
||||
requireRule(g.battle.chooser && [g.battle.attacker, g.battle.defender].includes(g.battle.chooser) &&
|
||||
g.battle.chooserEvent === battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder.uses.length) &&
|
||||
g.battle.chooser === [g.battle.attacker, g.battle.defender].sort((a, b) =>
|
||||
g.battleOrder!.priority.indexOf(a) - g.battleOrder!.priority.indexOf(b))[0],
|
||||
'The selected battle lost its original scheduling owner.');
|
||||
else if (saphoBattleBoundary(g))
|
||||
requireRule(g.active === battleOrderQuote(g).current,
|
||||
'The active battle chooser contradicts the remaining scheduling priority.');
|
||||
}
|
||||
function battleOrderAfterBattle(g: Game) {
|
||||
return g.lastBattleContext?.turn === g.turn ? g.lastBattleContext.event : null;
|
||||
}
|
||||
function battleOrderQuote(g: Game) {
|
||||
return battleOrderRule(() => quoteBattleChoosers(quoteCombatBoard(g).battles, g.order, g.battleOrder?.priority));
|
||||
}
|
||||
function saphoBattleBoundary(g: Game) {
|
||||
return g.phase === 6 && saphoCleanWindow(g) && !g.battle &&
|
||||
!g.pendingTreacheryDiscard && !g.pendingRichesePurchaseIncome && !g.pendingCapture &&
|
||||
!g.pendingAuditor && !g.pendingFaceDance && !g.pendingTech && !g.pendingChoamBattleIncome &&
|
||||
!g.moritaniRetention && !g.choamMarket;
|
||||
}
|
||||
function projectedBattleOrder(g: Game) {
|
||||
if (g.status !== 'playing' || g.phase !== 6) return null;
|
||||
const quote = battleOrderQuote(g);
|
||||
return {event: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0),
|
||||
phaseEvent: `battleOrder:${g.turn}`, remaining: quote.remaining,
|
||||
current: saphoBattleBoundary(g) ? quote.current : null,
|
||||
uses: g.battleOrder?.uses.map(({event, player, mode}) => ({event, player, mode})) ?? []};
|
||||
}
|
||||
/** Evaluate only at the committed end of movement, using the normal battle geometry. */
|
||||
function offerMoritaniDuke(g: Game) {
|
||||
@@ -12076,7 +12127,7 @@ function offerMoritaniDuke(g: Game) {
|
||||
return true;
|
||||
}
|
||||
type SaphoOption = {
|
||||
scope: 'onceAround' | 'movement';
|
||||
scope: 'onceAround' | 'movement' | 'battleOrder';
|
||||
event: string;
|
||||
mode: 'first' | 'last';
|
||||
};
|
||||
@@ -12181,6 +12232,13 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
|
||||
)
|
||||
return [];
|
||||
const options: SaphoOption[] = [];
|
||||
if (saphoBattleBoundary(g)) {
|
||||
const order = projectedBattleOrder(g)!;
|
||||
if (order.remaining.includes(p.id)) {
|
||||
if (order.current !== p.id) options.push({scope: 'battleOrder', event: order.event, mode: 'first'});
|
||||
if (order.remaining.at(-1) !== p.id) options.push({scope: 'battleOrder', event: order.event, mode: 'last'});
|
||||
}
|
||||
}
|
||||
const lot = g.richeseAuction;
|
||||
if (
|
||||
g.phase === 3 &&
|
||||
@@ -12257,6 +12315,16 @@ function playSapho(g: Game, p: Player, action: Action) {
|
||||
{ faction: p.faction, name: 'Juice of Sapho' },
|
||||
);
|
||||
settleRicheseLot(g);
|
||||
} else if (option.scope === 'battleOrder') {
|
||||
const next = battleOrderRule(() => reorderBattleChoosers({state: g.battleOrder, physicalOrder: g.order,
|
||||
turn: g.turn, afterBattle: battleOrderAfterBattle(g), pairs: quoteCombatBoard(g).battles,
|
||||
event: option.event, player: p.id, mode: option.mode}));
|
||||
discard(g, p, 'richese-juice-of-sapho');
|
||||
g.battleOrder = next;
|
||||
(g.battleOrderUseEvents ??= []).push(option.event);
|
||||
g.active = battleOrderQuote(g).current;
|
||||
log(g, `${p.name} discarded Juice of Sapho to choose their remaining battles ${option.mode} this Battle Phase. Other players may still choose to fight them earlier. Physical storm order and battle aggressor are unchanged.`,
|
||||
{faction: p.faction, name: 'Juice of Sapho'});
|
||||
} else {
|
||||
const queue = saphoMovementQueue(g)!;
|
||||
const next =
|
||||
@@ -12715,6 +12783,10 @@ function finishEcazPlacement(g: Game) {
|
||||
}
|
||||
function completePhase(g: Game) {
|
||||
if (closeNexusCards(g)) return;
|
||||
if (g.phase === 6) {
|
||||
delete g.battleOrder;
|
||||
delete g.battleOrderUseEvents;
|
||||
}
|
||||
if (g.phase === 7 && g.grummanCollection?.turn === g.turn && g.grummanCollection.stage === 'waiting') {
|
||||
g.grummanCollection.stage = 'complete';
|
||||
g.grummanCollection.outcome = 'expired';
|
||||
@@ -12779,7 +12851,7 @@ function beginPhase(g: Game) {
|
||||
}
|
||||
if (g.phase === 6) {
|
||||
const b = battles(g);
|
||||
if (b.length) g.active = b[0].attacker;
|
||||
if (b.length) g.active = b[0].chooser;
|
||||
else {
|
||||
nextPhase(g);
|
||||
return;
|
||||
@@ -14157,6 +14229,7 @@ function gholaOptions(g: Game, p: Player) {
|
||||
};
|
||||
}
|
||||
function marketGholaIntegrity(g: Game) {
|
||||
battleOrderIntegrity(g);
|
||||
nexusCardsIntegrity(g);
|
||||
nexusEmperorSecretIntegrity(g);
|
||||
nexusTraitorIntegrity(g);
|
||||
@@ -16291,7 +16364,7 @@ function finishBattle(g: Game) {
|
||||
g.decision = { ...next };
|
||||
} else {
|
||||
const remaining = battles(g);
|
||||
if (remaining.length) g.active = remaining[0].attacker;
|
||||
if (remaining.length) g.active = remaining[0].chooser;
|
||||
else nextPhase(g);
|
||||
}
|
||||
}
|
||||
@@ -23593,9 +23666,9 @@ function applyActionInner(
|
||||
);
|
||||
const choice = battles(g).find(
|
||||
(b) =>
|
||||
b.attacker === id &&
|
||||
b.chooser === id &&
|
||||
b.territory === action.territory &&
|
||||
b.defender === action.target,
|
||||
(b.attacker === id ? b.defender : b.attacker) === action.target,
|
||||
);
|
||||
requireRule(choice, 'Choose one of your unresolved battles.');
|
||||
g.auditorInsight = null;
|
||||
@@ -23614,6 +23687,7 @@ function applyActionInner(
|
||||
const battleEvent = crypto.randomUUID();
|
||||
g.battle = {
|
||||
...choice,
|
||||
chooserEvent: battleChooserEvent(g.turn, battleOrderAfterBattle(g), g.battleOrder?.uses.length ?? 0),
|
||||
event: battleEvent,
|
||||
...(g.mentatQuestionPreview === true && g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
|
||||
...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 1 as const } : {}),
|
||||
@@ -23630,7 +23704,7 @@ function applyActionInner(
|
||||
beginStrongholdBattle(g);
|
||||
log(
|
||||
g,
|
||||
`${p.name} attacks ${getPlayer(g, choice.defender).name} in ${combatLocationName(g, choice.territory)}.`,
|
||||
`${p.name} chose the battle between ${getPlayer(g, choice.attacker).name} and ${getPlayer(g, choice.defender).name} in ${combatLocationName(g, choice.territory)}. ${getPlayer(g, choice.attacker).name} remains the aggressor.`,
|
||||
);
|
||||
return g;
|
||||
}
|
||||
@@ -24051,7 +24125,8 @@ export function viewGame(state: Game, id: string) {
|
||||
canAct: biddingEndQuiet(g) && g.biddingEnd.owners.includes(id), kaitain: { owner: byFaction(g, 'emperor')!.id,
|
||||
eligible: homeworldRule(() => highKaitainDiscardsAvailable(g, byFaction(g, 'emperor')!.id)) } } : null,
|
||||
combatLocations: combatLocations(g),
|
||||
battleChoices: g.status === 'playing' && g.phase === 6 ? quoteCombatBoard(g).battles : [],
|
||||
battleChoices: g.status === 'playing' && g.phase === 6 ? battleOrderQuote(g).choices : [],
|
||||
battleOrder: projectedBattleOrder(g),
|
||||
guildAmbassadorAdvisorChoices: guildAdvisorChoices(g, id),
|
||||
truthShipmentAnswers:
|
||||
g.truthtrance?.stage === 'answer' &&
|
||||
@@ -24946,6 +25021,7 @@ export function viewGame(state: Game, id: string) {
|
||||
preLeader: b.preLeader ?? null,
|
||||
territory: b.territory,
|
||||
attacker: b.attacker,
|
||||
chooser: b.chooser ?? b.attacker,
|
||||
defender: b.defender,
|
||||
revealed: b.revealed,
|
||||
kwisatzBlocked: b.kwisatzBlocked ?? false,
|
||||
|
||||
+10
-5
@@ -263,7 +263,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A player whose hand becomes full is automatically passed, or submits zero in an unfinished Silent lot. A full-hand owner’s unbid cache card is removed automatically. If every hand is full, preparing the normal pool does not draw and lose unseen cards.',
|
||||
'AI supports cache choices and all three offered bidding methods using its own projected information. It currently skips the optional Black Market prelude. A seller in an already offered Black Market lot passes or submits zero.',
|
||||
'Some unresolved rules are explicitly blocked: changing the normal card count after cache-auction cancellation, exhausted-cache arithmetic, positive Black Market self-bids, and advanced Ixian Technology substitution on special lots. These are implementation guards while the rules are resolved, not claims of official prohibitions.',
|
||||
'Juice of Sapho now supports bounded first/last Once Around and movement ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon and Semuta Drug remain unfinished, as do the other Sapho timing modes; this does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.',
|
||||
'Juice of Sapho now supports bounded first/last Once Around, movement and remaining battle-choice ordering; see its timing guide for current limits. Richese’s Karama uses the existing generic handler, Distrans supports a separate private hand transfer, Nullentropy Box supports a paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment in development fixtures. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon and Semuta Drug remain unfinished, as do the other Sapho timing modes; this does not certify every Karama interaction. Richese expansion starts remain disabled. Each component guide identifies its currently available action. Independent continuation and persisted-room checks pass. Desktop checks confirmed all ten enlarged guides, direct topic links, Once Around payment and restored private Silent bidding. Mobile and full Richese-game verification remain unfinished.',
|
||||
],
|
||||
related: [
|
||||
'choam-modules',
|
||||
@@ -328,11 +328,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary:
|
||||
'Choose one available first or last position, then discard Sapho. Battle aggressor and other timing modes remain unfinished.',
|
||||
'Choose an available first or last position in Once Around bidding, movement or remaining battle choices, then discard Sapho. Battle aggressor and other timing modes remain unfinished.',
|
||||
steps: [
|
||||
'In a current Once Around auction, first is available before anyone bids. Last is available before your own bid, including after Richese. You still get only one bidding opportunity and must outbid the current high bid to win.',
|
||||
'Discarding Sapho frees a hand slot. A full-hand holder can join a still-open lot through this order change. A completed bid or pass cannot be repeated, and 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.',
|
||||
'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.',
|
||||
'These are the supported development controls, not additional printed restrictions. Battle aggressor, ordinary cyclic auction scope, other phase ordering and intervention during a partly completed combined turn remain unfinished. Silent bids are simultaneous; their storm-order tie rule is unchanged.',
|
||||
@@ -350,13 +351,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Finite Once Around and clean combined-turn ordering, including first after completed movement turns; physical discard, full-hand eligibility and persisted last-over-Guild protection. Remaining timing modes are guarded.',
|
||||
'Finite Once Around, clean combined-turn and remaining Battle-phase ordering; authoritative chooser/participant separation, phase priority, physical discard, full-hand auction eligibility and persisted last-over-Guild movement protection. Remaining timing modes are guarded.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Owner-only first/last actions, readable inspector and timing explanations. No aggressor or cyclic-auction controls yet.',
|
||||
'Owner-only first/last actions, public remaining battle order, owner-relative battle targets, readable inspector and timing explanations. No aggressor or cyclic-auction controls yet.',
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
@@ -374,7 +375,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Verification',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Finite-order, engine, all-profile and real persisted-room tests cover once-only play, funding, full-hand eligibility, privacy and Guild deferral. Full expansion games remain unverified.',
|
||||
'Finite-order, engine, all-profile and real persisted-room tests cover once-only play, funding, full-hand eligibility, privacy, Guild deferral and battle scheduling through actual outcomes. Full expansion compliance remains unverified.',
|
||||
evidence: [
|
||||
'tests/ordered-opportunity.test.ts',
|
||||
'tests/juice-of-sapho-engine.test.ts',
|
||||
@@ -382,6 +383,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/juice-of-sapho-bots.test.ts',
|
||||
'tests/juice-of-sapho-recovery.test.ts',
|
||||
'tests/juice-of-sapho-boundary-recovery.test.ts',
|
||||
'tests/sapho-battle-order.test.ts',
|
||||
'tests/sapho-battle-order-bots.test.ts',
|
||||
'tests/sapho-battle-order-controls.test.tsx',
|
||||
'tests/sapho-battle-order-recovery.test.ts',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user