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:
1 parent
2d89ddb6e1
commit
6c83148ead
14 files changed
+855
-16
No files matched your search
@@ -0,0 +1,165 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
|
||||
const sapho = richeseCards().find(card => card.effect === 'juiceOfSapho'); assert.ok(sapho);
|
||||
export const EXTORTION_SAPHO = sapho.id;
|
||||
export type SaphoExtortionStep = { actor: string; action: Action };
|
||||
export const saphoExtortionPlayer = (game: Game, actor = 'emperor') => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const saphoExtortionClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export const saphoExtortionPending = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && game.decision?.kind === 'moritaniExtortion';
|
||||
export function actSaphoExtortion(game: Game, actor: string, action: Action,
|
||||
actions: SaphoExtortionStep[] = []): Game {
|
||||
const next = withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
/** Native optional controls are declined; the source programme owns every
|
||||
* Extortion reveal, placement and payment instead of inventing those choices. */
|
||||
export function nextSaphoExtortionStep(game: Game): SaphoExtortionStep | null {
|
||||
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'moritaniTerror' || decision?.kind === 'moritaniExtortion') return null;
|
||||
if (decision?.kind === 'moritaniSetup') return { actor: decision.player,
|
||||
action: { type: 'decision', territory: 'carthag', sector: 11 } };
|
||||
if (decision?.kind === 'moritaniPlacement') return { actor: decision.player,
|
||||
action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'moritaniAssassinate') return { actor: decision.player,
|
||||
action: { type: 'decision', event: decision.event, decline: true } };
|
||||
if (decision?.kind === 'guildTiming') return { actor: decision.player,
|
||||
action: { type: 'decision', take: false } };
|
||||
if (decision?.kind === 'richeseCache') {
|
||||
const card = game.richeseCache!.find(card => card.id === EXTORTION_SAPHO) ?? game.richeseCache![0]; assert.ok(card);
|
||||
return { actor: decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'onceAround', direction: 'clockwise' } };
|
||||
}
|
||||
if (decision?.kind === 'richeseUnbid') return { actor: decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
}
|
||||
const lot = game.richeseAuction;
|
||||
if (saphoExtortionClean(game) && lot?.cardId === EXTORTION_SAPHO && !lot.outcome &&
|
||||
lot.active === 'emperor' && lot.bid === 0)
|
||||
return { actor: 'emperor', action: { type: 'richeseBid', event: lot.event, amount: 1, allyPayment: 0 } };
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceSaphoExtortion(game: Game, until: (state: Game) => boolean,
|
||||
actions: SaphoExtortionStep[] = []): Game {
|
||||
for (let count = 0; count < 2400; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Extortion programme ended before its next control');
|
||||
const step = nextSaphoExtortionStep(game); assert.ok(step, 'Choose the actual owned Extortion control before advancing');
|
||||
game = actSaphoExtortion(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('The original Sapho/Extortion programme did not reach its finite boundary');
|
||||
}
|
||||
function orderOriginalLand(game: Game) {
|
||||
const front: Game['spiceDeck'] = [];
|
||||
for (let count = 0; count < (game.advanced ? 2 : 1); count++) {
|
||||
const index = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(index >= 0);
|
||||
front.push(game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game.spiceDeck.unshift(...front);
|
||||
}
|
||||
export function saphoExtortionAnswer(game: Game, pay: boolean): SaphoExtortionStep {
|
||||
const decision = game.decision; assert.ok(decision?.kind === 'moritaniExtortion');
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, pay } };
|
||||
}
|
||||
|
||||
/** Public original setup, actual paid cache1 Sapho, first native Mentat Terror
|
||||
* placement, paid foreign entry and reveal, then another actual placement
|
||||
* before bank5. The original seat/join order leaves four native payers; an
|
||||
* actual Atreides decline leaves the holder inside the unasked suffix.
|
||||
* Only unopened Spice positions and original scalar lotteries are controlled.
|
||||
* No dealt card, physical counter, wallet, clock, queue or receipt is installed. */
|
||||
export function createSaphoExtortionFixture(options: { advanced?: boolean; priorDecline?: boolean } = {}) {
|
||||
const advanced = options.advanced ?? false;
|
||||
let game = createGame('SAPHOEXTORTION', newPlayer('richese', 'Richese', 'richese'), advanced, ['ecaz', 'choam']);
|
||||
for (const faction of ['atreides', 'guild', 'emperor', 'moritani'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: SaphoExtortionStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actSaphoExtortion(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceSaphoExtortion(game, until, actions); };
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
orderOriginalLand(game);
|
||||
reach(state => saphoExtortionPlayer(state).hand.some(card => card.id === EXTORTION_SAPHO));
|
||||
const acquired = structuredClone(game);
|
||||
reach(state => state.turn === 1 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
|
||||
const token = game.moritaniTerror!.tokens.find(token => token.kind === 'extortion' && token.status === 'available'); assert.ok(token);
|
||||
const beforePlacement = structuredClone(game), placement: SaphoExtortionStep = { actor: 'moritani', action: {
|
||||
type: 'decision', token: token.id, territory: 'habbanya_ridge_sietch',
|
||||
} };
|
||||
act(placement.actor, placement.action);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && saphoExtortionClean(state));
|
||||
const placed = structuredClone(game);
|
||||
orderOriginalLand(game);
|
||||
reach(state => state.phase === 5 && state.active === 'emperor' && saphoExtortionClean(state));
|
||||
const beforeEntry = structuredClone(game), entry: SaphoExtortionStep = { actor: 'emperor', action: {
|
||||
type: 'ship', territory: 'habbanya_ridge_sietch', sector: 17, amount: 1, elite: 0, allyPayment: 0,
|
||||
} };
|
||||
act(entry.actor, entry.action);
|
||||
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniTerror');
|
||||
const offered = structuredClone(game), reveal: SaphoExtortionStep = { actor: 'moritani',
|
||||
action: { type: 'decision', reveal: true } };
|
||||
act(reveal.actor, reveal.action);
|
||||
reach(saphoExtortionClean);
|
||||
const revealed = structuredClone(game);
|
||||
reach(state => state.turn === 2 && !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniPlacement');
|
||||
const beforeMentatPlacement = structuredClone(game);
|
||||
const later = game.moritaniTerror!.tokens.find(token => token.kind === 'sabotage' && token.status === 'available'); assert.ok(later);
|
||||
const mentatPlacement: SaphoExtortionStep = { actor: 'moritani', action: {
|
||||
type: 'decision', token: later.id, territory: 'sietch_tabr',
|
||||
} };
|
||||
act(mentatPlacement.actor, mentatPlacement.action);
|
||||
reach(saphoExtortionPending);
|
||||
const collected = structuredClone(game), declines: { before: Game; step: SaphoExtortionStep; after: Game }[] = [];
|
||||
if (options.priorDecline !== false) {
|
||||
const before = structuredClone(game), step = saphoExtortionAnswer(game, false);
|
||||
assert.equal(step.actor, 'atreides', 'The original turn-two Storm/join order supplies the completed prefix');
|
||||
act(step.actor, step.action);
|
||||
declines.push({ before, step, after: structuredClone(game) });
|
||||
}
|
||||
const queue = game.moritaniExtortion!;
|
||||
assert.ok(queue.queue.slice(queue.cursor).includes('emperor'));
|
||||
assert.notEqual(queue.queue[queue.cursor], 'emperor');
|
||||
assert.notEqual(queue.queue.at(-1), 'emperor');
|
||||
return { game, lobby, setup, afterSetup, acquired, beforePlacement, placement, placed, beforeEntry, entry,
|
||||
offered, reveal, revealed, beforeMentatPlacement, mentatPlacement, collected, declines,
|
||||
owner: 'emperor', moritani: 'moritani', card: EXTORTION_SAPHO, token: token.id, laterToken: later.id,
|
||||
advanced, priorDecline: options.priorDecline !== false, sourceActionCount: actions.length, actions,
|
||||
staging: ['Only unopened land Spice positions and original scalar lotteries are selected. Public join order Richese/Atreides/Guild/Emperor/Moritani, original setup, actual cache1, initial native Extortion placement, paid foreign1 entry, revelation and later placement-before-bank5 create the real payer queue. No postdeal custody, counters, wallets, clocks, payer order or receipt is assigned.'] };
|
||||
}
|
||||
export type SaphoExtortionFixture = ReturnType<typeof createSaphoExtortionFixture>;
|
||||
export function saphoExtortionAction(game: Game, mode: 'first' | 'last'): Action {
|
||||
const option = viewGame(game, 'emperor').saphoOptions.find(option => option.scope === 'extortion' && option.mode === mode);
|
||||
assert.ok(option, `The actual pending unasked suffix needs a meaningful Sapho-${mode} option`);
|
||||
return { type: 'card', card: EXTORTION_SAPHO, ...option };
|
||||
}
|
||||
|
||||
/** Each answer uses the actual current native payer and current event. The
|
||||
* first real payment ends offers; an all-decline branch returns the real token
|
||||
* through the native hidden-supply rotation. */
|
||||
export function completeSaphoExtortion(fixture: SaphoExtortionFixture, mode: 'first' | 'last', payOwner = false) {
|
||||
let game = structuredClone(fixture.game);
|
||||
const actions: SaphoExtortionStep[] = [], answers: { before: Game; step: SaphoExtortionStep; after: Game }[] = [];
|
||||
const previous = saphoExtortionAnswer(game, false), action = saphoExtortionAction(game, mode);
|
||||
game = actSaphoExtortion(game, fixture.owner, action, actions);
|
||||
const ordered = structuredClone(game);
|
||||
for (let count = 0; game.moritaniExtortion && count < fixture.game.players.length; count++) {
|
||||
assert.ok(saphoExtortionPending(game));
|
||||
const before = structuredClone(game), step = saphoExtortionAnswer(game, payOwner && game.decision!.player === fixture.owner);
|
||||
game = actSaphoExtortion(game, step.actor, step.action, actions);
|
||||
answers.push({ before, step, after: structuredClone(game) });
|
||||
}
|
||||
assert.equal(game.moritaniExtortion, undefined, 'The real Extortion obligation settles finitely');
|
||||
return { game, action, previous, ordered, answers, actions, mode, payOwner };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import {
|
||||
actSaphoExtortion, completeSaphoExtortion, createSaphoExtortionFixture,
|
||||
saphoExtortionAnswer, saphoExtortionPending, saphoExtortionPlayer as player,
|
||||
type SaphoExtortionFixture,
|
||||
} from './fixture-sapho-extortion';
|
||||
|
||||
function source(f: SaphoExtortionFixture) {
|
||||
assert.equal(f.game.advanced, f.advanced);
|
||||
assert.ok(f.actions.some(step => step.actor === f.game.host && step.action.type === 'start'));
|
||||
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||
assert.ok(player(f.acquired).hand.some(card => card.id === f.card));
|
||||
assert.equal(player(f.afterSetup, f.moritani).forces['carthag:11'], 6);
|
||||
assert.equal(f.beforePlacement.turn, 1);
|
||||
assert.equal(f.beforePlacement.decision?.kind, 'moritaniPlacement');
|
||||
assert.equal(f.placed.moritaniTerror!.tokens.find(token => token.id === f.token)?.location, 'habbanya_ridge_sietch');
|
||||
assert.equal(player(f.beforeEntry).spice - player(f.revealed).spice, 1);
|
||||
assert.equal(player(f.beforeEntry).reserves - player(f.revealed).reserves, 1);
|
||||
assert.equal(player(f.revealed).forces['habbanya_ridge_sietch:17'], 1);
|
||||
assert.equal(f.offered.decision?.kind, 'moritaniTerror');
|
||||
assert.equal(viewGame(f.offered, f.moritani).terrorEntry!.kind, 'extortion');
|
||||
assert.equal(player(f.offered, f.moritani).spice, player(f.revealed, f.moritani).spice);
|
||||
assert.deepEqual(f.revealed.moritaniExtortion!.bank, { reserved: 5, collected: 0 });
|
||||
assert.equal(viewGame(f.revealed, f.owner).extortion.deferred, 5);
|
||||
assert.equal(f.beforeMentatPlacement.decision?.kind, 'moritaniPlacement');
|
||||
assert.equal(f.beforeMentatPlacement.phase, 8);
|
||||
assert.deepEqual(f.beforeMentatPlacement.moritaniExtortion!.bank, { reserved: 5, collected: 0 });
|
||||
assert.equal(f.collected.moritaniTerror!.tokens.find(token => token.id === f.laterToken)?.location, 'sietch_tabr');
|
||||
assert.equal(player(f.collected, f.moritani).spice - player(f.beforeMentatPlacement, f.moritani).spice, 5);
|
||||
assert.deepEqual(f.collected.moritaniExtortion!.bank, { reserved: 0, collected: 5 });
|
||||
assert.deepEqual(f.collected.moritaniExtortion!.queue, f.collected.order.filter(actor => actor !== f.moritani));
|
||||
assert.equal(f.declines.length, 1);
|
||||
assert.equal(f.declines[0].step.actor, 'atreides');
|
||||
assert.equal(f.declines[0].step.action.pay, false);
|
||||
assert.ok(saphoExtortionPending(f.game));
|
||||
assert.equal(f.game.moritaniExtortion!.cursor, 1);
|
||||
assert.equal(f.sourceActionCount, f.actions.length);
|
||||
}
|
||||
function reordered(f: SaphoExtortionFixture, result: ReturnType<typeof completeSaphoExtortion>) {
|
||||
const before = f.game.moritaniExtortion!, after = result.ordered.moritaniExtortion!;
|
||||
const rest = before.queue.slice(before.cursor).filter(actor => actor !== f.owner);
|
||||
assert.equal(result.action.scope, 'extortion');
|
||||
assert.deepEqual(after.queue, [...before.queue.slice(0, before.cursor),
|
||||
...(result.mode === 'first' ? [f.owner, ...rest] : [...rest, f.owner])]);
|
||||
assert.equal(after.cursor, before.cursor);
|
||||
assert.deepEqual(after.bank, before.bank);
|
||||
assert.equal(after.token, f.token);
|
||||
assert.equal(after.payer, null);
|
||||
assert.ok(after.saphoOrder);
|
||||
assert.deepEqual(result.ordered.order, f.game.order);
|
||||
assert.equal(result.ordered.moritaniTerror!.placementTurn, f.game.moritaniTerror!.placementTurn);
|
||||
assert.equal(result.ordered.moritaniTerror!.tokens.find(token => token.id === f.token)?.status, 'extortion');
|
||||
assert.equal(player(result.ordered, f.moritani).spice, player(f.game, f.moritani).spice);
|
||||
assert.equal(result.ordered.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.ok(result.ordered.decision?.kind === 'moritaniExtortion');
|
||||
assert.notEqual(result.ordered.decision.event, result.previous.action.event);
|
||||
assert.throws(() => actSaphoExtortion(result.ordered, result.previous.actor, result.previous.action),
|
||||
/Extortion|event|turn|expired|current|decision/i);
|
||||
assert.equal(result.answers.some(answer => answer.step.actor === 'atreides'), false, 'The completed real decline is not asked again');
|
||||
for (const answer of result.answers) {
|
||||
assert.equal(answer.step.actor, answer.before.moritaniExtortion!.queue[answer.before.moritaniExtortion!.cursor]);
|
||||
if (answer.after.moritaniExtortion) assert.deepEqual(answer.after.moritaniExtortion.saphoOrder, after.saphoOrder,
|
||||
'The original ledger answer clone retains the applied local order');
|
||||
}
|
||||
assert.equal(result.game.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(result.game.moritaniExtortion, undefined);
|
||||
assert.equal(result.game.decision?.kind === 'moritaniExtortion', false);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const rules = advanced ? 'Advanced' : 'Basic';
|
||||
void test(`genuine ${rules} paid Sapho first preserves a real earlier Extortion decline and ends offers with one payment3`, () => {
|
||||
const f = createSaphoExtortionFixture({ advanced }); source(f);
|
||||
const result = completeSaphoExtortion(f, 'first', true); reordered(f, result);
|
||||
assert.equal(result.answers.length, 1);
|
||||
assert.equal(result.answers[0].step.actor, f.owner);
|
||||
assert.equal(result.answers[0].step.action.pay, true);
|
||||
assert.equal(player(result.game).spice, player(f.game).spice - 3);
|
||||
assert.equal(player(result.game, f.moritani).spice, player(f.game, f.moritani).spice + 3);
|
||||
assert.equal(player(result.game, f.moritani).spice - player(f.beforeMentatPlacement, f.moritani).spice, 8);
|
||||
assert.equal(result.game.moritaniTerror!.tokens.find(token => token.id === f.token)?.status, 'removed');
|
||||
assert.equal(viewGame(result.game, f.owner).extortion.pending, null);
|
||||
assert.throws(() => actSaphoExtortion(result.game, f.owner, result.answers[0].step.action));
|
||||
});
|
||||
void test(`genuine ${rules} Sapho last preserves its completed payer prefix and all declines rotate the recovered Terror supply`, () => {
|
||||
const f = createSaphoExtortionFixture({ advanced }); source(f);
|
||||
const result = completeSaphoExtortion(f, 'last'); reordered(f, result);
|
||||
const native = f.game.moritaniExtortion!;
|
||||
assert.deepEqual(result.answers.map(answer => answer.step.actor),
|
||||
[...native.queue.slice(native.cursor).filter(actor => actor !== f.owner), f.owner]);
|
||||
assert.ok(result.answers.every(answer => answer.step.action.pay === false));
|
||||
assert.equal(player(result.game, f.moritani).spice, player(f.game, f.moritani).spice);
|
||||
for (const actor of native.queue) assert.equal(player(result.game, actor).spice, player(f.game, actor).spice);
|
||||
assert.equal(result.game.moritaniTerror!.tokens.length, 6);
|
||||
assert.equal(result.game.moritaniTerror!.tokens.some(token => token.id === f.token), false);
|
||||
const recovered = result.game.moritaniTerror!.tokens.find(token => token.kind === 'extortion'); assert.ok(recovered);
|
||||
assert.equal(recovered.status, 'available');
|
||||
assert.equal(recovered.location, null);
|
||||
assert.equal(result.game.moritaniTerror!.tokens.find(token => token.id === f.laterToken)?.location, 'sietch_tabr');
|
||||
});
|
||||
}
|
||||
|
||||
void test('all four minimal policies use actual Sapho Extortion options and answer the resulting native payer control legally', () => {
|
||||
const f = createSaphoExtortionFixture({ advanced: true });
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const holder = viewGame(f.game, f.owner);
|
||||
holder.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(holder).find(action => action.type === 'card' && action.card === f.card && action.scope === 'extortion');
|
||||
assert.ok(action, `${difficulty} retains the real owned Sapho choice during the native payer decision`);
|
||||
const ordered = actSaphoExtortion(structuredClone(f.game), f.owner, action);
|
||||
assert.ok(saphoExtortionPending(ordered));
|
||||
const payer = ordered.decision!.player, view = viewGame(ordered, payer);
|
||||
view.players.find(player => player.id === payer)!.bot = difficulty;
|
||||
const answer = botActions(view).find(action => action.type === 'decision' && typeof action.pay === 'boolean');
|
||||
assert.ok(answer, `${difficulty} answers the actual native payer event`);
|
||||
const next = actSaphoExtortion(ordered, payer, answer);
|
||||
assert.equal(next.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(next.order, f.game.order);
|
||||
if (next.moritaniExtortion) {
|
||||
assert.equal(next.moritaniExtortion.cursor, ordered.moritaniExtortion!.cursor + 1);
|
||||
assert.equal(next.decision?.kind, 'moritaniExtortion');
|
||||
const actual = saphoExtortionAnswer(next, false);
|
||||
assert.equal(actual.actor, next.moritaniExtortion.queue[next.moritaniExtortion.cursor]);
|
||||
} else assert.equal(answer.pay, true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
saphoExtortionEvent,
|
||||
validateSaphoExtortion,
|
||||
reorderSaphoExtortion,
|
||||
SaphoExtortionError,
|
||||
type SaphoExtortionContext,
|
||||
type SaphoExtortionOrder,
|
||||
} from '../game/sapho-extortion';
|
||||
|
||||
const initial = (): SaphoExtortionContext => ({
|
||||
owner: 'm', token: 'original-extortion-token', turn: 3,
|
||||
stormOrder: ['a', 'm', 'b', 'c', 'd'], queue: ['a', 'b', 'c', 'd'], cursor: 0,
|
||||
});
|
||||
const move = (context: SaphoExtortionContext, player: string, mode: 'first' | 'last'): SaphoExtortionContext => ({
|
||||
...context, ...reorderSaphoExtortion(context, { event: saphoExtortionEvent(context), player, mode }),
|
||||
});
|
||||
const clone = <T>(value: T): T => structuredClone(value);
|
||||
|
||||
// These are public finite-order inputs. Changing a cursor represents the
|
||||
// caller's already-completed native opportunity; no payment or card custody
|
||||
// claim is established by this pure adapter.
|
||||
void test('Sapho Extortion records the original owner-excluded storm queue and an immutable first-position use', () => {
|
||||
const context = initial(), before = clone(context);
|
||||
Object.freeze(context.queue); Object.freeze(context.stormOrder); Object.freeze(context);
|
||||
assert.equal(saphoExtortionEvent(context), 'saphoExtortion:["original-extortion-token",3,0,0]');
|
||||
const result = reorderSaphoExtortion(context, { event: saphoExtortionEvent(context), player: 'd', mode: 'first' });
|
||||
assert.deepEqual(result.queue, ['d', 'a', 'b', 'c']);
|
||||
assert.deepEqual(result.order.original, ['a', 'b', 'c', 'd']);
|
||||
assert.equal(result.order.version, 1);
|
||||
assert.deepEqual(result.order.uses, [{ event: saphoExtortionEvent(context), player: 'd', mode: 'first', cursor: 0 }]);
|
||||
validateSaphoExtortion({ ...context, ...result });
|
||||
assert.deepEqual(context, before);
|
||||
assert.notEqual(result.queue, context.queue);
|
||||
});
|
||||
|
||||
void test('later first and last uses preserve completed payers and the relative order of every other pending payer', () => {
|
||||
const first = move(initial(), 'd', 'first');
|
||||
const afterCompleted = { ...first, cursor: 1 }, before = clone(afterCompleted);
|
||||
const next = move(afterCompleted, 'b', 'first');
|
||||
assert.deepEqual(next.queue, ['d', 'b', 'a', 'c']);
|
||||
assert.equal(next.queue[0], afterCompleted.queue[0]);
|
||||
assert.deepEqual(next.queue.filter(player => player !== 'b'), afterCompleted.queue.filter(player => player !== 'b'));
|
||||
const last = move(next, 'a', 'last');
|
||||
assert.deepEqual(last.queue, ['d', 'b', 'c', 'a']);
|
||||
assert.deepEqual(last.queue.filter(player => player !== 'a'), next.queue.filter(player => player !== 'a'));
|
||||
assert.deepEqual(last.order!.uses.map(use => use.cursor), [0, 1, 1]);
|
||||
assert.equal(saphoExtortionEvent(last), 'saphoExtortion:["original-extortion-token",3,1,3]');
|
||||
validateSaphoExtortion(last);
|
||||
assert.deepEqual(afterCompleted, before);
|
||||
});
|
||||
|
||||
void test('a fresh repeated use by the same pending holder has its own revision while recovered-card custody stays external', () => {
|
||||
const context = initial(), originalEvent = saphoExtortionEvent(context);
|
||||
const first = move(context, 'b', 'first');
|
||||
assert.deepEqual(first.queue, ['b', 'a', 'c', 'd']);
|
||||
const last = move(first, 'b', 'last');
|
||||
assert.deepEqual(last.queue, ['a', 'c', 'd', 'b']);
|
||||
assert.equal(last.order!.uses.length, 2);
|
||||
assert.notEqual(last.order!.uses[0].event, last.order!.uses[1].event);
|
||||
const reused = move(last, 'b', 'first');
|
||||
assert.deepEqual(reused.queue, ['b', 'a', 'c', 'd']);
|
||||
assert.equal(reused.order!.uses.length, 3);
|
||||
validateSaphoExtortion(reused);
|
||||
assert.throws(() => reorderSaphoExtortion(last, { event: originalEvent, player: 'b', mode: 'first' }), /expired/);
|
||||
reused.order!.uses[0].player = 'c';
|
||||
assert.equal(first.order!.uses[0].player, 'b', 'A later result must not alias earlier history records');
|
||||
});
|
||||
|
||||
void test('current-first, already-last, completed, owner and foreign payer requests are unavailable', () => {
|
||||
const context = { ...initial(), cursor: 1 }, before = clone(context);
|
||||
for (const [player, mode] of [['b', 'first'], ['d', 'last'], ['a', 'first'], ['m', 'first'], ['outsider', 'last']] as const)
|
||||
assert.throws(() => reorderSaphoExtortion(context, { event: saphoExtortionEvent(context), player, mode }), SaphoExtortionError);
|
||||
for (const cursor of [3, 4]) {
|
||||
const ended = { ...context, cursor };
|
||||
validateSaphoExtortion(ended);
|
||||
for (const mode of ['first', 'last'] as const)
|
||||
assert.throws(() => reorderSaphoExtortion(ended, { event: saphoExtortionEvent(ended), player: 'd', mode }), SaphoExtortionError);
|
||||
}
|
||||
assert.deepEqual(context, before);
|
||||
});
|
||||
|
||||
void test('history replay rejects altered original order, queue, event revision and decreasing or future cursors', () => {
|
||||
const first = move({ ...initial(), cursor: 1 }, 'c', 'first');
|
||||
const valid = move({ ...first, cursor: 2 }, 'd', 'first');
|
||||
assert.deepEqual(valid.queue, ['a', 'c', 'd', 'b']);
|
||||
validateSaphoExtortion(valid);
|
||||
const changes: ((context: SaphoExtortionContext & { order: SaphoExtortionOrder }) => void)[] = [
|
||||
context => { context.order.original.reverse(); },
|
||||
context => { context.queue = ['a', 'd', 'c', 'b']; },
|
||||
context => { context.order.uses[1].event = context.order.uses[0].event; },
|
||||
context => { context.order.uses[1].cursor = 0;
|
||||
context.order.uses[1].event = 'saphoExtortion:["original-extortion-token",3,0,1]'; },
|
||||
context => { context.order.uses[1].cursor = 3;
|
||||
context.order.uses[1].event = 'saphoExtortion:["original-extortion-token",3,3,1]'; },
|
||||
context => { context.order.uses[0].player = 'a'; },
|
||||
context => { context.order.uses.pop(); },
|
||||
context => { context.token = 'another-extortion-token'; },
|
||||
context => { context.turn++; },
|
||||
context => { Object.assign(context.order.uses[0], { extra: undefined }); },
|
||||
context => { Object.assign(context.order, { version: 2 }); },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const invalid = clone(valid) as SaphoExtortionContext & { order: SaphoExtortionOrder };
|
||||
change(invalid);
|
||||
assert.throws(() => validateSaphoExtortion(invalid), SaphoExtortionError);
|
||||
assert.throws(() => saphoExtortionEvent(invalid), SaphoExtortionError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('unstamped queues retain exact storm order and malformed public membership or cursors reject', () => {
|
||||
const context = initial();
|
||||
for (const invalid of [
|
||||
{ ...context, owner: 'missing' }, { ...context, token: '' },
|
||||
{ ...context, turn: 0 }, { ...context, turn: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...context, stormOrder: ['a', 'm', 'b', 'c', 'c'] },
|
||||
{ ...context, queue: ['b', 'a', 'c', 'd'] },
|
||||
{ ...context, queue: ['a', 'b', 'c'] }, { ...context, queue: ['a', 'b', 'c', 'm'] },
|
||||
{ ...context, queue: ['a', 'b', 'c', 'c'] },
|
||||
...[-1, 0.5, NaN, Infinity, 5, Number.MAX_SAFE_INTEGER + 1].map(cursor => ({ ...context, cursor })),
|
||||
]) assert.throws(() => validateSaphoExtortion(invalid), SaphoExtortionError);
|
||||
assert.throws(() => reorderSaphoExtortion(context, {
|
||||
event: saphoExtortionEvent(context), player: 'b', mode: 'middle',
|
||||
} as unknown as Parameters<typeof reorderSaphoExtortion>[1]), /first or last/);
|
||||
});
|
||||
|
||||
void test('native completion and a different original Extortion token invalidate an earlier public event', () => {
|
||||
const context = initial(), event = saphoExtortionEvent(context);
|
||||
const afterCompletion = { ...context, cursor: 1 };
|
||||
assert.equal(saphoExtortionEvent(afterCompletion), 'saphoExtortion:["original-extortion-token",3,1,0]');
|
||||
assert.throws(() => reorderSaphoExtortion(afterCompletion, { event, player: 'c', mode: 'first' }), /expired/);
|
||||
const anotherToken = { ...context, token: 'second-original-token' };
|
||||
assert.notEqual(saphoExtortionEvent(anotherToken), event);
|
||||
assert.throws(() => reorderSaphoExtortion(anotherToken, { event, player: 'c', mode: 'first' }), /expired/);
|
||||
const completed = { ...move(afterCompletion, 'd', 'first'), cursor: 4 };
|
||||
validateSaphoExtortion(completed);
|
||||
assert.equal(completed.queue.length, 4, 'Reordering never creates another payer or payment opportunity');
|
||||
});
|
||||
Reference in new issue
Block a user