Files
Dune/tests/sapho-extortion-engine.test.ts
T
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

130 lines
8.6 KiB
TypeScript

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);
}
});