Support Sapho ordering during Advanced Revival
This commit is contained in:
1 parent
011b6aa096
commit
82370d906f
15 files changed
+1092
-28
No files matched your search
+81
-1
@@ -1,5 +1,6 @@
|
||||
import { ordinaryDiscardIncomeMatches } from './ordinary-discard-income';
|
||||
import { createRevivalOrder, validateRevivalOrder, revivalOrderBlock, startRevivalOrder,
|
||||
saphoRevivalOrderEvent, reorderSaphoRevivalOrder,
|
||||
finishRevivalOrder, RevivalOrderError, type RevivalOrder } from './revival-order';
|
||||
import { semutaTerminalArrivalSupported } from './semuta-terminal-arrival';
|
||||
import { semutaDisasterArrivalSupported } from './semuta-disaster-arrival';
|
||||
@@ -1543,6 +1544,15 @@ export type Game = {
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'saphoRevivalDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
event: string;
|
||||
mode: 'first' | 'last';
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
| {
|
||||
kind: 'saphoExtortionDiscard';
|
||||
player: string;
|
||||
@@ -5699,6 +5709,19 @@ type SaphoExtortionDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'saphoExtortionDiscard' }
|
||||
>;
|
||||
type SaphoRevivalDiscardContinuation = Extract<
|
||||
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
{ kind: 'saphoRevivalDiscard' }
|
||||
>;
|
||||
function saphoRevivalDiscardSignature(g: Game, c: SaphoRevivalDiscardContinuation) {
|
||||
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,revivalOrder:g.revivalOrder,active:g.active,ready:g.ready,
|
||||
requests:g.revivalRequests,rules:g.revivalRules,extra:g.emperorExtra,free:g.freeRevival,
|
||||
players:g.players.map(p=>({id:p.id,faction:p.faction,reserves:p.reserves,tanks:p.tanks,
|
||||
revived:p.revived,freeForcesRevived:p.freeForcesRevived,leaderRevived:p.leaderRevived,
|
||||
elites:p.elites}))});
|
||||
}
|
||||
function saphoExtortionDiscardSignature(g: Game, c: SaphoExtortionDiscardContinuation) {
|
||||
return JSON.stringify({player: c.player, card: c.card, event: c.event, mode: c.mode,
|
||||
parent: nullentropyParentSignature(g, c.resume, c.event),
|
||||
@@ -7296,6 +7319,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 === 'saphoRevivalDiscard') {
|
||||
const c=continuation,entry=batch.entries[0],frame=g.revivalOrder;
|
||||
const latest=frame?.version===2 ? frame.uses.at(-1) : null;
|
||||
requireRule(g.advanced && g.phase===4 && frame?.version===2 &&
|
||||
latest?.event===c.event && latest.player===c.player && latest.mode===c.mode &&
|
||||
batch.cause==='sapho:revival' && 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===saphoRevivalDiscardSignature(g,c),
|
||||
'The used Juice of Sapho no longer matches its committed Revival order.');
|
||||
revivalOrderIntegrity(g);
|
||||
} else if (continuation?.kind === 'saphoExtortionDiscard') {
|
||||
const c = continuation, entry = batch.entries[0], state = g.moritaniExtortion;
|
||||
const latest = state?.saphoOrder?.uses.at(-1), decision = c.resume.decision;
|
||||
@@ -8462,6 +8497,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'nexusChoamTrade' &&
|
||||
continuation.kind !== 'nexusChoamBetrayal' &&
|
||||
continuation.kind !== 'saphoMovementDiscard' &&
|
||||
continuation.kind !== 'saphoRevivalDiscard' &&
|
||||
continuation.kind !== 'saphoExtortionDiscard' &&
|
||||
continuation.kind !== 'saphoAggressorDiscard' &&
|
||||
continuation.kind !== 'saphoAuctionDiscard' &&
|
||||
@@ -8531,6 +8567,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'ornithopterDiscard' &&
|
||||
continuation.kind !== 'saphoAuctionDiscard' &&
|
||||
continuation.kind !== 'saphoMovementDiscard' &&
|
||||
continuation.kind !== 'saphoRevivalDiscard' &&
|
||||
continuation.kind !== 'saphoExtortionDiscard' &&
|
||||
continuation.kind !== 'battleResolved' &&
|
||||
continuation.kind !== 'winnerMandatoryDiscard' &&
|
||||
@@ -8547,6 +8584,11 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'karamaBattleDiscard') return false;
|
||||
if (continuation.kind === 'battleResolved' &&
|
||||
!g.lastBattleContext?.mandatoryDiscard) return false;
|
||||
if (continuation.kind === 'saphoRevivalDiscard' &&
|
||||
(!g.advanced || g.phase!==4 || g.revivalOrder?.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 === 'saphoExtortionDiscard' &&
|
||||
(g.phase !== 8 || g.moritaniExtortion?.stage !== 'payment' ||
|
||||
continuation.resume.decision?.kind !== 'moritaniExtortion' ||
|
||||
@@ -8989,6 +9031,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 === 'saphoRevivalDiscard') {
|
||||
g.response=next.resume.response;
|
||||
g.decision=next.resume.decision;
|
||||
g.pendingKarama=next.resume.pendingKarama;
|
||||
g.phaseOpening=next.resume.phaseOpening;
|
||||
revivalOrderIntegrity(g);
|
||||
} else if (next.kind === 'saphoExtortionDiscard') {
|
||||
g.response = next.resume.response;
|
||||
g.decision = next.resume.decision;
|
||||
@@ -22690,7 +22738,7 @@ function offerMoritaniDuke(g: Game) {
|
||||
return true;
|
||||
}
|
||||
type SaphoOption = {
|
||||
scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion';
|
||||
scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion' | 'revival';
|
||||
event: string;
|
||||
mode: 'first' | 'last';
|
||||
/** Display only; action submission keeps scope/event/mode. */
|
||||
@@ -22871,6 +22919,18 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
|
||||
)
|
||||
return [];
|
||||
const options: SaphoOption[] = [];
|
||||
if (g.phase===4 && g.advanced && g.revivalOrder) {
|
||||
const context=revivalOrderContext(g);
|
||||
const event=revivalOrderRule(()=>saphoRevivalOrderEvent(context,g.revivalOrder!));
|
||||
for (const mode of ['first','last'] as const) {
|
||||
try {
|
||||
const quote=reorderSaphoRevivalOrder(context,g.revivalOrder,{event,player:p.id,mode});
|
||||
options.push({scope:'revival',event,mode,...(quote.afterCurrent ? {afterCurrent:quote.afterCurrent} : {})});
|
||||
} catch(error) {
|
||||
if (!(error instanceof RevivalOrderError)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (saphoExtortionDecision(g)) {
|
||||
const context = extortionSaphoContext(g), event = extortionRule(() => saphoExtortionEvent(context));
|
||||
for (const mode of ['first', 'last'] as const) {
|
||||
@@ -23046,6 +23106,26 @@ function playSapho(g: Game, p: Player, action: Action) {
|
||||
}
|
||||
}
|
||||
settleRicheseLot(g);
|
||||
} else if (option.scope === 'revival') {
|
||||
requireRule(g.revivalOrder,'The original ordered Revival is no longer available.');
|
||||
const quote=revivalOrderRule(()=>reorderSaphoRevivalOrder(revivalOrderContext(g),g.revivalOrder!,
|
||||
{event:option.event,player:p.id,mode:option.mode}));
|
||||
const used=discard(g,p,'richese-juice-of-sapho');
|
||||
g.revivalOrder=quote.order;
|
||||
g.active=quote.order.remaining[0] ?? null;
|
||||
log(g,`${p.name} discarded Juice of Sapho to take their remaining Revival turn ${option.mode}.${quote.afterCurrent ? ` Provisional queued order: ${getPlayer(g,quote.afterCurrent).name} finishes the started turn first.` : ''} Completed turns, native allowances and physical First Player order stay unchanged.`,
|
||||
{faction:p.faction,name:'Juice of Sapho'});
|
||||
if (g.semutaPreview) {
|
||||
const continuation:SaphoRevivalDiscardContinuation={kind:'saphoRevivalDiscard',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=saphoRevivalDiscardSignature(g,continuation);
|
||||
stageTreacheryDiscard(g,'sapho:revival',entries,continuation);
|
||||
}
|
||||
}
|
||||
} else if (option.scope === 'extortion') {
|
||||
const state = g.moritaniExtortion!;
|
||||
const ordered = extortionRule(() => reorderSaphoExtortion(extortionSaphoContext(g),
|
||||
|
||||
+10
-4
@@ -46,7 +46,7 @@ export const PHASE_HELP = [
|
||||
'Reveal a spice blow, resolve worms and card responses, then finish any Nexus and Fremen rides.',
|
||||
'Claim charity once if below two spice. Advanced Bene Gesserit receives two spice regardless of wealth. Then mark yourself ready.',
|
||||
'Bid or pass in turn. A winning bidder chooses how to pay before income and bonus-card responses resolve.',
|
||||
'Return eligible forces and leaders from the tanks to your reserves. New Advanced Revival phases proceed from the First Player counterclockwise: finish your own force and leader choices before the next player acts. Basic and already-opened older phases retain their current readiness timing.',
|
||||
'Return eligible forces and leaders from the tanks to your reserves. New Advanced Revival phases begin from the First Player counterclockwise; Sapho may change unfinished positions. Finish your own force and leader choices before the next player acts. Basic and already-opened older phases retain their current readiness timing.',
|
||||
'Ship before moving. Select a destination sector and, for a move, the forces leaving one territory.',
|
||||
'Choose a battle, resolve faction powers, seal plans, decide traitors, then choose which winning cards to keep.',
|
||||
'Forces collect spice in their own sector. Review the results, then continue to Mentat Pause.',
|
||||
@@ -501,9 +501,10 @@ 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 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 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.',
|
||||
'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.',
|
||||
'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:
|
||||
@@ -519,7 +520,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Finite Once Around, current-lot cyclic bidding, clean combined-turn and queued unstarted-tail movement, remaining battle-choice and late aggressor modes are connected. Queued movement retains the started actor, resources/counters and native Guild choice; exact Semuta disposal preserves that same current actor. 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 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.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
@@ -546,6 +547,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/revival-order.ts',
|
||||
'tests/sapho-revival-order.test.ts',
|
||||
'tests/fixture-sapho-revival-order.ts',
|
||||
'tests/sapho-revival-order-engine.test.ts',
|
||||
'tests/sapho-revival-order-controls.test.tsx',
|
||||
'tests/sapho-movement-order.test.ts',
|
||||
'tests/sapho-started-movement-controls.test.tsx',
|
||||
'tests/fixture-sapho-started-movement.ts',
|
||||
@@ -3789,7 +3795,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Tleilaxu can answer another player’s request, grant a larger limit or offer an ally discount outside its own turn. Fremen can grant allied free revival. Emperor can fund its ally’s extra forces during that ally’s opportunity. Finish every accepted return and its required responses before ending the turn.',
|
||||
'Choose Finish Revival turn when done. An unanswered leader request is withdrawn by this explicit finish. Completed native turns stay finished. Card effects, Nexus cards, special Karama and their responses keep their separate timing; taking part in a response does not consume your own turn.',
|
||||
'Basic retains concurrent readiness. An already-opened older Advanced Revival finishes its saved readiness timing; the next new Advanced Revival uses player order. This does not redeal or restart a game.',
|
||||
'Sapho’s additional Revival-order adapter remains unfinished. Full Advanced games, optional combinations and final multiplayer/deployed acceptance remain open.',
|
||||
'Sapho may change an unfinished Revival position first or last. A started current player finishes first under visibly provisional queued timing. Native requests, allowances and completed turns remain; only the remaining queue changes. Full Advanced games, optional combinations and final multiplayer/deployed acceptance remain open.',
|
||||
],
|
||||
checklist: [
|
||||
{area:'Implementation',status:'Partial',detail:'Fresh Advanced Revival has one current native opportunity, explicit completion and retained response ownership. Leader grouping is an explicit composition; Basic and unstamped current phases retain prior timing.',evidence:['game/revival-order.ts','game/engine.ts']},
|
||||
|
||||
+108
-8
@@ -1,14 +1,26 @@
|
||||
import { reorderOrderedOpportunity, OrderedOpportunityError } from './ordered-opportunity';
|
||||
|
||||
/** One native Revival opportunity per faction in public First Player order.
|
||||
* Leader decisions remain within that faction's opportunity; this grouping is
|
||||
* an implementation composition of the supplied Advanced physical9/printed7. */
|
||||
export type RevivalOrder = {
|
||||
version: 1;
|
||||
type RevivalOrderFields = {
|
||||
turn: number;
|
||||
original: string[];
|
||||
remaining: string[];
|
||||
completed: string[];
|
||||
started: boolean;
|
||||
};
|
||||
export type SaphoRevivalOrderUse = {
|
||||
event: string;
|
||||
player: string;
|
||||
mode: 'first' | 'last';
|
||||
completed: string[];
|
||||
started: boolean;
|
||||
};
|
||||
export type RevivalOrder = RevivalOrderFields & (
|
||||
| { version: 1 }
|
||||
| { version: 2; uses: SaphoRevivalOrderUse[] }
|
||||
);
|
||||
export type RevivalOrderContext = {
|
||||
turn: number;
|
||||
order: readonly string[];
|
||||
@@ -34,34 +46,122 @@ function validateContext(context: RevivalOrderContext): void {
|
||||
ids(context.order) && context.order.length >= 2 && context.order.length <= 6 &&
|
||||
new Set(context.order).size === context.order.length,
|
||||
'Revival needs a current turn and two through six distinct factions in public order.');
|
||||
requireOrder(ids(context.ready) && same(context.ready, context.order.slice(0, context.ready.length)),
|
||||
'Completed Revival factions must be the public-order prefix.');
|
||||
requireOrder(ids(context.ready) && new Set(context.ready).size === context.ready.length &&
|
||||
context.ready.every(player => context.order.includes(player)),
|
||||
'Completed Revival factions must be distinct seated participants.');
|
||||
}
|
||||
function copy(frame: RevivalOrder): RevivalOrder {
|
||||
return { version: 1, turn: frame.turn, original: [...frame.original],
|
||||
const fields = { turn: frame.turn, original: [...frame.original],
|
||||
remaining: [...frame.remaining], completed: [...frame.completed], started: frame.started };
|
||||
return frame.version === 1 ? { version: 1, ...fields }
|
||||
: { version: 2, ...fields, uses: frame.uses.map(use => ({ ...use, completed: [...use.completed] })) };
|
||||
}
|
||||
|
||||
export function createRevivalOrder(context: RevivalOrderContext): RevivalOrder {
|
||||
validateContext(context);
|
||||
requireOrder(same(context.ready, context.order.slice(0, context.ready.length)),
|
||||
'Completed Revival factions must be the public-order prefix.');
|
||||
return { version: 1, turn: context.turn, original: [...context.order],
|
||||
remaining: context.order.slice(context.ready.length), completed: [...context.ready], started: false };
|
||||
}
|
||||
|
||||
export function validateRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder): void {
|
||||
validateContext(context);
|
||||
requireOrder(record(frame) && Object.keys(frame).sort().join(',') ===
|
||||
'completed,original,remaining,started,turn,version' && frame.version === 1 &&
|
||||
requireOrder(record(frame) && (frame.version === 1 || frame.version === 2) &&
|
||||
Object.keys(frame).sort().join(',') === (frame.version === 1
|
||||
? 'completed,original,remaining,started,turn,version' : 'completed,original,remaining,started,turn,uses,version') &&
|
||||
Number.isSafeInteger(frame.turn) && frame.turn === context.turn &&
|
||||
ids(frame.original) && ids(frame.remaining) && ids(frame.completed) && typeof frame.started === 'boolean',
|
||||
'The current Revival order is malformed or belongs to another turn.');
|
||||
requireOrder(same(frame.original, context.order) && same(frame.completed, context.ready) &&
|
||||
requireOrder(same(frame.original, context.order) && same(frame.completed, context.ready),
|
||||
'The Revival order must retain its original public order and completed factions.');
|
||||
if (frame.version === 1) requireOrder(same(frame.completed, frame.original.slice(0, frame.completed.length)) &&
|
||||
same(frame.remaining, frame.original.slice(frame.completed.length)),
|
||||
'The Revival order must retain the original public order and its completed prefix.');
|
||||
else validateSaphoHistory(frame);
|
||||
requireOrder(frame.remaining.length > 0 || !frame.started,
|
||||
'Completed Revival cannot retain a started opportunity.');
|
||||
}
|
||||
|
||||
function eventFor(turn: number, original: readonly string[], remaining: readonly string[],
|
||||
completed: readonly string[], started: boolean, revision: number): string {
|
||||
return `saphoRevival:${JSON.stringify([turn, original, remaining, completed, started, revision])}`;
|
||||
}
|
||||
/** Consume only actual consecutive completions since the preceding public use.
|
||||
* A recorded started actor stays locked until its own opportunity completes. */
|
||||
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 Revival opportunities.');
|
||||
const count = next.length - completed.length;
|
||||
requireOrder(count <= remaining.length && same(next.slice(completed.length), remaining.slice(0, count)),
|
||||
'Revival completions must follow the current remaining opportunity order.');
|
||||
return { remaining: remaining.slice(count), completed: [...next], started: started && count === 0 };
|
||||
}
|
||||
function reordered(original: readonly string[], remaining: readonly string[],
|
||||
completed: readonly string[], started: boolean, use: Pick<SaphoRevivalOrderUse, 'event' | 'player' | 'mode'>) {
|
||||
requireOrder(remaining.includes(use.player), 'Only a pending faction may change its Revival position.');
|
||||
requireOrder(!started || use.player !== remaining[0],
|
||||
'Juice of Sapho cannot split a Revival opportunity that has already started.');
|
||||
try {
|
||||
const next = reorderOrderedOpportunity({ event: use.event, eligible: original, 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 Revival position.');
|
||||
return [...next.remaining];
|
||||
} catch (error) {
|
||||
if (error instanceof OrderedOpportunityError) throw new RevivalOrderError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function validateSaphoHistory(frame: Extract<RevivalOrder, { version: 2 }>): void {
|
||||
requireOrder(Array.isArray(frame.uses) && frame.uses.length > 0,
|
||||
'A reordered Revival needs its actual Juice of Sapho use history.');
|
||||
let remaining = [...frame.original], 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 Revival 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 Revival opportunity.');
|
||||
requireOrder(!use.started || advanced.remaining.length > 0,
|
||||
'Only a current Revival opportunity can have started.');
|
||||
requireOrder(use.event === eventFor(frame.turn, frame.original, advanced.remaining,
|
||||
advanced.completed, use.started, index), 'The saved Sapho Revival use lost its original public boundary.');
|
||||
remaining = reordered(frame.original, 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 Revival frame contradicts its Sapho use history or unlocks a started opportunity.');
|
||||
}
|
||||
|
||||
/** This boundary binds the actual public queue and every prior genuine use's
|
||||
* revision. It grants no card ownership, payment or additional opportunity. */
|
||||
export function saphoRevivalOrderEvent(context: RevivalOrderContext, frame: RevivalOrder): string {
|
||||
validateRevivalOrder(context, frame);
|
||||
return eventFor(frame.turn, frame.original, frame.remaining, frame.completed,
|
||||
frame.started, frame.version === 2 ? frame.uses.length : 0);
|
||||
}
|
||||
export function reorderSaphoRevivalOrder(context: RevivalOrderContext, frame: RevivalOrder, request: {
|
||||
event: string; player: string; mode: 'first' | 'last';
|
||||
}): { order: RevivalOrder; afterCurrent?: string } {
|
||||
validateRevivalOrder(context, frame);
|
||||
const event = saphoRevivalOrderEvent(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 Revival opportunity.');
|
||||
const remaining = reordered(frame.original, frame.remaining, frame.completed, frame.started, request);
|
||||
const order: RevivalOrder = { version: 2, turn: frame.turn, original: [...frame.original], remaining,
|
||||
completed: [...frame.completed], started: frame.started,
|
||||
uses: [...(frame.version === 2 ? frame.uses.map(use => ({ ...use, completed: [...use.completed] })) : []),
|
||||
{ event, player: request.player, mode: request.mode, completed: [...frame.completed], started: frame.started }] };
|
||||
validateRevivalOrder(context, order);
|
||||
return { order, ...(frame.started ? { afterCurrent: frame.remaining[0] } : {}) };
|
||||
}
|
||||
|
||||
export function revivalOrderBlock(context: RevivalOrderContext, frame: RevivalOrder, player: string): string | null {
|
||||
validateRevivalOrder(context, frame);
|
||||
requireOrder(id(player), 'Choose a valid Revival faction.');
|
||||
|
||||
Reference in new issue
Block a user