Files
Dune/tests/emperor-extortion-gift.test.ts
Quintine 72f6ec2b12
Verify and publish checkpoint container / container (push) Canceled after 0s
Allow Basic bribes during selected pending decisions
2026-10-29 06:42:50 +11:00

179 lines
8.9 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { placeTerror } from '../game/moritani-terror';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
const seat = (g: Game, id: string) => { const p = g.players.find(row => row.id === id); assert.ok(p); return p; };
const act = (g: Game, id: string, action: Action) => applyAction(g, id, action);
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function allow(g: Game) {
for (let i = 0; g.response && i < 30; i++) {
const p = g.players.find(row => !g.response!.passed.includes(row.id)); assert.ok(p);
g = act(g, p.id, {type:'passResponse'});
}
assert.equal(g.response, null);
return g;
}
function payment(g: Game, pay: boolean): Action {
const d = g.decision; assert.ok(d?.kind === 'moritaniExtortion');
return {type:'decision', event:d.event, pay};
}
function counter(g: Game) {
const p = seat(g, 'h');
let card = p.hand.find(row => row.effect === 'karama');
if (!card) {
// Starting cards are shuffled: the physical Karama may already be in
// another hand rather than the unopened deck.
const owner = g.players.find(player => player.id !== p.id && player.hand.some(row => row.effect === 'karama'));
const source = owner?.hand ?? g.deck;
const index = source.findIndex(row => row.effect === 'karama'); assert.ok(index >= 0);
[card] = source.splice(index, 1); p.hand.push(card);
}
assert.ok(card); return card.id;
}
/** Genuine public E3/Basic or Advanced start and native setup. Stage the
* reciprocal alliance, clean movement boundary, a physical Extortion token's
* legal prior placement and the named test wallets. A real shipment triggers
* revelation, and actual Mentat controls collect bank5 and create the queue. */
function pending(advanced: boolean) {
let g = createGame('EMPEROREXTORTION', newPlayer('m', 'Moritani', 'moritani'), advanced, ['ecaz']);
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
for (const p of g.players) g = act(g, p.id, {type:'ready'});
g = act(g, 'm', advanced ? {type:'start', advancedPreview:true} : {type:'start'});
for (let i = 0; i < 100 && g.status === 'setup'; i++) {
if (g.decision?.kind === 'moritaniSetup') {
g = act(g, 'm', {type:'decision', territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]});
} else {
const step = nextStrongholdFactionsNativeStep(g); assert.ok(step);
g = act(g, step.actor, step.action);
}
}
assert.equal(g.status, 'playing');
seat(g, 'e').ally = 'a'; seat(g, 'a').ally = 'e';
// A stopped ordinary shipment is not involved: the original entry commits
// before the native Terror decision. The source physical token is unchanged.
const token = g.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token);
g.moritaniTerror = placeTerror(g.moritaniTerror!, token.id, 'habbanya_ridge_sietch', 1);
Object.assign(g, {phase:5, turn:2, storm:18, order:['e','a','h','m'], active:'e',
movementRemaining:['e','a','h','m'], response:null, decision:null, phaseOpening:null, ready:[]});
seat(g, 'e').spice = 10; seat(g, 'a').spice = 2;
const karama = counter(g);
g = act(g, 'e', {type:'ship', territory:'habbanya_ridge_sietch', sector:17, amount:1});
assert.equal(g.decision?.kind, 'moritaniTerror');
g = act(g, 'm', {type:'decision', reveal:true});
assert.equal(g.moritaniTerror!.tokens.find(row => row.id === token.id)?.status, 'extortion');
const beforeAward = seat(g, 'm').spice;
// Enter the original Mentat pipeline after the played reveal, like the
// existing Extortion engine fixture; the physical reveal remains intact.
g.phase = 7; g.ready = [];
for (const p of g.players) g = act(g, p.id, {type:'ready'});
assert.equal(g.phase, 8);
assert.equal(g.decision?.kind, 'moritaniPlacement');
g = act(g, 'm', {type:'decision', decline:true});
assert.equal(seat(g, 'm').spice, beforeAward + 5);
assert.equal(g.decision?.kind, 'moritaniExtortion');
assert.equal(g.decision?.player, 'e');
g = act(g, 'e', payment(g, false));
assert.equal(g.decision?.kind, 'moritaniExtortion');
assert.equal(g.decision?.player, 'a');
assert.equal(viewGame(g, 'a').extortion.pending?.canPay, false);
return {g, token:token.id, karama, beforeAward};
}
function parent(g: Game) {
return {decision:structuredClone(g.decision), obligation:structuredClone(g.moritaniExtortion),
order:[...g.order], turn:g.turn, phase:g.phase};
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} Emperor gift lets an insolvent ally pay the original Extortion`, () => {
const fixture = pending(advanced), {token, beforeAward} = fixture;
let g = fixture.g;
const decision = g.decision; assert.ok(decision?.kind === 'moritaniExtortion');
const before = parent(g), emperor = seat(g, 'e').spice, event = decision.event;
assert.deepEqual(viewGame(g, 'e').emperorExtortionGift, {recipient:'a', maximum:emperor});
for (const other of ['a','h','m']) assert.equal(viewGame(g, other).emperorExtortionGift, null);
g = act(g, 'e', {type:'emperorGift', amount:1});
assert.equal(g.response?.kind, 'emperorGift');
assert.equal(viewGame(g, 'e').response?.amount, 1);
assert.equal(viewGame(g, 'a').response?.amount, 1);
assert.equal(viewGame(g, 'h').response?.amount, undefined);
assert.equal(viewGame(g, 'm').response?.amount, undefined);
assert.equal(seat(g, 'a').spice, 2);
g = allow(reload(g));
assert.deepEqual(parent(g), before);
assert.equal(seat(g, 'e').spice, emperor - 1);
assert.equal(seat(g, 'a').spice, 3);
assert.equal(seat(g, 'm').spice, beforeAward + 5);
assert.equal(viewGame(g, 'a').extortion.pending?.canPay, true);
g = act(g, 'a', {type:'decision', event, pay:true});
assert.equal(seat(g, 'a').spice, 0);
assert.equal(seat(g, 'm').spice, beforeAward + 8);
assert.equal(g.moritaniTerror!.tokens.find(row => row.id === token)?.status, 'removed');
assert.equal(g.moritaniExtortion, undefined);
});
void test(`${advanced ? 'Advanced' : 'Basic'} Karama cancels the gift without changing the unaffordable payer or event`, () => {
const fixture = pending(advanced), {token, karama, beforeAward} = fixture;
let g = fixture.g;
const before = parent(g), emperor = seat(g, 'e').spice;
g = act(g, 'e', {type:'emperorGift', amount:1});
assert.equal(g.response?.kind, 'emperorGift');
g = act(reload(g), 'h', {type:'card', card:karama, mode:'cancel'});
assert.equal(g.response, null);
assert.deepEqual(parent(g), before);
assert.equal(seat(g, 'e').spice, emperor);
assert.equal(seat(g, 'a').spice, 2);
assert.equal(viewGame(g, 'a').extortion.pending?.canPay, false);
assert.equal(seat(g, 'm').spice, beforeAward + 5);
assert.equal(g.moritaniTerror!.tokens.find(row => row.id === token)?.status, 'extortion');
assert.equal(g.discard.filter(card => card.id === karama).length, 1);
const snapshot = reload(g);
assert.throws(() => act(g, 'a', payment(g, true)));
assert.deepEqual(g, snapshot);
g = act(g, 'a', payment(g, false));
assert.equal(seat(g, 'm').spice, beforeAward + 5);
});
}
void test('unauthorized, invalid, and stale Extortion actions do not change the live opportunity', () => {
const {g} = pending(false), before = reload(g);
for (const [actor, action] of [
['a', {type:'emperorGift', amount:1}], ['h', {type:'emperorGift', amount:1}],
['e', {type:'emperorGift', amount:0}], ['e', {type:'emperorGift', amount:seat(g, 'e').spice + 1}],
['a', {type:'decision', event:'stale', pay:false}],
] as [string, Action][]) assert.throws(() => act(g, actor, action));
assert.deepEqual(g, before);
});
void test('all minimal bot profiles choose a legal response to the original gift', () => {
let {g} = pending(false);
g = act(g, 'e', {type:'emperorGift', amount:1});
assert.equal(g.response?.kind, 'emperorGift');
for (const difficulty of DIFFICULTIES) {
const view = viewGame(g, 'h');
view.players.find(p => p.id === 'h')!.bot = difficulty;
const actions = botActions(view); assert.ok(actions.length > 0);
assert.doesNotThrow(() => act(reload(g), 'h', actions[0]));
}
});
void test('the active gift remains bound to the original Extortion decision and amount', () => {
const {g: start} = pending(false);
const g = act(start, 'e', {type:'emperorGift', amount:1});
const before = reload(g);
assert.throws(() => act(g, 'a', payment(g, false)));
assert.deepEqual(g, before);
const changed = reload(g);
assert.ok(changed.response);
changed.response.amount = 2;
assert.throws(() => viewGame(changed, 'a'));
assert.throws(() => act(changed, 'h', {type:'passResponse'}));
});