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.
247 lines
7.9 KiB
TypeScript
247 lines
7.9 KiB
TypeScript
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.
|
|
*/
|
|
export type ExtortionPlayer = { id: string; spice: number };
|
|
export type ExtortionContext = {
|
|
turn: number;
|
|
phase: number;
|
|
players: readonly ExtortionPlayer[];
|
|
};
|
|
export type ExtortionState = {
|
|
owner: string;
|
|
token: string;
|
|
turn: number;
|
|
bank: { reserved: 0 | 5; collected: 0 | 5 };
|
|
queue: string[];
|
|
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 }
|
|
| { from: 'player'; player: string; to: string; amount: 3 };
|
|
export type ExtortionResult = {
|
|
state: ExtortionState;
|
|
transfer: ExtortionTransfer | null;
|
|
/** null: still pending; false: first payment prevents recovery; true: return token. */
|
|
recover: boolean | null;
|
|
};
|
|
|
|
function requireValid(condition: unknown, message: string): asserts condition {
|
|
if (!condition) throw new Error(message);
|
|
}
|
|
function contextPlayers(context: ExtortionContext) {
|
|
requireValid(
|
|
Number.isSafeInteger(context.turn) && context.turn > 0,
|
|
'Extortion requires a positive turn.',
|
|
);
|
|
requireValid(
|
|
Number.isSafeInteger(context.phase) &&
|
|
context.phase >= 0 &&
|
|
context.phase <= 9,
|
|
'Extortion requires a valid current phase.',
|
|
);
|
|
const players = new Map<string, ExtortionPlayer>();
|
|
for (const player of context.players) {
|
|
requireValid(
|
|
typeof player.id === 'string' &&
|
|
player.id.length > 0 &&
|
|
!players.has(player.id),
|
|
'Extortion player identities must be unique and nonempty.',
|
|
);
|
|
requireValid(
|
|
Number.isSafeInteger(player.spice) && player.spice >= 0,
|
|
'Extortion requires current nonnegative integer spice balances.',
|
|
);
|
|
players.set(player.id, player);
|
|
}
|
|
return players;
|
|
}
|
|
function validate(state: ExtortionState, context: ExtortionContext) {
|
|
const players = contextPlayers(context);
|
|
requireValid(
|
|
players.has(state.owner),
|
|
'Extortion owner must still be present.',
|
|
);
|
|
requireValid(
|
|
typeof state.token === 'string' && state.token.length > 0,
|
|
'Extortion requires its revealed token identity.',
|
|
);
|
|
requireValid(
|
|
state.turn === context.turn,
|
|
'This Extortion obligation belongs to another turn.',
|
|
);
|
|
requireValid(
|
|
Number.isSafeInteger(state.cursor) &&
|
|
state.cursor >= 0 &&
|
|
state.cursor <= state.queue.length,
|
|
'Invalid Extortion payment cursor.',
|
|
);
|
|
requireValid(
|
|
new Set(state.queue).size === state.queue.length &&
|
|
state.queue.every((id) => id !== state.owner && players.has(id)),
|
|
'Invalid Extortion payer queue.',
|
|
);
|
|
if (state.stage === 'reserved') {
|
|
requireValid(
|
|
state.bank.reserved === 5 &&
|
|
state.bank.collected === 0 &&
|
|
state.queue.length === 0 &&
|
|
state.cursor === 0 &&
|
|
state.payer === null,
|
|
'Invalid uncollected Extortion reserve.',
|
|
);
|
|
} else {
|
|
requireValid(
|
|
state.stage === 'payment' || state.stage === 'settled',
|
|
'Invalid Extortion stage.',
|
|
);
|
|
requireValid(
|
|
state.bank.reserved === 0 && state.bank.collected === 5,
|
|
'Extortion payment requires its bank award to be collected.',
|
|
);
|
|
requireValid(
|
|
state.queue.length === players.size - 1,
|
|
'Extortion payer membership changed.',
|
|
);
|
|
if (state.stage === 'payment')
|
|
requireValid(
|
|
state.payer === null && state.cursor < state.queue.length,
|
|
'Invalid pending Extortion payment.',
|
|
);
|
|
else
|
|
requireValid(
|
|
state.payer === null
|
|
? state.cursor === state.queue.length
|
|
: state.cursor > 0 && state.queue[state.cursor - 1] === state.payer,
|
|
'Invalid completed Extortion payment.',
|
|
);
|
|
}
|
|
return players;
|
|
}
|
|
|
|
/** Check an unfinished saved obligation against its original turn and seat roster. */
|
|
export function validateExtortionState(state: ExtortionState, context: ExtortionContext): void {
|
|
validate(state, context);
|
|
}
|
|
|
|
function clone(state: ExtortionState): ExtortionState {
|
|
return {
|
|
owner: state.owner,
|
|
token: state.token,
|
|
turn: state.turn,
|
|
bank: { ...state.bank },
|
|
queue: [...state.queue],
|
|
cursor: state.cursor,
|
|
stage: state.stage,
|
|
payer: state.payer,
|
|
...(state.saphoOrder ? {saphoOrder: structuredClone(state.saphoOrder)} : {}),
|
|
};
|
|
}
|
|
|
|
/** Reserve five bank spice without crediting any player's spendable balance.
|
|
* The caller must enforce one obligation per revealed token and authorize revelation.
|
|
*/
|
|
export function reserveExtortion(
|
|
input: { owner: string; token: string; turn: number },
|
|
context: ExtortionContext,
|
|
): ExtortionResult {
|
|
const state: ExtortionState = {
|
|
...input,
|
|
bank: { reserved: 5, collected: 0 },
|
|
queue: [],
|
|
cursor: 0,
|
|
stage: 'reserved',
|
|
payer: null,
|
|
};
|
|
validate(state, context);
|
|
return { state, transfer: null, recover: null };
|
|
}
|
|
|
|
/** Collect before approaching payers, at a caller-selected Mentat continuation.
|
|
* The queue includes allies and insolvent players. "Pays you" is composed as another
|
|
* player's transfer: self-payment is excluded, not claimed as an explicit printed rule.
|
|
*/
|
|
export function collectExtortion(
|
|
state: ExtortionState,
|
|
context: ExtortionContext & { stormOrder: readonly string[] },
|
|
): ExtortionResult {
|
|
const players = validate(state, context);
|
|
requireValid(context.phase === 8, 'Collect Extortion during Mentat Pause.');
|
|
requireValid(
|
|
state.stage === 'reserved',
|
|
'Extortion bank spice has already been collected.',
|
|
);
|
|
requireValid(
|
|
context.stormOrder.length === players.size &&
|
|
new Set(context.stormOrder).size === players.size &&
|
|
context.stormOrder.every((id) => players.has(id)),
|
|
'Storm order must contain every current player exactly once.',
|
|
);
|
|
requireValid(
|
|
players.get(state.owner)!.spice <= Number.MAX_SAFE_INTEGER - 5,
|
|
'Extortion collection would overflow the owner balance.',
|
|
);
|
|
const next = clone(state);
|
|
next.bank = { reserved: 0, collected: 5 };
|
|
next.queue = context.stormOrder.filter((id) => id !== state.owner);
|
|
next.stage = next.queue.length ? 'payment' : 'settled';
|
|
return {
|
|
state: next,
|
|
transfer: { from: 'reservedBank', to: state.owner, amount: 5 },
|
|
recover: next.stage === 'settled' ? true : null,
|
|
};
|
|
}
|
|
|
|
/** Apply returned state and transfer atomically. Never replay a transfer independently.
|
|
* Balances are checked at the choice, allowing permitted intervening income to fund it.
|
|
*/
|
|
export function answerExtortion(
|
|
state: ExtortionState,
|
|
playerId: string,
|
|
pay: boolean,
|
|
context: ExtortionContext,
|
|
): ExtortionResult {
|
|
const players = validate(state, context);
|
|
requireValid(context.phase === 8, 'Answer Extortion during Mentat Pause.');
|
|
requireValid(
|
|
state.stage === 'payment',
|
|
'There is no pending Extortion payment.',
|
|
);
|
|
requireValid(
|
|
state.queue[state.cursor] === playerId,
|
|
'Wait for your current Extortion payment opportunity.',
|
|
);
|
|
requireValid(
|
|
typeof pay === 'boolean',
|
|
'Choose whether to pay exactly three spice.',
|
|
);
|
|
if (pay) {
|
|
requireValid(
|
|
players.get(playerId)!.spice >= 3,
|
|
'Extortion payment requires three available spice.',
|
|
);
|
|
requireValid(
|
|
players.get(state.owner)!.spice <= Number.MAX_SAFE_INTEGER - 3,
|
|
'Extortion payment would overflow the owner balance.',
|
|
);
|
|
}
|
|
const next = clone(state);
|
|
next.cursor++;
|
|
if (pay) {
|
|
next.payer = playerId;
|
|
next.stage = 'settled';
|
|
} else if (next.cursor === next.queue.length) next.stage = 'settled';
|
|
return {
|
|
state: next,
|
|
transfer: pay
|
|
? { from: 'player', player: playerId, to: state.owner, amount: 3 }
|
|
: null,
|
|
recover: next.stage === 'settled' ? !pay : null,
|
|
};
|
|
}
|