179 lines
8.9 KiB
TypeScript
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'}));
|
|
});
|