Connect Sapho to ordered Advanced Charity
This commit is contained in:
1 parent
e1596dbcee
commit
28df687bf9
15 files changed
+1080
-38
No files matched your search
+142
-10
@@ -1,15 +1,28 @@
|
||||
import { reorderOrderedOpportunity, OrderedOpportunityError } from './ordered-opportunity';
|
||||
|
||||
/** Native Charity follows the actual First Player order, one opportunity per seat. */
|
||||
export type CharityOrder = {
|
||||
version: 1;
|
||||
type CharityOrderFields = {
|
||||
turn: number;
|
||||
order: string[];
|
||||
remaining: string[];
|
||||
};
|
||||
export type SaphoCharityOrderUse = {
|
||||
event: string;
|
||||
player: string;
|
||||
mode: 'first' | 'last';
|
||||
completed: string[];
|
||||
started: boolean;
|
||||
};
|
||||
export type CharityOrder = CharityOrderFields & (
|
||||
| { version: 1 }
|
||||
| { version: 2; completed: string[]; started: boolean; uses: SaphoCharityOrderUse[] }
|
||||
);
|
||||
export type CharityOrderContext = {
|
||||
turn: number;
|
||||
order: readonly string[];
|
||||
players: readonly { id: string }[];
|
||||
ready: readonly string[];
|
||||
claimed?: readonly string[];
|
||||
};
|
||||
export class CharityOrderError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -38,25 +51,130 @@ function validateContext(context: CharityOrderContext): void {
|
||||
requireOrder(new Set(seats).size === seats.length && context.order.length === seats.length &&
|
||||
context.order.every(player => seats.includes(player)),
|
||||
'Charity order must contain every actual seat exactly once.');
|
||||
requireOrder(ids(context.ready) && context.ready.length <= context.order.length &&
|
||||
same(context.ready, context.order.slice(0, context.ready.length)),
|
||||
'Completed Charity opportunities must be the exact public-order prefix.');
|
||||
requireOrder(ids(context.ready) && context.ready.every(player => seats.includes(player)),
|
||||
'Completed Charity opportunities must be distinct actual seats.');
|
||||
requireOrder(context.claimed === undefined ||
|
||||
(ids(context.claimed) && context.claimed.every(player => seats.includes(player))),
|
||||
'Charity claim receipts must name distinct actual seats.');
|
||||
}
|
||||
|
||||
export function createCharityOrder(context: CharityOrderContext): CharityOrder {
|
||||
validateContext(context);
|
||||
requireOrder(same(context.ready, context.order.slice(0, context.ready.length)),
|
||||
'Completed Charity opportunities must be the exact public-order prefix.');
|
||||
return { version: 1, turn: context.turn, order: [...context.order],
|
||||
remaining: context.order.slice(context.ready.length) };
|
||||
}
|
||||
|
||||
function currentStarted(context: CharityOrderContext, frame: CharityOrder): boolean {
|
||||
return frame.version === 2 ? 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] })),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCharityOrder(context: CharityOrderContext, frame: CharityOrder): void {
|
||||
validateContext(context);
|
||||
requireOrder(record(frame) && Object.keys(frame).sort().join(',') === 'order,remaining,turn,version' &&
|
||||
frame.version === 1 && frame.turn === context.turn && ids(frame.order) && ids(frame.remaining),
|
||||
requireOrder(record(frame) && (frame.version === 1 || frame.version === 2) &&
|
||||
Object.keys(frame).sort().join(',') === (frame.version === 1
|
||||
? 'order,remaining,turn,version' : 'completed,order,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) &&
|
||||
same(frame.remaining, context.order.slice(context.ready.length)),
|
||||
requireOrder(same(frame.order, context.order), 'Charity must retain its original physical order.');
|
||||
if (frame.version === 1) requireOrder(same(context.ready, frame.order.slice(0, context.ready.length)) &&
|
||||
same(frame.remaining, frame.order.slice(context.ready.length)),
|
||||
'Charity must retain its original public order and exact remaining suffix.');
|
||||
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);
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
function eventFor(turn: number, order: readonly string[], remaining: readonly string[],
|
||||
completed: readonly string[], started: boolean, revision: number): string {
|
||||
return `saphoCharity:${JSON.stringify([turn, order, remaining, completed, started, revision])}`;
|
||||
}
|
||||
function advanceCompleted(remaining: readonly string[], completed: readonly string[], started: boolean,
|
||||
next: readonly string[]) {
|
||||
requireOrder(next.length >= completed.length && same(next.slice(0, completed.length), completed),
|
||||
'Sapho cannot reopen or replace completed Charity opportunities.');
|
||||
const count = next.length - completed.length;
|
||||
requireOrder(count <= remaining.length && same(next.slice(completed.length), remaining.slice(0, count)),
|
||||
'Charity completions must follow the current remaining opportunity order.');
|
||||
return { remaining: remaining.slice(count), completed: [...next], started: started && count === 0 };
|
||||
}
|
||||
function reordered(order: readonly string[], remaining: readonly string[], completed: readonly string[],
|
||||
started: boolean, use: Pick<SaphoCharityOrderUse, 'event' | 'player' | 'mode'>) {
|
||||
requireOrder(remaining.includes(use.player), 'Only a pending faction may change its Charity position.');
|
||||
requireOrder(!started || use.player !== remaining[0],
|
||||
'Juice of Sapho cannot split a Charity opportunity that has already started.');
|
||||
try {
|
||||
const next = reorderOrderedOpportunity({ event: use.event, eligible: order, remaining, completed,
|
||||
current: remaining[0] ?? null, currentStarted: started },
|
||||
{ event: use.event, player: use.player, position: use.mode });
|
||||
requireOrder(!same(next.remaining, remaining), 'This faction is already in that remaining Charity position.');
|
||||
return [...next.remaining];
|
||||
} catch (error) {
|
||||
if (error instanceof OrderedOpportunityError) throw new CharityOrderError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function validateSaphoHistory(frame: Extract<CharityOrder, { version: 2 }>): void {
|
||||
requireOrder(Array.isArray(frame.uses) && frame.uses.length > 0,
|
||||
'Reordered Charity needs its actual Juice of Sapho use history.');
|
||||
let remaining = [...frame.order], completed: string[] = [], started = false;
|
||||
for (const [index, use] of frame.uses.entries()) {
|
||||
requireOrder(record(use) && Object.keys(use).sort().join(',') === 'completed,event,mode,player,started' &&
|
||||
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.');
|
||||
const advanced = advanceCompleted(remaining, completed, started, use.completed);
|
||||
requireOrder(!advanced.started || use.started,
|
||||
'A later Sapho use cannot unlock the current started Charity opportunity.');
|
||||
requireOrder(!use.started || advanced.remaining.length > 0,
|
||||
'Only a current Charity opportunity can have started.');
|
||||
requireOrder(use.event === eventFor(frame.turn, frame.order, advanced.remaining,
|
||||
advanced.completed, use.started, index), 'The saved Sapho Charity use lost its original public boundary.');
|
||||
remaining = reordered(frame.order, advanced.remaining, advanced.completed, use.started, use);
|
||||
completed = advanced.completed;
|
||||
started = use.started;
|
||||
}
|
||||
const advanced = advanceCompleted(remaining, completed, started, frame.completed);
|
||||
requireOrder(same(frame.remaining, advanced.remaining) && (!advanced.started || frame.started),
|
||||
'The Charity frame contradicts its Sapho history or unlocks a started opportunity.');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function reorderSaphoCharityOrder(context: CharityOrderContext, frame: CharityOrder, request: {
|
||||
event: string; player: string; mode: 'first' | 'last';
|
||||
}): { order: CharityOrder; afterCurrent?: 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 }] };
|
||||
validateCharityOrder(context, order);
|
||||
return { order, ...(started ? { afterCurrent: frame.remaining[0] } : {}) };
|
||||
}
|
||||
|
||||
export function charityOrderBlock(context: CharityOrderContext, frame: CharityOrder, player: string): string | null {
|
||||
@@ -68,11 +186,25 @@ export function charityOrderBlock(context: CharityOrderContext, frame: CharityOr
|
||||
return frame.remaining[0] === player ? null : 'Wait for your Charity opportunity.';
|
||||
}
|
||||
|
||||
/** Pre-claim quote: the caller records the accepted native claim in the same
|
||||
* transaction. Version one derives that start from context, retaining its bytes.
|
||||
*/
|
||||
export function startCharityOrder(context: CharityOrderContext, frame: CharityOrder, player: string): CharityOrder {
|
||||
const blocked = charityOrderBlock(context, frame, player);
|
||||
requireOrder(blocked === null, blocked ?? 'This Charity opportunity is unavailable.');
|
||||
const next = copy(frame);
|
||||
return next.version === 1 ? next : { ...next, started: true };
|
||||
}
|
||||
|
||||
/** Consume the pre-finish ready prefix. The caller appends this actor to ready
|
||||
* with the native claim/pass in the same commit; no wallet or card changes occur here.
|
||||
*/
|
||||
export function finishCharityOrder(context: CharityOrderContext, frame: CharityOrder, player: string): CharityOrder {
|
||||
const blocked = charityOrderBlock(context, frame, player);
|
||||
requireOrder(blocked === null, blocked ?? 'This Charity opportunity is unavailable.');
|
||||
return { version: 1, turn: frame.turn, order: [...frame.order], remaining: frame.remaining.slice(1) };
|
||||
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 };
|
||||
validateCharityOrder({ ...context, ready: [...context.ready, player] }, next);
|
||||
return next;
|
||||
}
|
||||
+84
-3
@@ -44,7 +44,8 @@ import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
|
||||
import { createRevivalOrder, validateRevivalOrder, revivalOrderBlock, startRevivalOrder,
|
||||
saphoRevivalOrderEvent, reorderSaphoRevivalOrder,
|
||||
finishRevivalOrder, RevivalOrderError, type RevivalOrder } from './revival-order';
|
||||
import { createCharityOrder, validateCharityOrder, charityOrderBlock,
|
||||
import { createCharityOrder, validateCharityOrder, charityOrderBlock, startCharityOrder,
|
||||
saphoCharityOrderEvent, reorderSaphoCharityOrder,
|
||||
finishCharityOrder, CharityOrderError, type CharityOrder } from './charity-order';
|
||||
import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival';
|
||||
import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival';
|
||||
@@ -1608,6 +1609,15 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'saphoCharityDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
event: string;
|
||||
mode: 'first' | 'last';
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'saphoRevivalDiscard';
|
||||
player: string;
|
||||
@@ -5852,6 +5862,17 @@ type SaphoExtortionDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'saphoExtortionDiscard' }
|
||||
>;
|
||||
type SaphoCharityDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'saphoCharityDiscard' }
|
||||
>;
|
||||
function saphoCharityDiscardSignature(g: Game, c: SaphoCharityDiscardContinuation) {
|
||||
return JSON.stringify({player:c.player,card:c.card,event:c.event,mode:c.mode,
|
||||
parent:nullentropyParentSignature(g,c.resume,c.event),turn:g.turn,phase:g.phase,
|
||||
advanced:g.advanced,order:g.order,charityOrder:g.charityOrder,active:g.active,ready:g.ready,
|
||||
inflation:g.inflation,choamCharity:g.choamCharity,
|
||||
players:g.players.map(p=>({id:p.id,faction:p.faction,spice:p.spice,bribes:p.bribes,charityTurn:p.charityTurn}))});
|
||||
}
|
||||
type SaphoRevivalDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'saphoRevivalDiscard' }
|
||||
@@ -7484,6 +7505,18 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
c.stateSignature === distransDiscardSignature(g, c),
|
||||
'The completed private Distrans transfer no longer matches its fresh discard.',
|
||||
);
|
||||
} 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 &&
|
||||
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 &&
|
||||
richeseCardDefinition(entry.card)?.card.effect==='juiceOfSapho' &&
|
||||
!c.resume.response && !c.resume.decision && !c.resume.pendingKarama && !c.resume.phaseOpening &&
|
||||
c.stateSignature===saphoCharityDiscardSignature(g,c),
|
||||
'The used Juice of Sapho no longer matches its committed Charity order.');
|
||||
charityOrderIntegrity(g);
|
||||
} else if (continuation?.kind === 'saphoRevivalDiscard') {
|
||||
const c=continuation,entry=batch.entries[0],frame=g.revivalOrder;
|
||||
const latest=frame?.version===2 ? frame.uses.at(-1) : null;
|
||||
@@ -8668,6 +8701,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'nexusChoamBetrayal' &&
|
||||
continuation.kind !== 'saphoMovementDiscard' &&
|
||||
continuation.kind !== 'saphoRevivalDiscard' &&
|
||||
continuation.kind !== 'saphoCharityDiscard' &&
|
||||
continuation.kind !== 'saphoExtortionDiscard' &&
|
||||
continuation.kind !== 'saphoAggressorDiscard' &&
|
||||
continuation.kind !== 'saphoAuctionDiscard' &&
|
||||
@@ -8738,6 +8772,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'saphoAuctionDiscard' &&
|
||||
continuation.kind !== 'saphoMovementDiscard' &&
|
||||
continuation.kind !== 'saphoRevivalDiscard' &&
|
||||
continuation.kind !== 'saphoCharityDiscard' &&
|
||||
continuation.kind !== 'saphoExtortionDiscard' &&
|
||||
continuation.kind !== 'battleResolved' &&
|
||||
continuation.kind !== 'winnerMandatoryDiscard' &&
|
||||
@@ -8754,6 +8789,11 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaBattleDiscard') return false;
|
||||
if (continuation.kind === 'battleResolved' &&
|
||||
!g.lastBattleContext?.mandatoryDiscard) return false;
|
||||
if (continuation.kind === 'saphoCharityDiscard' &&
|
||||
(!g.advanced || g.phase!==2 || g.charityOrder?.version!==2 ||
|
||||
continuation.resume.response || continuation.resume.decision ||
|
||||
continuation.resume.pendingKarama || continuation.resume.phaseOpening ||
|
||||
g.response || g.decision || g.pendingRevival)) return false;
|
||||
if (continuation.kind === 'saphoRevivalDiscard' &&
|
||||
(!g.advanced || g.phase!==4 || g.revivalOrder?.version!==2 ||
|
||||
continuation.resume.response || continuation.resume.decision ||
|
||||
@@ -9214,6 +9254,12 @@ function finishTreacheryDiscard(g: Game, claim?: { player: string; card: string
|
||||
g.decision = next.resume.decision;
|
||||
g.pendingKarama = next.resume.pendingKarama;
|
||||
g.phaseOpening = next.resume.phaseOpening;
|
||||
} else if (next.kind === 'saphoCharityDiscard') {
|
||||
g.response=next.resume.response;
|
||||
g.decision=next.resume.decision;
|
||||
g.pendingKarama=next.resume.pendingKarama;
|
||||
g.phaseOpening=next.resume.phaseOpening;
|
||||
charityOrderIntegrity(g);
|
||||
} else if (next.kind === 'saphoRevivalDiscard') {
|
||||
g.response=next.resume.response;
|
||||
g.decision=next.resume.decision;
|
||||
@@ -25737,7 +25783,7 @@ function offerMoritaniDuke(g: Game) {
|
||||
return true;
|
||||
}
|
||||
type SaphoOption = {
|
||||
scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion' | 'revival';
|
||||
scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion' | 'revival' | 'charity';
|
||||
event: string;
|
||||
mode: 'first' | 'last';
|
||||
/** Display only; action submission keeps scope/event/mode. */
|
||||
@@ -25919,6 +25965,18 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
|
||||
)
|
||||
return [];
|
||||
const options: SaphoOption[] = [];
|
||||
if (g.phase===2 && g.advanced && g.charityOrder) {
|
||||
const context=charityOrderContext(g);
|
||||
const event=charityOrderRule(()=>saphoCharityOrderEvent(context,g.charityOrder!));
|
||||
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} : {})});
|
||||
} catch(error) {
|
||||
if (!(error instanceof CharityOrderError)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (g.phase===4 && g.advanced && g.revivalOrder) {
|
||||
const context=revivalOrderContext(g);
|
||||
const event=revivalOrderRule(()=>saphoRevivalOrderEvent(context,g.revivalOrder!));
|
||||
@@ -26107,6 +26165,26 @@ function playSapho(g: Game, p: Player, action: Action) {
|
||||
}
|
||||
}
|
||||
settleRicheseLot(g);
|
||||
} else if (option.scope === 'charity') {
|
||||
requireRule(g.charityOrder,'The original ordered Charity is no longer available.');
|
||||
const quote=charityOrderRule(()=>reorderSaphoCharityOrder(charityOrderContext(g),g.charityOrder!,
|
||||
{event:option.event,player:p.id,mode:option.mode}));
|
||||
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.`,
|
||||
{faction:p.faction,name:'Juice of Sapho'});
|
||||
if (g.semutaPreview) {
|
||||
const continuation:SaphoCharityDiscardContinuation={kind:'saphoCharityDiscard',player:p.id,card:used.id,
|
||||
event:option.event,mode:option.mode,
|
||||
resume:{response:g.response,decision:g.decision,pendingKarama:g.pendingKarama,phaseOpening:g.phaseOpening},
|
||||
stateSignature:''};
|
||||
const entries=[{card:used,discardedBy:p.id,publicFace:true}];
|
||||
if(semutaOfferSupported(g,continuation,entries)) {
|
||||
continuation.stateSignature=saphoCharityDiscardSignature(g,continuation);
|
||||
stageTreacheryDiscard(g,'sapho:charity',entries,continuation);
|
||||
}
|
||||
}
|
||||
} else if (option.scope === 'revival') {
|
||||
requireRule(g.revivalOrder,'The original ordered Revival is no longer available.');
|
||||
const quote=revivalOrderRule(()=>reorderSaphoRevivalOrder(revivalOrderContext(g),g.revivalOrder!,
|
||||
@@ -26734,7 +26812,8 @@ function revivalOrderContext(g: Game) {
|
||||
return {turn:g.turn,order:g.order,ready:g.ready};
|
||||
}
|
||||
function charityOrderContext(g: Game) {
|
||||
return {turn:g.turn,order:g.order,players:g.players,ready:g.ready};
|
||||
return {turn:g.turn,order:g.order,players:g.players,ready:g.ready,
|
||||
claimed:g.players.filter(p=>p.charityTurn===g.turn).map(p=>p.id)};
|
||||
}
|
||||
function charityOrderRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
@@ -41607,6 +41686,8 @@ function applyActionInner(
|
||||
p.charityTurn !== g.turn,
|
||||
'Charity may be claimed only once per turn.',
|
||||
);
|
||||
if (g.charityOrder)
|
||||
g.charityOrder = charityOrderRule(() => startCharityOrder(charityOrderContext(g),g.charityOrder!,id));
|
||||
p.charityTurn = g.turn;
|
||||
if (advancedBG && p.spice >= 2)
|
||||
g.response = {
|
||||
|
||||
+13
-7
@@ -122,7 +122,7 @@ const phaseDetails = [
|
||||
[
|
||||
'If CHOAM is present, first resolve its income response. CHOAM then pays other factions’ charity; if its income was canceled, charity comes from the bank. Use Claim charity when eligible to bring ordinary available spice to two.',
|
||||
'New Advanced Charity follows the First Player counterclockwise. Only the current player may claim or finish; a claim and any cancellation response finish before the player chooses Finish Charity turn. You may finish without claiming. Waiting and completed seats cannot take another Charity turn.',
|
||||
'Basic and already-opened older unstamped Advanced Charity keep concurrent readiness. Mark yourself ready after making your choice. New Truthtrance answers do not reopen completed ordered turns. Sapho first/last composition for the newly ordered phase remains unfinished.',
|
||||
'Basic and already-opened older unstamped Advanced Charity keep concurrent readiness. Mark yourself ready after making your choice. New Truthtrance answers do not reopen completed ordered turns. Owned Sapho changes only remaining Charity order; an accepted current claim provisionally protects that turn before the reordered tail.',
|
||||
],
|
||||
[
|
||||
'Before each auction, resolve the Atreides foresight response. The card stays hidden until that response is allowed. Cancellation affects this card only.',
|
||||
@@ -503,10 +503,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'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.',
|
||||
'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 Revival, take your unfinished opportunity first or last, then discard Sapho. If the current player has already begun a native return or negotiation, provisional queued timing lets that player finish unchanged; first means first among the later unstarted turns. You cannot split your own started turn, repeat a completed turn or consume the card for a position you already hold. Basic and older concurrent Revival phases have no ordered scope.',
|
||||
'Finish existing shipment, movement, card preparation and pending 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.',
|
||||
'In the explicitly opted-in Semuta preview, clean Once Around, movement and ordered-Revival discards pause after their 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. Revival retains its current participant, requests and allowances through claim/pass. 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.',
|
||||
'In the explicitly opted-in Semuta preview, clean Once Around, movement and ordered Charity/Revival discards pause after their 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. Charity/Revival retain their current participant, claims or requests and allowances through Semuta claim/pass. 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, cyclic lot duration and queued movement timing are provisional conventions. Other phase ordering and splitting your own started combined turn remain unfinished. Silent bids retain simultaneous timing and storm tie order.',
|
||||
],
|
||||
example:
|
||||
@@ -522,13 +523,13 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Finite Once Around, current-lot cyclic bidding, clean/queued movement and ordered Advanced Revival, remaining battle-choice and late aggressor modes are connected. Queued timing retains the started actor; Revival keeps its requests/allowances and separate physical order through exact Semuta disposal. Tail timing is visibly provisional. Other phases and split-own-turn mode 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 the started actor; Charity/Revival keep native claims or requests/allowances and separate physical order through their Semuta disposal. Tail timing is visibly provisional. Other phases and split-own-turn mode remain unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Owner-only first/last and unresolved-battle aggressor controls retain public priority. Queued movement names the started actor who finishes first, explains provisional tail ordering and sends only original action fields; afterCurrent is display metadata. Final battle/cyclic controls retain their existing scope. Older battles keep early timing.',
|
||||
'Owner-only first/last and unresolved-battle aggressor controls retain public priority. Queued movement and Charity/Revival name the started actor who finishes first, explain provisional tail ordering and sends only original action fields; afterCurrent is display metadata. Final battle/cyclic controls retain their existing scope. Older battles keep early timing.',
|
||||
},
|
||||
{
|
||||
area: 'AI',
|
||||
@@ -549,6 +550,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'18 October Extortion:9-file115/115, types/lint/build and six original-action/rendered-table programmes pass. Public Basic/Advanced cache1, placed/revealed Terror, paid entry1, native Mentat placement-before-bank5 and an earlier decline use120/139 source actions. First/payment3 and last/payment3 or all-decline use2–4 continuations, preserving one payment and native token retirement/rotation. Four profiles make eight legal holder/payer proposals and complete. Pure replay/no-op/recovered reuse cases are separate. One deliberate-invalid-mode test cast was repaired; all checks then passed. Prior Guild, aggressor and cyclic evidence remains in linked feature docs. No browser clicks, visual/deployed, direct combined Semuta claim/capture, complete-mode or final-assurance acceptance',
|
||||
evidence: [
|
||||
'game/sapho-movement-order.ts',
|
||||
'game/charity-order.ts',
|
||||
'tests/sapho-charity-order.test.ts',
|
||||
'tests/fixture-sapho-charity-order.ts',
|
||||
'tests/sapho-charity-order-engine.test.ts',
|
||||
'tests/sapho-charity-order-controls.test.tsx',
|
||||
'game/revival-order.ts',
|
||||
'tests/sapho-revival-order.test.ts',
|
||||
'tests/fixture-sapho-revival-order.ts',
|
||||
@@ -2596,15 +2602,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A claim and its native responses retain the current player. Completing a Karama response does not finish or consume the responder’s own Charity turn.',
|
||||
'Waiting and completed seats cannot claim or finish. A new Truthtrance answer cannot reopen a completed ordered turn, while a remaining owned future claim may still support a binding answer.',
|
||||
'Basic and already-opened unstamped Advanced phases keep concurrent readiness. Inflation, Homeworld bonuses and CHOAM funding retain their existing calculation and limitations.',
|
||||
'Sapho first/last composition, broader interactions and full Advanced acceptance remain unfinished.',
|
||||
'Owned Juice of Sapho changes the remaining turn order first/last. A current accepted claim provisionally protects that turn before the reordered later queue. Completed turns stay closed and no additional claim is granted. Broader interactions and full Advanced acceptance remain unfinished.',
|
||||
],
|
||||
related: ['charity', 'choam-economy', 'bg-charity-karama', 'truthtrance-turn-actions'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Fresh native order, current-only claim/finish and original response/closing lifecycle are connected; Sapho composition remains.', evidence: ['game/charity-order.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Fresh native order, current-only claim/finish and original response/closing lifecycle are connected. Sapho reorders remaining opportunities with provisional started-current protection and original claim limits.', evidence: ['game/charity-order.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Persistent current/wait/completed status and native claim/finish controls retain funding explanations.', evidence: ['components/charity-claim.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing policies wait for their actual opportunity and use the native claim and finish actions; strategy and calibration remain deferred.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Source cutover, Basic/legacy behavior, remaining entitlements and interaction boundaries are explicit.', evidence: ['docs/ADVANCED_CHARITY_ORDER.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, native and control sources; frozen runtime evidence and complete-mode acceptance are separate.', evidence: ['tests/charity-order.test.ts', 'tests/advanced-charity-order-engine.test.ts', 'tests/charity-claim-controls.test.tsx'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused helper, native and control sources; frozen runtime evidence and complete-mode acceptance are separate.', evidence: ['tests/charity-order.test.ts', 'tests/advanced-charity-order-engine.test.ts', 'tests/charity-claim-controls.test.tsx', 'tests/sapho-charity-order.test.ts', 'tests/sapho-charity-order-engine.test.ts', 'tests/sapho-charity-order-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user