Support Advanced Sapho Charity suspension

This commit is contained in:
Quintine committed 2026-10-27 23:36:27 +11:00
1 parent fcd93ca252
commit 70ef142577
19 files changed
+694 -49

No files matched your search

+83 -16
View File
@@ -12,10 +12,13 @@ export type SaphoCharityOrderUse = {
mode: 'first' | 'last';
completed: string[];
started: boolean;
/** Version 3 only: the current started player voluntarily paused last. */
suspended?: true;
};
export type CharityOrder = CharityOrderFields & (
| { version: 1 }
| { version: 2; completed: string[]; started: boolean; uses: SaphoCharityOrderUse[] }
| { version: 3; completed: string[]; started: boolean; uses: SaphoCharityOrderUse[]; paused: string[] }
);
export type CharityOrderContext = {
turn: number;
@@ -67,21 +70,23 @@ export function createCharityOrder(context: CharityOrderContext): CharityOrder {
}
function currentStarted(context: CharityOrderContext, frame: CharityOrder): boolean {
return frame.version === 2 ? frame.started : !!context.claimed?.includes(frame.remaining[0]);
return frame.version !== 1 ? frame.started : !!context.claimed?.includes(frame.remaining[0]);
}
function copy(frame: CharityOrder): CharityOrder {
const fields = { turn: frame.turn, order: [...frame.order], remaining: [...frame.remaining] };
return frame.version === 1 ? { version: 1, ...fields } : {
version: 2, ...fields, completed: [...frame.completed], started: frame.started,
uses: frame.uses.map(use => ({ ...use, completed: [...use.completed] })),
};
if (frame.version === 1) return { version: 1, ...fields };
const rest = { ...fields, completed: [...frame.completed], started: frame.started,
uses: frame.uses.map(use => ({ ...use, completed: [...use.completed] })) };
return frame.version === 2 ? { version: 2, ...rest } : { version: 3, ...rest, paused: [...frame.paused] };
}
export function validateCharityOrder(context: CharityOrderContext, frame: CharityOrder): void {
validateContext(context);
requireOrder(record(frame) && (frame.version === 1 || frame.version === 2) &&
requireOrder(record(frame) && (frame.version === 1 || frame.version === 2 || frame.version === 3) &&
Object.keys(frame).sort().join(',') === (frame.version === 1
? 'order,remaining,turn,version' : 'completed,order,remaining,started,turn,uses,version') &&
? 'order,remaining,turn,version' : frame.version === 2
? 'completed,order,remaining,started,turn,uses,version'
: 'completed,order,paused,remaining,started,turn,uses,version') &&
frame.turn === context.turn && ids(frame.order) && ids(frame.remaining),
'The current Charity order is malformed or belongs to another turn.');
requireOrder(same(frame.order, context.order), 'Charity must retain its original physical order.');
@@ -91,12 +96,16 @@ export function validateCharityOrder(context: CharityOrderContext, frame: Charit
else {
requireOrder(ids(frame.completed) && same(frame.completed, context.ready) && typeof frame.started === 'boolean',
'Reordered Charity must retain its actual completions and current start.');
validateSaphoHistory(frame);
if (frame.version === 2) validateSaphoHistory(frame);
else validateSuspendedSaphoHistory(frame);
requireOrder(frame.remaining.length > 0 || !frame.started,
'Completed Charity cannot retain a started opportunity.');
if (context.claimed !== undefined) requireOrder(frame.started ===
!!context.claimed.includes(frame.remaining[0]),
'The Charity start must match the current native claim receipt.');
if (frame.version === 3 && context.claimed !== undefined) requireOrder(
frame.paused.every(player => context.claimed!.includes(player)),
'Every paused Charity turn must retain its native claim receipt.');
}
}
@@ -154,27 +163,81 @@ function validateSaphoHistory(frame: Extract<CharityOrder, { version: 2 }>): voi
'The Charity frame contradicts its Sapho history or unlocks a started opportunity.');
}
/** Version 3 preserves public order history and a started faction's native
* claim while it waits for its original opportunity to resume. */
function validateSuspendedSaphoHistory(frame: Extract<CharityOrder, { version: 3 }>): void {
requireOrder(Array.isArray(frame.uses) && frame.uses.length > 0 && ids(frame.paused),
'A suspended Charity turn needs its actual use history and unique paused factions.');
let remaining = [...frame.order], completed: string[] = [], started = false, paused: string[] = [];
const advance = (next: readonly string[]) => {
const advanced = advanceCompleted(remaining, completed, started, next);
const count = next.length - completed.length;
paused = paused.filter(player => !next.includes(player));
remaining = advanced.remaining;
completed = advanced.completed;
started = count === 0 ? advanced.started : !!remaining.length && paused.includes(remaining[0]);
};
for (const [index, use] of frame.uses.entries()) {
requireOrder(record(use) &&
(Object.keys(use).sort().join(',') === 'completed,event,mode,player,started' ||
Object.keys(use).sort().join(',') === 'completed,event,mode,player,started,suspended' && use.suspended === true) &&
typeof use.event === 'string' && id(use.player) && (use.mode === 'first' || use.mode === 'last') &&
ids(use.completed) && typeof use.started === 'boolean',
'The saved Sapho Charity use is malformed.');
advance(use.completed);
requireOrder(!started || use.started, 'A later Sapho use cannot unlock the started Charity opportunity.');
requireOrder(!use.started || remaining.length > 0, 'Only a current Charity opportunity can have started.');
requireOrder(use.event === eventFor(frame.turn, frame.order, remaining, completed, use.started, index),
'The saved Sapho Charity use lost its original public boundary.');
if (use.suspended) {
requireOrder(use.started && use.player === remaining[0] && use.mode === 'last' && remaining.length > 1,
'Only the current started faction can suspend its own Charity turn last.');
if (!paused.includes(use.player)) paused.push(use.player);
remaining = [...remaining.slice(1), use.player];
started = paused.includes(remaining[0]);
} else {
remaining = reordered(frame.order, remaining, completed, use.started, use);
started = use.started || paused.includes(remaining[0]);
}
}
advance(frame.completed);
requireOrder(same(frame.remaining, remaining) && same(frame.paused, paused) && (!started || frame.started),
'The Charity frame contradicts its Sapho history or loses a suspended opportunity.');
requireOrder(frame.uses.some(use => use.suspended === true), 'Version 3 requires an actual suspended Charity turn.');
}
export function saphoCharityOrderEvent(context: CharityOrderContext, frame: CharityOrder): string {
validateCharityOrder(context, frame);
return eventFor(frame.turn, frame.order, frame.remaining, context.ready,
currentStarted(context, frame), frame.version === 2 ? frame.uses.length : 0);
currentStarted(context, frame), frame.version === 1 ? 0 : frame.uses.length);
}
export function reorderSaphoCharityOrder(context: CharityOrderContext, frame: CharityOrder, request: {
event: string; player: string; mode: 'first' | 'last';
}): { order: CharityOrder; afterCurrent?: string } {
}): { order: CharityOrder; afterCurrent?: string; suspended?: string } {
validateCharityOrder(context, frame);
const event = saphoCharityOrderEvent(context, frame), started = currentStarted(context, frame);
requireOrder(record(request) && Object.keys(request).sort().join(',') === 'event,mode,player' &&
request.event === event && id(request.player) && (request.mode === 'first' || request.mode === 'last'),
'Choose first or last at the current Sapho Charity opportunity.');
const remaining = reordered(frame.order, frame.remaining, context.ready, started, request);
const order: CharityOrder = { version: 2, turn: frame.turn, order: [...frame.order], remaining,
completed: [...context.ready], started,
uses: [...(frame.version === 2 ? frame.uses.map(use => ({ ...use, completed: [...use.completed] })) : []),
{ event, player: request.player, mode: request.mode, completed: [...context.ready], started }] };
const suspended = started && request.player === frame.remaining[0];
if (suspended) requireOrder(request.mode === 'last' && frame.remaining.length > 1,
'A started Charity turn can only be suspended last when another opportunity remains.');
const remaining = suspended ? [...frame.remaining.slice(1), request.player]
: reordered(frame.order, frame.remaining, context.ready, started, request);
const paused = frame.version === 3 ? [...frame.paused] : [];
if (suspended && !paused.includes(request.player)) paused.push(request.player);
const uses = [...(frame.version === 1 ? [] : frame.uses.map(use => ({ ...use, completed: [...use.completed] }))),
{ event, player: request.player, mode: request.mode, completed: [...context.ready], started,
...(suspended ? { suspended: true as const } : {}) }];
const fields = { turn: frame.turn, order: [...frame.order], remaining,
completed: [...context.ready], started: suspended ? paused.includes(remaining[0]) : started || paused.includes(remaining[0]) };
const order: CharityOrder = frame.version === 3 || suspended
? { version: 3, ...fields, uses, paused }
: { version: 2, ...fields, uses };
validateCharityOrder(context, order);
return { order, ...(started ? { afterCurrent: frame.remaining[0] } : {}) };
return { order, ...(suspended ? { suspended: request.player } :
started ? { afterCurrent: frame.remaining[0] } : {}) };
}
export function charityOrderBlock(context: CharityOrderContext, frame: CharityOrder, player: string): string | null {
@@ -205,6 +268,10 @@ export function finishCharityOrder(context: CharityOrderContext, frame: CharityO
if (frame.version === 1)
return { version: 1, turn: frame.turn, order: [...frame.order], remaining: frame.remaining.slice(1) };
const next = { ...copy(frame), remaining: frame.remaining.slice(1), completed: [...frame.completed, player], started: false };
if (next.version === 3) {
next.paused = next.paused.filter(id => id !== player);
next.started = next.remaining.length > 0 && next.paused.includes(next.remaining[0]);
}
validateCharityOrder({ ...context, ready: [...context.ready, player] }, next);
return next;
}
+6 -5
View File
@@ -7706,8 +7706,8 @@ function treacheryDiscardIntegrity(g: Game) {
catch (error) { if (error instanceof SaphoTruthOrderError) throw new RuleError(error.message); throw error; }
} else if (continuation?.kind === 'saphoCharityDiscard') {
const c=continuation,entry=batch.entries[0],frame=g.charityOrder;
const latest=frame?.version===2 ? frame.uses.at(-1) : null;
requireRule(g.advanced && g.phase===2 && frame?.version===2 &&
const latest=frame && frame.version!==1 ? frame.uses.at(-1) : null;
requireRule(g.advanced && g.phase===2 && frame && frame.version!==1 &&
latest?.event===c.event && latest.player===c.player && latest.mode===c.mode &&
batch.cause==='sapho:charity' && batch.entries.length===1 &&
entry.publicFace && entry.discardedBy===c.player && entry.card.id===c.card &&
@@ -9052,7 +9052,7 @@ function semutaOfferSupported(
if (continuation.kind === 'battleResolved' &&
!g.lastBattleContext?.mandatoryDiscard) return false;
if (continuation.kind === 'saphoCharityDiscard' &&
(!g.advanced || g.phase!==2 || g.charityOrder?.version!==2 ||
(!g.advanced || g.phase!==2 || !g.charityOrder || g.charityOrder.version===1 ||
continuation.resume.response || continuation.resume.decision ||
continuation.resume.pendingKarama || continuation.resume.phaseOpening ||
g.response || g.decision || g.pendingRevival)) return false;
@@ -27176,7 +27176,8 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
for (const mode of ['first','last'] as const) {
try {
const quote=reorderSaphoCharityOrder(context,g.charityOrder,{event,player:p.id,mode});
options.push({scope:'charity',event,mode,...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {})});
options.push({scope:'charity',event,mode,...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {}),
...(quote.suspended ? {resumeOwn:true as const} : {})});
} catch(error) {
if (!(error instanceof CharityOrderError)) throw error;
}
@@ -27380,7 +27381,7 @@ function playSapho(g: Game, p: Player, action: Action) {
const used=discard(g,p,'richese-juice-of-sapho');
g.charityOrder=quote.order;
g.active=quote.order.remaining[0] ?? null;
log(g,`${p.name} discarded Juice of Sapho to take their remaining Charity turn ${option.mode}.${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the started turn first.` : ''} Completed turns, native claim entitlements and physical First Player order stay unchanged.`,
log(g,`${p.name} discarded Juice of Sapho to take their remaining Charity turn ${option.mode}.${quote.suspended ? ' Their started turn pauses provisionally and resumes last with its claim unchanged.' : ''}${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the started turn first.` : ''} Completed turns, native claim entitlements and physical First Player order stay unchanged.`,
{faction:p.faction,name:'Juice of Sapho'});
if (g.semutaPreview) {
const continuation:SaphoCharityDiscardContinuation={kind:'saphoCharityDiscard',player:p.id,card:used.id,
+3 -2
View File
@@ -504,7 +504,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'When two or more players declared Truthtrance in a supported clean parent, each queued holder may continue or use their held Sapho to move their entire pending question group first or last. Physical storm order and each holder’s card order stay unchanged. Supported Basic Semuta claim/pass restores the same question queue. Clean ordinary auction lots also preserve their standing bid and remaining clock. Richese cache Once Around and Silent lots preserve their original bidding and funding; Black Market, other special auctions, battle and pending-parent combinations remain unfinished.',
'During a clean native Extortion payer decision, use Sapho to take your optional choice first/last among unanswered non-Moritani players. Earlier declines, collected bank5 and the one-payment3 limit remain. The next payer event changes even when that player stays next. You may decline instead of paying.',
'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. Each player still claims at most once, completed turns stay closed, and the holder cannot split its own started turn. Physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.',
'During ordered Advanced Charity, take your remaining turn first or last, then discard Sapho. An accepted current claim provisionally protects that actor while another holder reorders only later unstarted turns. At a clean boundary, the holder may pause its own claimed turn and resume last without repeating the claim or payment. Completed turns stay closed and physical First Player order is unchanged. Basic and older concurrent phases have no ordered scope.',
'During ordered Advanced Revival, take your unfinished opportunity first or last, then discard Sapho. If another player has started, provisional queued timing lets that player finish unchanged. At a clean boundary, you may instead pause your own started turn and resume last, retaining used allowances. Pending requests must finish first. Completed turns and already-held positions cannot be repeated. Basic and older concurrent Revival phases have no ordered scope.',
'Finish pending shipment, movement and card preparations and decisions before changing order. The exact Extortion payer window is a supported exception for its own order only. Your hand panel lists available choices. Reserved cards and positions 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.',
@@ -524,7 +524,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Finite Once Around, current-lot cyclic bidding, clean/queued movement and ordered Advanced Charity/Revival, remaining battle-choice and late aggressor modes are connected. Queued timing retains another started actor; a clean own started movement turn may provisionally resume last in either ruleset. Charity/Revival keep native claims or requests/allowances and separate physical order through their Semuta disposal. Tail timing is visibly provisional. Other phases remain unfinished.',
'Finite Once Around, current-lot cyclic bidding, clean/queued movement and ordered Advanced Charity/Revival, remaining battle-choice and late aggressor modes are connected. Queued timing retains another started actor; a clean own started movement turn in either ruleset, or Charity/Revival turn in Advanced, may provisionally resume last. Native claims or requests/allowances and physical order remain through their Semuta disposal. Tail timing is visibly provisional. Other phases remain unfinished.',
},
{
area: 'Player controls',
@@ -4439,6 +4439,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Stronghold targets, storm-contested occupation, prediction and final-turn fallback.',
steps: [
'Victory is checked when the Mentat pause begins. A faction needs three strongholds, or four when allied or playing a two-player game. Allies combine distinct qualifying strongholds.',
'The optional two-person setup with two factions per person is not yet available; ordinary two-seat games use the four-stronghold target.',
'Ecaz and its reciprocal ally can also win by jointly occupying three strongholds: both factions need non-advisor presence in each, without opposing fighters. The ordinary four-stronghold alliance route remains available. Occupy cancellation does not remove this victory condition. A concealed No-Field supplies public presence; its secret value is not revealed by this check.',
'The separately marked fresh native Ecaz Homeworld preview has another printed route: native Ecaz must currently have at least seven reserves, the reciprocal alliance must jointly hold one actual uncontested stronghold, and it must occupy worlds of two other native factions. Two Emperor worlds count as one faction. This joins normal winners before a correct BG prediction or final-turn fallback; it adds no ordinary stronghold points.',
'Opposing forces may survive together in a stronghold covered by storm. That contested stronghold does not count for either side. A sole occupant still counts it even under storm.',