Connect Sapho first and last to Extortion payer choices

Apply the printed ordered-action alternative provisionally to the unanswered native Extortion suffix. Preserve collected bank5, completed declines, payer membership, one payment3 and placement-first policy. Replay a public local-order receipt and retain it through ledger answers; expire the old payer event even when the same player remains next. Permit only the exact current Extortion decision as a clean Sapho window. Add persistent remaining-order/card controls and existing minimal policy participation. The typed Semuta suffix parks one updated payer decision and restores it without paying, advancing or recollecting.

Validation:9-file115/115, types/lint/build and six original public Basic/Advanced host/setup/paid-cache1/placed-Terror/paid-entry1/reveal/Mentat-placement-before-bank5 programmes. An actual earlier decline stays finished; first/payment3 and last/payment3 or all-decline preserve native retirement/recovery. Four profiles make eight legal holder/payer proposals and complete. Public order and private payer affordability render in actual GameTable plus enabled/busy controls. Pure replay/no-op/recovered reuse cases are separate. Bounded source/continuation review found no concrete defect.

Extortion/Sapho boundary is provisional. Direct combined Semuta claim/capture, broader combinations, complete modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
This commit is contained in:
Quintine committed 2026-10-18 14:07:08 +11:00
1 parent 2d89ddb6e1
commit 6c83148ead
14 files changed
+855 -16

No files matched your search

+105 -9
View File
@@ -507,6 +507,7 @@ import {
type TerrorState,
} from './moritani-terror';
import { reserveExtortion, collectExtortion, answerExtortion, validateExtortionState, type ExtortionState } from './moritani-extortion';
import { saphoExtortionEvent, validateSaphoExtortion, reorderSaphoExtortion, SaphoExtortionError } from './sapho-extortion';
import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions';
import { cashInCards } from './choam-karama';
import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullNexusCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull';
@@ -1400,6 +1401,15 @@ export type Game = {
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoExtortionDiscard';
player: string;
card: string;
event: string;
mode: 'first' | 'last';
resume: NonNullable<Game['pendingNullentropy']>['resume'];
stateSignature: string;
}
| {
kind: 'saphoAuctionDiscard';
cyclic?: 'normal' | 'blackMarket';
@@ -5427,6 +5437,17 @@ type SaphoMovementDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'saphoMovementDiscard' }
>;
type SaphoExtortionDiscardContinuation = Extract<
NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
{ kind: 'saphoExtortionDiscard' }
>;
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),
turn: g.turn, phase: g.phase, advanced: g.advanced, order: g.order,
extortion: g.moritaniExtortion,
players: g.players.map(p => ({id: p.id, faction: p.faction}))});
}
function saphoMovementDiscardSignature(g: Game, c: SaphoMovementDiscardContinuation) {
return JSON.stringify({
player: c.player, card: c.card, event: c.event, mode: c.mode,
@@ -6439,6 +6460,20 @@ function treacheryDiscardIntegrity(g: Game) {
c.stateSignature === distransDiscardSignature(g, c),
'The completed private Distrans transfer no longer matches its fresh discard.',
);
} 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;
requireRule(g.phase === 8 && state?.stage === 'payment' &&
latest?.event === c.event && latest.player === c.player && latest.mode === c.mode &&
decision?.kind === 'moritaniExtortion' && decision.player === state.queue[state.cursor] &&
decision.event === extortionEvent(state) && !g.decision &&
batch.cause === 'sapho:extortion' && 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.pendingKarama && !c.resume.phaseOpening &&
c.stateSignature === saphoExtortionDiscardSignature(g, c),
'The used Juice of Sapho no longer matches its current Extortion payer order.');
moritaniExtortionIntegrity(g);
} else if (continuation?.kind === 'saphoMovementDiscard') {
const c = continuation, entry = batch.entries[0];
requireRule(
@@ -7492,6 +7527,7 @@ function semutaOfferSupported(
continuation.kind !== 'nexusChoamTrade' &&
continuation.kind !== 'nexusChoamBetrayal' &&
continuation.kind !== 'saphoMovementDiscard' &&
continuation.kind !== 'saphoExtortionDiscard' &&
continuation.kind !== 'saphoAggressorDiscard' &&
continuation.kind !== 'saphoAuctionDiscard' &&
continuation.kind !== 'saphoBattleOrderDiscard' &&
@@ -7556,6 +7592,7 @@ function semutaOfferSupported(
continuation.kind !== 'ornithopterDiscard' &&
continuation.kind !== 'saphoAuctionDiscard' &&
continuation.kind !== 'saphoMovementDiscard' &&
continuation.kind !== 'saphoExtortionDiscard' &&
continuation.kind !== 'battleResolved' &&
continuation.kind !== 'winnerMandatoryDiscard' &&
continuation.kind !== 'battleCleanup' &&
@@ -7571,6 +7608,13 @@ function semutaOfferSupported(
continuation.kind !== 'karamaBattleDiscard') return false;
if (continuation.kind === 'battleResolved' &&
!g.lastBattleContext?.mandatoryDiscard) return false;
if (continuation.kind === 'saphoExtortionDiscard' &&
(g.phase !== 8 || g.moritaniExtortion?.stage !== 'payment' ||
continuation.resume.decision?.kind !== 'moritaniExtortion' ||
continuation.resume.decision.event !== extortionEvent(g.moritaniExtortion) ||
continuation.resume.decision.player !== g.moritaniExtortion.queue[g.moritaniExtortion.cursor] ||
continuation.resume.response || continuation.resume.pendingKarama ||
continuation.resume.phaseOpening || g.decision || g.response)) return false;
if ((continuation.kind === 'battleCleanup' ||
continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleResolved') &&
@@ -7661,6 +7705,7 @@ function semutaOfferSupported(
continuation.kind === 'choamBalisetDiscard' ||
continuation.kind === 'choamGamontDiscard' ||
continuation.kind === 'choamJubbaDiscard' ||
continuation.kind === 'saphoExtortionDiscard' ||
(continuation.kind === 'karamaCharityDiscard' ||
continuation.kind === 'karamaInflationDiscard' ||
continuation.kind === 'karamaBgCharityDiscard' ||
@@ -7920,6 +7965,11 @@ 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 === 'saphoExtortionDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
g.pendingKarama = next.resume.pendingKarama;
g.phaseOpening = next.resume.phaseOpening;
} else if (next.kind === 'saphoMovementDiscard') {
g.response = next.resume.response;
g.decision = next.resume.decision;
@@ -11121,7 +11171,13 @@ function setupComplete(g: Game) {
);
}
function extortionEvent(state: ExtortionState): string {
return JSON.stringify(['moritaniExtortion', state.turn, state.token, state.cursor]);
return JSON.stringify(['moritaniExtortion', state.turn, state.token, state.cursor,
...(state.saphoOrder ? [state.saphoOrder.uses.length] : [])]);
}
function extortionSaphoContext(g: Game) {
const state = g.moritaniExtortion!;
return {owner: state.owner, token: state.token, turn: state.turn,
stormOrder: g.order, queue: state.queue, cursor: state.cursor, order: state.saphoOrder};
}
function extortionContext(g: Game) {
return { turn: g.turn, phase: g.phase, players: g.players.map(player => ({ id: player.id, spice: player.spice })) };
@@ -11142,8 +11198,7 @@ function moritaniExtortionIntegrity(g: Game) {
'Extortion has lost its revealed token or Moritani owner.');
if (!pending) return;
extortionRule(() => validateExtortionState(pending, extortionContext(g)));
const stormPayers = pending.stage === 'payment'
? g.order.filter(id => id !== pending.owner) : null;
if (pending.stage === 'payment') extortionRule(() => validateSaphoExtortion(extortionSaphoContext(g)));
const savedDecisions = pending.stage === 'payment'
? homeworldSavedDecisions(g).filter(decision => decision.kind === 'moritaniExtortion')
: [];
@@ -11153,10 +11208,8 @@ function moritaniExtortionIntegrity(g: Game) {
g.moritaniTerror?.placementTurn === g.turn &&
savedDecisions.length === 1 &&
savedDecisions[0].player === pending.queue[pending.cursor] &&
savedDecisions[0].event === extortionEvent(pending) &&
pending.queue.length === stormPayers?.length &&
pending.queue.every((seat, index) => seat === stormPayers?.[index]),
'Extortion payment must resume in the original Mentat storm order.');
savedDecisions[0].event === extortionEvent(pending),
'Extortion payment must resume in its original Mentat scope and current ordered opportunity.');
}
function settleMoritaniExtortion(g: Game, state: ExtortionState, recover: boolean) {
requireRule(g.moritaniTerror && state.stage === 'settled',
@@ -19378,7 +19431,7 @@ function offerMoritaniDuke(g: Game) {
return true;
}
type SaphoOption = {
scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder';
scope: 'onceAround' | 'cyclicAuction' | 'movement' | 'battleOrder' | 'extortion';
event: string;
mode: 'first' | 'last';
} | { scope: 'battleAggressor'; event: string; mode: 'aggressor' };
@@ -19456,13 +19509,19 @@ function saphoMovementIntegrity(g: Game) {
protectedLast: last.player,
});
}
function saphoExtortionDecision(g: Game) {
const state = g.moritaniExtortion, decision = g.decision;
return g.phase === 8 && state?.stage === 'payment' && !g.pendingTreacheryDiscard &&
decision?.kind === 'moritaniExtortion' && decision.player === state.queue[state.cursor] &&
decision.event === extortionEvent(state);
}
function saphoCleanWindow(g: Game) {
return (
g.status === 'playing' &&
!g.truthtrance &&
!g.phaseOpening &&
!g.response &&
!g.decision &&
(!g.decision || saphoExtortionDecision(g)) &&
!g.pendingNullentropy &&
!g.pendingRicheseGift &&
!g.pendingExchange &&
@@ -19533,6 +19592,17 @@ function saphoOptions(g: Game, p: Player): SaphoOption[] {
)
return [];
const options: SaphoOption[] = [];
if (saphoExtortionDecision(g)) {
const context = extortionSaphoContext(g), event = extortionRule(() => saphoExtortionEvent(context));
for (const mode of ['first', 'last'] as const) {
try {
reorderSaphoExtortion(context, {event, player: p.id, mode});
options.push({scope: 'extortion', event, mode});
} catch (error) {
if (!(error instanceof SaphoExtortionError)) throw error;
}
}
}
const cyclic = saphoCyclicContext(g,p);
if (cyclic) for (const mode of ['first','last'] as const) {
try {
@@ -19706,6 +19776,29 @@ function playSapho(g: Game, p: Player, action: Action) {
}
}
settleRicheseLot(g);
} else if (option.scope === 'extortion') {
const state = g.moritaniExtortion!;
const ordered = extortionRule(() => reorderSaphoExtortion(extortionSaphoContext(g),
{event: option.event, player: p.id, mode: option.mode}));
state.queue = ordered.queue;
state.saphoOrder = ordered.order;
g.decision = {kind: 'moritaniExtortion', player: state.queue[state.cursor], event: extortionEvent(state)};
const used = discard(g, p, 'richese-juice-of-sapho');
log(g, `${p.name} discarded Juice of Sapho to take their Extortion choice ${option.mode} among the unanswered payers. Earlier declines and the five-spice collection remain; each player still chooses whether to pay the one three-spice payment.`,
{faction: p.faction, name: 'Juice of Sapho'});
if (g.semutaPreview) {
const continuation: SaphoExtortionDiscardContinuation = {
kind: 'saphoExtortionDiscard', 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}];
g.decision = null;
if (semutaOfferSupported(g, continuation, entries)) {
continuation.stateSignature = saphoExtortionDiscardSignature(g, continuation);
stageTreacheryDiscard(g, 'sapho:extortion', entries, continuation);
} else g.decision = continuation.resume.decision;
}
} 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,
@@ -35129,6 +35222,9 @@ export function viewGame(state: Game, id: string) {
player: g.decision.player,
amount: 3 as const,
canPay: me.id === g.decision.player ? me.spice >= 3 : null,
remaining: g.moritaniExtortion.queue.slice(g.moritaniExtortion.cursor),
completed: g.moritaniExtortion.queue.slice(0, g.moritaniExtortion.cursor),
saphoReordered: !!g.moritaniExtortion.saphoOrder,
}
: null,
},
+6 -1
View File
@@ -1,3 +1,5 @@
import type { SaphoExtortionOrder } from './sapho-extortion';
/** Isolated Extortion ledger; authorization and Mentat scheduling belong to the engine.
* Source: docs/MORITANI_EXTORTION_RULES.md. No placement-relative priority is encoded.
*/
@@ -16,6 +18,8 @@ export type ExtortionState = {
cursor: number;
stage: 'reserved' | 'payment' | 'settled';
payer: string | null;
/** Public ordered-action exception; absent in original storm-order ledgers. */
saphoOrder?: SaphoExtortionOrder;
};
export type ExtortionTransfer =
| { from: 'reservedBank'; to: string; amount: 5 }
@@ -135,6 +139,7 @@ function clone(state: ExtortionState): ExtortionState {
cursor: state.cursor,
stage: state.stage,
payer: state.payer,
...(state.saphoOrder ? {saphoOrder: structuredClone(state.saphoOrder)} : {}),
};
}
@@ -209,7 +214,7 @@ export function answerExtortion(
);
requireValid(
state.queue[state.cursor] === playerId,
'Wait for your Extortion turn in storm order.',
'Wait for your current Extortion payment opportunity.',
);
requireValid(
typeof pay === 'boolean',
+10 -4
View File
@@ -499,7 +499,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'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, provisional first ordering retains an unspent Guild’s native next-boundary take/wait choice; Guild may still act ahead. An already granted current Guild turn cannot be displaced, and a Karama-locked privilege stays locked. 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.',
'In a fresh Richese battle, use unreserved Sapho at a clean boundary before both combatants finish final battle actions, including after sealed plans and revealed declarations. Habbanya still precedes ordinary aggressor tie priority. Participants, plans, promises, weapon choices and votes do not swap or replay. Older battles retain their pre-plan opportunity.',
'Finish existing shipment, movement, card preparation and pending decisions before changing order. Your hand panel lists only currently available choices. A reserved card or a position you already hold cannot be selected.',
'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.',
'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.',
'Both combatants receive the same persistent final battle opportunity after original votes and native late defense, regardless of held cards or result. Finish explicitly; a Sapho priority change clears both finish marks with a fresh event. The second finish closes priority before Stone counts and outcome settlement. This shared online procedure and cyclic lot duration are provisional conventions. Other phase ordering and partly completed combined-turn intervention remain unfinished. Silent bids retain simultaneous timing and storm tie order.',
@@ -541,8 +542,13 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'18 October unspent Guild first:7-file97/97, types/lint/build and six original-action/rendered-table programmes pass. Actual cache1/native wait uses49 source actions; real paid completed Richese prefix uses55. TAKE/WAIT preserves non-Guild order and paid Emperor2/Guild1 shipments. Granted-current and actual originally-dealt Karama cancellation both finish without restoring blocked timing. Four profiles make eight legal holder/Guild proposals. Two fixture assumptions were repaired, then all checks passed together. Prior late-aggressor and cyclic evidence remains in the linked feature docs. No browser clicks, visual/deployed, direct combined Semuta claim/capture, actual competing first, partly completed turn, full-mode or final-assurance acceptance',
'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-extortion.ts',
'tests/sapho-extortion.test.ts',
'tests/fixture-sapho-extortion.ts',
'tests/sapho-extortion-engine.test.ts',
'docs/MORITANI_EXTORTION_RULES.md',
'tests/fixture-sapho-guild-first.ts',
'tests/sapho-guild-first-engine.test.ts',
'game/battle-outcome-window.ts',
@@ -3180,9 +3186,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'Revealing Extortion sets aside five spice from the bank in front of Moritani’s shield. Moritani collects it during Mentat Pause; it is not available to spend when the token is revealed.',
'For this table’s selected timing, Moritani’s normal Mentat Terror placement or relocation opportunity resolves first, including any Karama response. Only afterward does Moritani collect the five spice and offer the Extortion payment opportunity. A token recovered afterward cannot be placed again during that same Mentat Pause.',
'If Extortion is revealed during Mentat after placement has already finished, resolve that committed entry and its interruptions first. Then collect the bank award and offer payment before any player can end the turn.',
'After collection, each non-Moritani player in storm order may pay Moritani three spice or decline. A player without three spice cannot pay but may decline. The first payment permanently removes Extortion from play and ends the offers; it does not undo the bank award.',
'After collection, each non-Moritani player in storm order may pay Moritani three spice or decline. Sapho may provisionally reorder only unanswered choices; earlier declines cannot repeat. A player without three spice cannot pay but may decline. The first payment permanently removes Extortion from play and ends the offers; it does not undo the bank award.',
'If every eligible player declines, rotate Extortion and return it face down to Moritani’s hidden supply for a future placement. Other revealed Terror tokens have no corresponding recovery provision.',
'These controls cover Extortion collection, the payment choice and return or removal, not complete Moritani expansion games. Public module starts remain unavailable.',
'These controls cover Extortion collection, payer ordering, the payment choice and native return/removal. Supported native Moritani starts remain labelled previews; complete-mode acceptance is unfinished.',
],
related: ['moritani-terror', 'moritani-terror-effects', 'mentat'],
},
+145
View File
@@ -0,0 +1,145 @@
import { reorderOrderedOpportunity } from './ordered-opportunity';
export type SaphoExtortionUse = {
event: string;
player: string;
mode: 'first' | 'last';
cursor: number;
};
export type SaphoExtortionOrder = {
version: 1;
original: string[];
uses: SaphoExtortionUse[];
};
export type SaphoExtortionContext = {
owner: string;
token: string;
turn: number;
stormOrder: readonly string[];
queue: readonly string[];
cursor: number;
order?: SaphoExtortionOrder;
};
export type SaphoExtortionRequest = {
event: string;
player: string;
mode: 'first' | 'last';
};
export class SaphoExtortionError extends Error {
constructor(message: string) {
super(message);
this.name = 'SaphoExtortionError';
}
}
function requireExtortion(value: unknown, message: string): asserts value {
if (!value) throw new SaphoExtortionError(message);
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const count = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const ids = (value: unknown): value is readonly string[] =>
Array.isArray(value) && [...value].every(identifier) && new Set(value).size === value.length;
const exact = (value: unknown, keys: string): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
Object.keys(value).sort().join(',') === keys;
const eventFor = (token: string, turn: number, cursor: number, revision: number): string =>
`saphoExtortion:${JSON.stringify([token, turn, cursor, revision])}`;
/** Reorder one still-unstarted payer opportunity, never a payment or grant. */
function applyUse(
original: readonly string[],
queue: readonly string[],
use: SaphoExtortionUse,
): string[] {
const remaining = queue.slice(use.cursor);
requireExtortion(use.mode === 'first' || use.mode === 'last', 'Choose first or last.');
requireExtortion(
identifier(use.player) && remaining.includes(use.player),
'Only a pending non-owner Extortion payer can change position.',
);
requireExtortion(
use.mode === 'first' ? remaining[0] !== use.player : remaining[remaining.length - 1] !== use.player,
'This payer is already in that Extortion position.',
);
try {
const next = reorderOrderedOpportunity({
event: use.event, eligible: original, completed: queue.slice(0, use.cursor),
remaining, current: remaining[0] ?? null, currentStarted: false,
}, { event: use.event, player: use.player, position: use.mode });
return [...next.completed, ...next.remaining];
} catch (error) {
throw new SaphoExtortionError(error instanceof Error ? error.message : 'Invalid Extortion payer order.');
}
}
function validate(context: SaphoExtortionContext): string[] {
requireExtortion(
context && typeof context === 'object' && identifier(context.owner) && identifier(context.token) &&
count(context.turn) && context.turn > 0 && ids(context.stormOrder) &&
context.stormOrder.includes(context.owner),
'Extortion needs its original owner, revealed token, turn and unique storm order.',
);
const original = context.stormOrder.filter(player => player !== context.owner);
requireExtortion(
ids(context.queue) && context.queue.length === original.length &&
context.queue.every(player => original.includes(player)) &&
count(context.cursor) && context.cursor <= context.queue.length,
'Extortion needs its complete public payer queue and a safe current cursor.',
);
if (context.order === undefined) {
requireExtortion(
JSON.stringify(context.queue) === JSON.stringify(original),
'An Extortion queue without a Sapho record must retain its original storm order.',
);
return original;
}
const order = context.order;
requireExtortion(
exact(order, 'original,uses,version') && order.version === 1 && ids(order.original) &&
JSON.stringify(order.original) === JSON.stringify(original) && Array.isArray(order.uses),
'The Sapho Extortion record must retain its exact original payer order.',
);
let queue = [...original], previousCursor = 0;
for (const [revision, use] of order.uses.entries()) {
requireExtortion(
exact(use, 'cursor,event,mode,player') && count(use.cursor) &&
use.cursor >= previousCursor && use.cursor <= context.cursor &&
use.event === eventFor(context.token, context.turn, use.cursor, revision),
'Sapho Extortion history has a stale event, revision or completed payer cursor.',
);
queue = applyUse(original, queue, use);
previousCursor = use.cursor;
}
requireExtortion(
JSON.stringify(context.queue) === JSON.stringify(queue),
'The Extortion payer queue does not match its original Sapho history.',
);
return original;
}
/** Public order only: no spice, card custody, insolvency or payment inspection. */
export function validateSaphoExtortion(context: SaphoExtortionContext): void {
validate(context);
}
export function saphoExtortionEvent(context: SaphoExtortionContext): string {
validate(context);
return eventFor(context.token, context.turn, context.cursor, context.order?.uses.length ?? 0);
}
export function reorderSaphoExtortion(
context: SaphoExtortionContext,
request: SaphoExtortionRequest,
): { queue: string[]; order: SaphoExtortionOrder } {
const original = validate(context);
const event = eventFor(context.token, context.turn, context.cursor, context.order?.uses.length ?? 0);
requireExtortion(request && typeof request === 'object' && request.event === event,
'This Sapho Extortion event has expired.');
const use: SaphoExtortionUse = { event, player: request.player, mode: request.mode, cursor: context.cursor };
const queue = applyUse(original, context.queue, use);
return {
queue,
order: { version: 1, original: [...original],
uses: [...(context.order?.uses ?? []).map(previous => ({ ...previous })), use] },
};
}