Files
Dune/game/sapho-extortion.ts
Quintine 6c83148ead 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.
2026-10-18 14:07:08 +11:00

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] },
};
}