Preserve Emperor gift opportunity before bot Extortion
Verify and publish checkpoint container / container (push) Successful in 10m54s

This commit is contained in:
Quintine committed 2026-10-30 17:44:00 +11:00
1 parent a6be17fc5d
commit 7c1f4b4e09
13 files changed
+370 -13

No files matched your search

@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { placeTerror } from '../game/moritani-terror';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export const extortionSeat = (game: Game, id: string) => {
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
};
export const extortionAct = (game: Game, id: string, action: Action) => applyAction(game, id, action);
export function extortionEvent(game: Game): string {
assert.ok(game.decision?.kind === 'moritaniExtortion');
return game.decision.event;
}
export function extortionPayment(game: Game, pay: boolean): Action {
return {type:'decision', event:extortionEvent(game), pay};
}
export function finishExtortionGiftResponse(game: Game): Game {
for (let count = 0; game.response && count < 30; count++) {
const responder = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(responder);
game = extortionAct(game, responder.id, {type:'passResponse'});
}
assert.equal(game.response, null);
return game;
}
/** A fresh public E3 Basic/Advanced start, genuine setup, shipment and Terror
* revelation. The alliance, movement boundary, token placement and wallets are
* staged; the original Extortion owner and subsequent choices are real. */
export function createExtortionBotPriority(advanced: boolean,
options: {unallied?: boolean; emperorSpice?: number; autopilot?: boolean} = {}) {
let game = createGame('EXTORTIONBOTPRIORITY', newPlayer('m', 'Moritani', 'moritani'), advanced, ['ecaz']);
for (const [id, name, faction] of [['e','Emperor','emperor'], ['a','Atreides','atreides'],
['h','Harkonnen','harkonnen']] as const) joinGame(game, newPlayer(id, name, faction));
for (const player of game.players) game = extortionAct(game, player.id, {type:'ready'});
game = extortionAct(game, 'm', advanced ? {type:'start', advancedPreview:true} : {type:'start'});
for (let count = 0; game.status === 'setup' && count < 100; count++) {
if (game.decision?.kind === 'moritaniSetup') game = extortionAct(game, 'm',
{type:'decision', territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]});
else {
const step = nextStrongholdFactionsNativeStep(game); assert.ok(step);
game = extortionAct(game, step.actor, step.action);
}
}
assert.equal(game.status, 'playing');
if (!options.unallied) { extortionSeat(game, 'e').ally = 'a'; extortionSeat(game, 'a').ally = 'e'; }
const token = game.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token);
game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'habbanya_ridge_sietch', 1);
Object.assign(game, {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:[]});
extortionSeat(game, 'e').spice = 10; extortionSeat(game, 'a').spice = 2;
const harkonnen = extortionSeat(game, 'h');
let card = harkonnen.hand.find(row => row.effect === 'karama');
if (!card) {
const sourceOwner = game.players.find(player => player.id !== 'h' && player.hand.some(row => row.effect === 'karama'));
const source = sourceOwner?.hand ?? game.deck, index = source.findIndex(row => row.effect === 'karama');
assert.ok(index >= 0); [card] = source.splice(index, 1); harkonnen.hand.push(card);
}
const karama = card.id;
game = extortionAct(game, 'e', {type:'ship', territory:'habbanya_ridge_sietch', sector:17, amount:1});
assert.equal(game.decision?.kind, 'moritaniTerror');
game = extortionAct(game, 'm', {type:'decision', reveal:true});
game.phase = 7; game.ready = [];
for (const player of game.players) game = extortionAct(game, player.id, {type:'ready'});
assert.equal(game.phase, 8);
assert.equal(game.decision?.kind, 'moritaniPlacement');
game = extortionAct(game, 'm', {type:'decision', decline:true});
assert.equal(game.decision?.kind, 'moritaniExtortion');
assert.equal(game.decision?.player, 'e');
if (options.autopilot) extortionSeat(game, 'a').autopilot = 'Easy';
else extortionSeat(game, 'a').bot = 'Easy';
if (options.emperorSpice !== undefined) extortionSeat(game, 'e').spice = options.emperorSpice;
game = extortionAct(game, 'e', extortionPayment(game, false));
assert.equal(game.decision?.kind, 'moritaniExtortion');
assert.equal(game.decision?.player, 'a');
return {game, token:token.id, karama};
}
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { botActions, runBots } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createExtortionBotPriority, extortionAct, extortionEvent, extortionPayment, extortionSeat,
finishExtortionGiftResponse } from './emperor-extortion-bot-priority-fixture';
for (const advanced of [false, true]) for (const autopilot of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} human Emperor gets Extortion gift priority before ${autopilot ? 'autopilot' : 'bot'} ally`, () => {
const {game: initial} = createExtortionBotPriority(advanced, {autopilot});
let game = initial;
const event = extortionEvent(game);
assert.deepEqual(viewGame(game, 'e').emperorExtortionPriority, {event, recipient:'a', canContinue:true});
for (const id of ['a','h','m'])
assert.deepEqual(viewGame(game, id).emperorExtortionPriority, {event, recipient:'a', canContinue:false});
const e = extortionSeat(game, 'e').spice, a = extortionSeat(game, 'a').spice;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'a');
const actor = view.players.find(player => player.id === 'a')!;
if (autopilot) actor.autopilot = difficulty; else actor.bot = difficulty;
assert.ok(!botActions(view).some(action => action.type === 'decision' && action.event === event), difficulty);
}
const waiting = runBots(game, 5);
assert.equal(waiting.decision?.kind, 'moritaniExtortion');
assert.equal(extortionEvent(waiting), event);
assert.equal(extortionSeat(waiting, 'e').spice, e);
assert.equal(extortionSeat(waiting, 'a').spice, a);
const original = structuredClone(game);
assert.throws(() => extortionAct(game, 'a', extortionPayment(game, false)));
assert.throws(() => extortionAct(game, 'a', {type:'emperorExtortionContinue', event}));
assert.throws(() => extortionAct(game, 'e', {type:'emperorExtortionContinue', event:'stale'}));
assert.deepEqual(game, original);
game = extortionAct(game, 'e', {type:'emperorExtortionContinue', event});
assert.ok(!viewGame(game, 'e').emperorExtortionPriority);
assert.deepEqual(game.emperorExtortionGiftRelease, {event, emperor:'e', recipient:'a'});
const action = botActions(viewGame(game, 'a')).find(row => row.type === 'decision' && row.event === event);
assert.ok(action); assert.equal(action.pay, false);
game = runBots(game, 1);
assert.ok(game.decision?.kind !== 'moritaniExtortion' || game.decision.event !== event);
});
for (const advanced of [false, true]) for (const canceled of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} ${canceled ? 'canceled' : 'successful'} Emperor gift preserves priority until Continue`, () => {
const fixture = createExtortionBotPriority(advanced);
let game = fixture.game;
const event = extortionEvent(game), emperor = extortionSeat(game, 'e').spice;
game = extortionAct(game, 'e', {type:'emperorGift', amount:1});
assert.equal(game.response?.kind, 'emperorGift');
game = canceled ? extortionAct(game, 'h', {type:'card', card:fixture.karama, mode:'cancel'}) :
finishExtortionGiftResponse(game);
assert.equal(game.response, null);
assert.equal(game.decision?.kind, 'moritaniExtortion');
assert.equal(extortionEvent(game), event);
assert.deepEqual(viewGame(game, 'e').emperorExtortionPriority, {event, recipient:'a', canContinue:true});
assert.equal(extortionSeat(game, 'a').spice, canceled ? 2 : 3);
assert.equal(extortionSeat(game, 'e').spice, emperor - (canceled ? 0 : 1));
const before = runBots(game, 5);
assert.equal(extortionEvent(before), event);
game = extortionAct(game, 'e', {type:'emperorExtortionContinue', event});
const action = botActions(viewGame(game, 'a')).find(row => row.type === 'decision' && row.event === event);
// The current minimal bot policy declines even when it can afford to pay.
assert.ok(action); assert.equal(action.pay, false);
game = runBots(game, 1);
assert.ok(game.decision?.kind !== 'moritaniExtortion' || game.decision.event !== event);
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === fixture.token)?.status, 'extortion');
});
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} no-spice Emperor still has neutral Continue priority`, () => {
const {game} = createExtortionBotPriority(advanced, {emperorSpice:0});
const event = extortionEvent(game);
assert.deepEqual(viewGame(game, 'e').emperorExtortionPriority, {event, recipient:'a', canContinue:true});
assert.deepEqual(viewGame(game, 'a').emperorExtortionPriority, {event, recipient:'a', canContinue:false});
assert.ok(!botActions(viewGame(game, 'a')).some(action => action.type === 'decision'));
const continued = extortionAct(game, 'e', {type:'emperorExtortionContinue', event});
assert.ok(botActions(viewGame(continued, 'a')).some(action => action.type === 'decision' && action.pay === false));
});
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} unaffiliated bot pays or declines without Emperor priority`, () => {
const {game} = createExtortionBotPriority(advanced, {unallied:true});
const event = extortionEvent(game);
assert.ok(!viewGame(game, 'e').emperorExtortionPriority);
const action = botActions(viewGame(game, 'a')).find(row => row.type === 'decision' && row.event === event);
assert.ok(action); assert.equal(action.pay, false);
const next = runBots(game, 1);
assert.ok(next.decision?.kind !== 'moritaniExtortion' || next.decision.event !== event);
});
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Children, createElement, isValidElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { Action } from '../game/engine';
import { EmperorExtortionPriority, type EmperorExtortionPriorityProps } from '../components/emperor-extortion-priority';
function props(): EmperorExtortionPriorityProps & { sent: Action[] } {
const sent: Action[] = [];
return {game:{emperorExtortionPriority:{event:'extortion-event',recipient:'ally',canContinue:true},
players:[{id:'ally',name:'Ally'}]}, busy:false, sent, act(action) {sent.push(action);}};
}
const render = (input: EmperorExtortionPriorityProps) => renderToStaticMarkup(createElement(EmperorExtortionPriority, input));
function button(input: EmperorExtortionPriorityProps) {
const section = EmperorExtortionPriority(input); assert.ok(section);
const match = Children.toArray(section.props.children).find(child => isValidElement(child) &&
(child.props as {'data-testid'?: string})['data-testid'] === 'emperor-extortion-continue');
assert.ok(isValidElement<{onClick:() => void; disabled:boolean}>(match));
return match;
}
void test('the Emperor sees a persistent, event-bound Extortion opportunity', () => {
const input = props(), html = render(input);
assert.match(html, /Before Ally makes the Extortion choice/);
assert.match(html, /Continue to Extortion/);
assert.equal(button(input).props.disabled, false);
button(input).props.onClick();
assert.deepEqual(input.sent, [{type:'emperorExtortionContinue',event:'extortion-event'}]);
});
void test('other viewers see the same neutral opportunity without a Continue control or private data', () => {
const input = props();
input.game.emperorExtortionPriority!.canContinue = false;
Object.assign(input.game, {privateSpice:19, privateCard:'secret-card'});
const html = render(input);
assert.match(html, /Waiting for the Emperor/);
assert.doesNotMatch(html, /emperor-extortion-continue|secret-card|19/);
assert.deepEqual(input.sent, []);
input.game.emperorExtortionPriority = null;
assert.equal(render(input), '');
});
void test('busy, invalid, stale or consumed opportunities cannot submit', () => {
for (const change of ['busy','empty','event','permission','replace','close'] as const) {
const input = props();
if (change === 'busy') input.busy = true;
if (change === 'empty') input.game.emperorExtortionPriority!.event = '';
const control = button(input);
if (change === 'busy' || change === 'empty') assert.equal(control.props.disabled, true);
if (change === 'event') input.game.emperorExtortionPriority!.event = 'other';
if (change === 'permission') input.game.emperorExtortionPriority!.canContinue = false;
if (change === 'replace') input.game.emperorExtortionPriority = {...input.game.emperorExtortionPriority!};
if (change === 'close') input.game.emperorExtortionPriority = null;
control.props.onClick();
assert.deepEqual(input.sent, [], change);
}
});
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions, runBots } from '../game/bots';
import { createSaphoStationPriorityFixture } from './sapho-station-priority-fixture';
for (const advanced of [false, true]) void test(
`${advanced ? 'Advanced' : 'Basic'} Station priority wins over a synthetic competing bot guide`, () => {
const fixture = createSaphoStationPriorityFixture(advanced);
const initial = fixture.game;
const event = initial.saphoStationPriority!.event;
const view = viewGame(initial, fixture.first);
// A supported Station decision normally suppresses turn-action guidance.
// Inject a competing projected guide to exercise defensive dispatch order;
// this does not claim that the combination occurs in an ordinary game.
view.turnActionGuide = {event:'synthetic-guide', action:{type:'ready'}};
const action = botActions(view)[0];
assert.deepEqual(action, {type:'saphoStationPass', event});
const afterPass = applyAction(initial, fixture.first, action);
assert.deepEqual(afterPass.saphoStationPriority?.passed, [fixture.first]);
assert.deepEqual(afterPass.ecologicalStorm?.queue?.choices, []);
assert.equal(viewGame(afterPass, fixture.holder).saphoStationPriority?.canPass, true);
// Real scheduler behavior is separately checked on the public-start state;
// only the fixture's Station placement and card custody were staged.
const scheduled = runBots(initial, 12);
assert.deepEqual(scheduled.saphoStationPriority?.passed, [fixture.first]);
assert.deepEqual(scheduled.ecologicalStorm?.queue?.choices, []);
assert.equal(viewGame(scheduled, fixture.holder).saphoStationPriority?.canPass, true);
},
);