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.
146 lines
5.9 KiB
TypeScript
146 lines
5.9 KiB
TypeScript
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] },
|
|
};
|
|
}
|