Files
Dune/tests/moritani-extortion.test.ts
T
2026-09-08 07:37:52 +10:00

259 lines
8.1 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
answerExtortion,
collectExtortion,
reserveExtortion,
type ExtortionContext,
type ExtortionResult,
type ExtortionState,
} from '../game/moritani-extortion';
const context = (phase = 5): ExtortionContext => ({
turn: 4,
phase,
players: [
{ id: 'm', spice: 1 },
{ id: 'ally', spice: 3 },
{ id: 'entrant', spice: 7 },
{ id: 'poor', spice: 0 },
],
});
const reserve = () =>
reserveExtortion({ owner: 'm', token: 'terror-4', turn: 4 }, context()).state;
const collect = (state = reserve()) =>
collectExtortion(state, {
...context(8),
stormOrder: ['poor', 'm', 'ally', 'entrant'],
});
const reload = (state: ExtortionState): ExtortionState =>
JSON.parse(JSON.stringify(state));
function applyTransfer(
ctx: ExtortionContext,
result: ExtortionResult,
): ExtortionContext {
const next = structuredClone(ctx);
const transfer = result.transfer;
if (transfer) {
next.players.find((p) => p.id === transfer.to)!.spice += transfer.amount;
if (transfer.from === 'player')
next.players.find((p) => p.id === transfer.player)!.spice -=
transfer.amount;
}
return next;
}
function rejectWithoutMutation(
state: ExtortionState,
ctx: ExtortionContext,
action: () => unknown,
) {
const before = structuredClone({ state, ctx });
assert.throws(action);
assert.deepEqual({ state, ctx }, before);
}
void test('revelation reserves exactly five bank spice without making it spendable or moving the token', () => {
const ctx = context();
const before = structuredClone(ctx);
const result = reserveExtortion(
{ owner: 'm', token: 'terror-4', turn: 4 },
ctx,
);
assert.deepEqual(result, {
state: {
owner: 'm',
token: 'terror-4',
turn: 4,
bank: { reserved: 5, collected: 0 },
queue: [],
cursor: 0,
stage: 'reserved',
payer: null,
},
transfer: null,
recover: null,
});
assert.deepEqual(ctx, before);
assert.equal(ctx.players[0].spice, 1);
});
void test('Mentat collection credits five once and snapshots storm order including ally and insolvent seats', () => {
const state = reserve();
const before = structuredClone(state);
const result = collect(state);
assert.deepEqual(result.transfer, {
from: 'reservedBank',
to: 'm',
amount: 5,
});
assert.deepEqual(result.state.bank, { reserved: 0, collected: 5 });
assert.deepEqual(result.state.queue, ['poor', 'ally', 'entrant']);
assert.equal(result.state.cursor, 0);
assert.equal(result.state.stage, 'payment');
assert.equal(result.recover, null);
assert.deepEqual(state, before);
const ctx = applyTransfer(context(8), result);
assert.equal(ctx.players[0].spice, 6);
rejectWithoutMutation(result.state, ctx, () =>
collectExtortion(reload(result.state), {
...ctx,
stormOrder: ['entrant', 'm', 'ally', 'poor'],
}),
);
});
void test('first exact-three payment settles non-recovery and the owner receives bank five plus payer three', () => {
const collected = collect();
let ctx = applyTransfer(context(8), collected);
const passed = answerExtortion(reload(collected.state), 'poor', false, ctx);
assert.equal(passed.transfer, null);
assert.equal(passed.recover, null);
const paid = answerExtortion(reload(passed.state), 'ally', true, ctx);
assert.deepEqual(paid.transfer, {
from: 'player',
player: 'ally',
to: 'm',
amount: 3,
});
assert.equal(paid.recover, false);
assert.equal(paid.state.stage, 'settled');
assert.equal(paid.state.payer, 'ally');
ctx = applyTransfer(ctx, paid);
assert.equal(ctx.players[0].spice, 9);
assert.equal(ctx.players[1].spice, 0);
assert.equal(ctx.players[2].spice, 7);
for (const payer of ['poor', 'ally', 'entrant'])
rejectWithoutMutation(paid.state, ctx, () =>
answerExtortion(reload(paid.state), payer, true, ctx),
);
});
void test('all explicit passes request token recovery exactly at the last payer and preserve the bank award', () => {
let result = collect();
const ctx = applyTransfer(context(8), result);
for (const payer of ['poor', 'ally', 'entrant']) {
result = answerExtortion(reload(result.state), payer, false, ctx);
assert.equal(result.transfer, null);
assert.equal(result.recover, payer === 'entrant' ? true : null);
}
assert.equal(result.state.token, 'terror-4');
assert.equal(result.state.cursor, 3);
assert.equal(result.state.payer, null);
assert.equal(ctx.players[0].spice, 6);
rejectWithoutMutation(result.state, ctx, () =>
answerExtortion(result.state, 'entrant', false, ctx),
);
});
void test('insolvent players remain queued, cannot pay early, and may use newly available spice', () => {
const result = collect();
const ctx = applyTransfer(context(8), result);
rejectWithoutMutation(result.state, ctx, () =>
answerExtortion(result.state, 'poor', true, ctx),
);
assert.equal(result.state.queue[result.state.cursor], 'poor');
ctx.players.find((p) => p.id === 'poor')!.spice = 3;
const paid = answerExtortion(result.state, 'poor', true, ctx);
assert.equal(paid.state.payer, 'poor');
assert.equal(paid.recover, false);
});
void test('payer cursor rejects self-payment, out-of-order seats, stale choices and nonboolean payments', () => {
const state = collect().state;
const ctx = context(8);
for (const payer of ['m', 'ally', 'entrant', 'unknown'])
rejectWithoutMutation(state, ctx, () =>
answerExtortion(state, payer, false, ctx),
);
rejectWithoutMutation(state, ctx, () =>
answerExtortion(state, 'poor', 3 as unknown as boolean, ctx),
);
const passed = answerExtortion(state, 'poor', false, ctx);
rejectWithoutMutation(passed.state, ctx, () =>
answerExtortion(passed.state, 'poor', false, ctx),
);
});
void test('collection and payment reject wrong phase or turn without inventing Mentat-relative scheduling', () => {
const reserved = reserve();
const waiting = collect().state;
for (const ctx of [{ ...context(8), turn: 5 }, context(5), context(9)]) {
rejectWithoutMutation(reserved, ctx, () =>
collectExtortion(reserved, {
...ctx,
stormOrder: ['poor', 'm', 'ally', 'entrant'],
}),
);
rejectWithoutMutation(waiting, ctx, () =>
answerExtortion(waiting, 'poor', false, ctx),
);
}
assert.throws(() => answerExtortion(reserved, 'poor', false, context(8)));
});
void test('bad queue, player balance and ledger records reject rather than duplicate or lose payment custody', () => {
for (const stormOrder of [
['m', 'ally', 'entrant'],
['m', 'ally', 'poor', 'poor'],
['m', 'ally', 'poor', 'unknown'],
])
assert.throws(() =>
collectExtortion(reserve(), { ...context(8), stormOrder }),
);
for (const bad of [-1, 0.5, NaN, Infinity]) {
const ctx = context();
ctx.players[0].spice = bad;
assert.throws(() =>
reserveExtortion({ owner: 'm', token: 'terror-4', turn: 4 }, ctx),
);
}
for (const mutate of [
(s: ExtortionState) => {
s.bank.reserved = 5;
},
(s: ExtortionState) => {
s.cursor = 99;
},
(s: ExtortionState) => {
s.queue[0] = 'm';
},
(s: ExtortionState) => {
s.queue[0] = 'ally';
},
(s: ExtortionState) => {
s.owner = 'missing';
},
(s: ExtortionState) => {
s.token = '';
},
]) {
const state = collect().state;
mutate(state);
const ctx = context(8);
rejectWithoutMutation(state, ctx, () =>
answerExtortion(state, 'poor', false, ctx),
);
}
});
void test('cloned ledger and queue outputs do not alias callers, and an empty other-player queue settles recovery after collection', () => {
const state = reserve();
const result = collect(state);
result.state.queue[0] = 'edited';
result.state.bank.collected = 0;
assert.deepEqual(state, reserve());
const ctx: ExtortionContext = {
turn: 4,
phase: 8,
players: [{ id: 'm', spice: 0 }],
};
const reserved = reserveExtortion(
{ owner: 'm', token: 'terror-4', turn: 4 },
ctx,
);
const empty = collectExtortion(reserved.state, { ...ctx, stormOrder: ['m'] });
assert.equal(empty.recover, true);
assert.equal(empty.transfer?.amount, 5);
assert.deepEqual(empty.state.queue, []);
});