Allow Emperor gifts during Moritani extortion
Verify and publish checkpoint container / container (push) Successful in 11m51s
Verify and publish checkpoint container / container (push) Successful in 11m51s
This commit is contained in:
1 parent
6cec4ed78b
commit
538247e777
8 files changed
+307
-4
No files matched your search
@@ -0,0 +1,174 @@
|
||||
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) {
|
||||
const index = g.deck.findIndex(row => row.effect === 'karama'); assert.ok(index >= 0);
|
||||
[card] = g.deck.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'}));
|
||||
});
|
||||
@@ -78,3 +78,23 @@ void test('the real Guild timing table exposes the gift only to the Emperor', ()
|
||||
assert.doesNotMatch(table(observer), /Share spice with Guild/);
|
||||
assert.match(table(own, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
|
||||
});
|
||||
|
||||
void test('an Extortion quote presents the recipient and restores its original decision after Karama', () => {
|
||||
const game = fixture();
|
||||
// Isolate the component contract: the underlying Guild fixture supplies the
|
||||
// seats, while this projected Extortion choice and quote are simulated.
|
||||
const decision = {kind:'moritaniExtortion' as const, player:'h', event:'extortion-test'};
|
||||
const own: GameView = {...viewGame(game, 'e'), decision, emperorGuildTimingGift:null,
|
||||
emperorExtortionGift:{recipient:'h', maximum:4}};
|
||||
const observer: GameView = {...viewGame(game, 'h'), decision, emperorGuildTimingGift:null,
|
||||
emperorExtortionGift:null};
|
||||
const html = component(own);
|
||||
assert.match(html, /Share spice with Harkonnen/);
|
||||
assert.match(html, /Moritani Extortion decision resumes after the Karama response/);
|
||||
assert.match(html, /type="number"[^>]*min="1"[^>]*max="4"/);
|
||||
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
|
||||
assert.equal(component(observer), '');
|
||||
assert.match(component(own, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
|
||||
assert.match(table(own), /Moritani Extortion decision resumes after the Karama response/);
|
||||
assert.doesNotMatch(table(observer), /Share spice with Harkonnen/);
|
||||
});
|
||||
Reference in new issue
Block a user