Handle advisor conversion after worm rides
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 09:10:53 +11:00
1 parent ad0abfef98
commit c608d6fea6
15 files changed
+507 -14

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame, type Game } from '../game/engine';
import { actAdvisorWormRide as act, advisorWormRidePlayer as player,
createAdvisorWormRideFixture as fixture } from './fixture-advisor-worm-ride-flip';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' :
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return {format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true};
},
});
const {GameTable} = await import('../components/game-table');
aliases.deregister();
/** Public setup is genuine; the fixture discloses its staged ride and conserved
* force placement. The real ride action opens the response under test. */
function response() {
const f = fixture();
const game = act(f.game,f.fremen,f.action);
assert.equal(game.response?.automaticAdvisorFlip?.producer,'wormRide');
return {...f,game};
}
function table(game:Game, player:string, busy = false) {
return renderToStaticMarkup(createElement(GameTable, {
game:viewGame(game,player), busy,
send:async () => { assert.fail('Rendering must not submit an action.'); },
onExit() { assert.fail('Rendering must not leave the table.'); },
}));
}
void test('the real worm-ride conversion explains its committed source to every seat', () => {
const f = response(), before = structuredClone(f.game);
for (const actor of [f.bg,f.fremen,f.holder]) {
const html = table(f.game,actor);
assert.match(html,/Bene Gesserit token flip/);
assert.match(html,/The worm ride and any collection are complete/);
assert.match(html,/advisors are now alone and must become fighters before the original arrival continues/);
assert.match(html,/Karama prevents only this conversion/);
}
assert.deepEqual(f.game,before);
});
void test('only the eligible holder sees native Karama and allowance controls', () => {
const f = response(), holder = viewGame(f.game,f.holder);
assert.ok(holder.responseControls?.cancelCards.includes(f.karama));
const html = table(f.game,f.holder);
assert.match(html,/Cancel with Karama/);
assert.match(html,/Allow this power/);
assert.doesNotMatch(table(f.game,f.bg),/Cancel with Karama/);
const busy = table(f.game,f.holder,true);
assert.match(busy,/<button(?=[^>]*disabled="")[^>]*>Cancel with Karama/);
});
void test('Fremen sees why summoning another worm waits for the current conversion', () => {
const f = fixture();
const holder = player(f.game,f.holder), fremen = player(f.game,f.fremen);
const index = holder.hand.findIndex(card => card.id === f.karama);
assert.ok(index >= 0 && fremen.hand.length > 0);
// Conserved physical-card swap before the actual ride gives Fremen Karama.
[holder.hand[index],fremen.hand[0]] = [fremen.hand[0],holder.hand[index]];
const game = act(f.game,f.fremen,f.action), before = structuredClone(game);
const html = table(game,f.fremen);
assert.match(html,/Resolve the completed worm ride’s advisor conversion before summoning another worm/);
assert.match(html,/<button(?=[^>]*disabled="")[^>]*>Spend Karama · summon Shai-Hulud/);
assert.throws(() => act(game,f.fremen,{type:'card',mode:'special',card:f.karama,territory:'hagga_basin'}));
assert.deepEqual(game,before);
});
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame } from '../game/engine';
import { isAdvisor } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createAdvisorWormRideFixture as fixture, actAdvisorWormRide as act,
advisorWormRideInventory as inventory, advisorWormRidePlayer as player,
passAdvisorWormRide as pass } from './fixture-advisor-worm-ride-flip';
import { sandmasterRide, sandmasterWormGame } from './fixture-sandmaster-worm';
for (const canceled of [false,true]) void test(
`native worm ride remains committed when source advisor conversion is ${canceled ? 'prevented' : 'allowed'}`, () => {
const f = fixture(), original = structuredClone(f.game);
let game = act(f.game,f.fremen,f.action);
assert.deepEqual(f.game,original);
assert.equal(game.response?.kind,'advisorFlip');
assert.equal(game.response?.automaticAdvisorFlip?.producer,'wormRide');
assert.equal(player(game,f.fremen).forces[f.source] ?? 0,0);
assert.equal(player(game,f.fremen).forces[f.destination],2);
assert.equal(player(game,f.fremen).forces['hagga_basin:12'],1);
assert.ok(isAdvisor(player(game,f.bg),f.origin));
assert.ok(viewGame(game,f.holder).responseControls?.cancelCards.includes(f.karama));
const committed = structuredClone(player(game,f.fremen));
game = canceled ? act(game,f.holder,{type:'card',card:f.karama,mode:'cancel'}) : pass(game);
assert.deepEqual(player(game,f.fremen),committed);
assert.equal(isAdvisor(player(game,f.bg),f.origin),canceled);
assert.equal(game.pendingAutomaticAdvisorFlips,undefined);
assert.equal(game.decision?.kind,'wormRide');
if (game.decision?.kind === 'wormRide') assert.equal(game.decision.territory,f.second);
assert.deepEqual(game.wormRides,[]);
assert.equal(game.discard.filter(card => card.id === f.karama).length,Number(canceled));
assert.deepEqual(inventory(game),f.originalCards);
},
);
void test('a partial ride leaves Fremen in the source and produces no alone conversion', () => {
const f=fixture({partial:true}), game=act(f.game,f.fremen,f.action);
assert.equal(player(game,f.fremen).forces[f.source],1);
assert.equal(player(game,f.fremen).forces[f.destination],1);
assert.ok(isAdvisor(player(game,f.bg),f.origin));
assert.equal(game.response?.automaticAdvisorFlip,undefined);
assert.deepEqual(inventory(game),f.originalCards);
});
void test('source conversion precedes the real destination intrusion and resumes it once', () => {
const f=fixture({intrusion:true});
let game=act(f.game,f.fremen,f.action);
assert.equal(game.response?.automaticAdvisorFlip?.producer,'wormRide');
assert.equal(game.decision,null);
game=pass(game);
assert.equal(game.decision?.kind,'intrusion');
if (game.decision?.kind === 'intrusion') assert.equal(game.decision.territory,'imperial_basin');
assert.equal(player(game,f.fremen).forces[f.destination],2);
assert.deepEqual(inventory(game),f.originalCards);
});
void test('Fremen cannot nest a special worm during the committed source conversion', () => {
const f=fixture(), source=structuredClone(f.game);
// Transfer the remaining physical Karama from the original deck to Fremen;
// the separately held Karama still keeps the advisor response open.
const index=source.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [card]=source.deck.splice(index,1); player(source,f.fremen).hand.push(card);
const cards=inventory(source);
const summon={type:'card',mode:'special',card:card.id,territory:'the_great_flat'};
let game=act(source,f.fremen,f.action);
assert.equal(game.response?.automaticAdvisorFlip?.producer,'wormRide');
const before=structuredClone(game);
assert.throws(() => act(game,f.fremen,summon));
assert.deepEqual(game,before);
assert.ok(player(game,f.fremen).hand.some(candidate => candidate.id === card.id));
assert.equal(player(game,f.fremen).specialKaramaUsed ?? false,false);
game=pass(game);
assert.equal(game.decision?.kind,'wormRide');
assert.equal(player(game,f.fremen).forces[f.destination],2);
game=act(game,f.fremen,summon);
assert.equal(player(game,f.fremen).specialKaramaUsed,true);
assert.equal(game.discard.filter(candidate => candidate.id === card.id).length,1);
assert.deepEqual(inventory(game),cards);
});
void test('all four bot profiles can resolve the original worm-ride conversion legally', () => {
const f=fixture(), pending=act(f.game,f.fremen,f.action);
for (const difficulty of DIFFICULTIES) {
const view=viewGame(pending,f.holder);
view.players.find(seat => seat.id === f.holder)!.bot=difficulty;
const action=botActions(view)[0]; assert.ok(action);
const game=pass(act(structuredClone(pending),f.holder,action));
assert.equal(game.decision?.kind,'wormRide');
if (game.decision?.kind === 'wormRide') assert.equal(game.decision.territory,f.second);
assert.equal(player(game,f.fremen).forces[f.destination],2);
assert.deepEqual(inventory(game),f.originalCards);
}
});
for (const canceled of [false,true]) void test(
`Sandmaster's native worm-ride collection occurs once when origin conversion is ${canceled ? 'prevented' : 'allowed'}`, () => {
// This fixture uses genuine Leader Skills setup and a staged, conserved
// ride. Move the actual BG force from the destination to the ride origin,
// and transfer one physical Karama to Fremen for the response.
const source=sandmasterWormGame(true,true), bg=player(source,'h'), rider=player(source,'p');
bg.forces={'wind_pass:14':1}; bg.reserves=19; bg.advisors={wind_pass:{}};
const index=source.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [card]=source.deck.splice(index,1); rider.hand.push(card);
const cards=inventory(source), beforeSpice=rider.spice, beforePile=source.spice['red_chasm:7'];
let game=act(source,'p',sandmasterRide());
assert.equal(game.response?.automaticAdvisorFlip?.producer,'wormRide');
assert.equal(player(game,'p').spice,beforeSpice+1);
assert.equal(game.spice['red_chasm:7'],beforePile-1);
assert.equal(game.log.filter(entry => entry.automatic?.name === 'Sandmaster collection').length,1);
game=canceled ? act(game,'p',{type:'card',card:card.id,mode:'cancel'}) : pass(game);
assert.equal(player(game,'p').spice,beforeSpice+1);
assert.equal(game.spice['red_chasm:7'],beforePile-1);
assert.equal(player(game,'p').forces['red_chasm:7'],4);
assert.equal(isAdvisor(player(game,'h'),'wind_pass'),canceled);
assert.equal(game.log.filter(entry => entry.automatic?.name === 'Sandmaster collection').length,1);
assert.deepEqual(inventory(game),cards);
},
);
+72
View File
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AutomaticAdvisorFlipError, quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip,
type AutomaticAdvisorFlipContext, type AutomaticAdvisorFlipSource, type AutomaticAdvisorWormRide,
} from '../game/advisor-automatic-flip';
type WormSource = Extract<AutomaticAdvisorFlipSource, {producer:'wormRide'}>;
/** A native Fremen ride has already deposited its physical forces in Hagga.
* Bene Gesserit's original Imperial Basin advisors are now alone. */
function source() {
const ride:AutomaticAdvisorWormRide = {event:'ride-1',player:'f',origin:'imperial_basin',
destination:'hagga_basin',sector:12,amount:3,elite:1};
const game:AutomaticAdvisorFlipContext = {status:'playing',advanced:true,turn:2,phase:1,
players:[
{id:'b',faction:'beneGesserit',forces:{'imperial_basin:9':2,'imperial_basin:10':1},
advisors:{imperial_basin:{aloneFlipHold:'flip-1'}}},
{id:'f',faction:'fremen',forces:{'hagga_basin:12':3},elites:{forces:{'hagga_basin:12':1}}},
{id:'a',faction:'atreides',forces:{}},
]};
const flip:WormSource = {event:'flip-1',owner:'b',territory:'imperial_basin',turn:2,phase:1,
forces:{'imperial_basin:9':2,'imperial_basin:10':1},producer:'wormRide',wormRide:ride};
return {game,flip};
}
void test('native committed worm ride binds its held advisor group without mutating it', () => {
const {game,flip}=source(), before=structuredClone({game,flip});
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(game,flip));
assert.deepEqual({game,flip},before);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(game,JSON.parse(JSON.stringify(flip)) as WormSource));
});
void test('worm ride rejects altered or malformed identity, origin, destination and force custody', () => {
const changes:Array<(game:AutomaticAdvisorFlipContext,flip:WormSource)=>void> = [
game => {game.phase=0;}, game => {game.advanced=false;}, game => {game.turn++;},
(_game,flip) => {flip.wormRide.event='';},
(_game,flip) => {flip.wormRide.player='missing';},
(game) => {game.players[1].faction='atreides';},
(_game,flip) => {flip.wormRide.origin='invalid';},
(_game,flip) => {flip.wormRide.destination='invalid';},
(_game,flip) => {flip.wormRide.destination=flip.wormRide.origin;},
(_game,flip) => {flip.wormRide.sector=10;},
(_game,flip) => {flip.wormRide.amount=0;},
(_game,flip) => {flip.wormRide.amount=4;},
(_game,flip) => {flip.wormRide.elite=4;},
(game) => {game.players[1].elites!.forces={'hagga_basin:12':0};},
(game) => {game.players[1].elites!.forces={'hagga_basin:12':3};},
(_game,flip) => {flip.territory='arrakeen';},
(game) => {game.players[0].forces={'imperial_basin:9':1};},
(game) => {game.players[2].forces={'imperial_basin:11':1};},
];
for (const change of changes) {
const {game,flip}=source(); change(game,flip); const before=structuredClone({game,flip});
assert.throws(() => validateAutomaticAdvisorFlip(game,flip),AutomaticAdvisorFlipError);
assert.deepEqual({game,flip},before);
}
const {game,flip}=source();
assert.throws(() => validateAutomaticAdvisorFlip(game,{...flip,wormRide:{...flip.wormRide,extra:true}} as WormSource),AutomaticAdvisorFlipError);
});
void test('worm ride quote uses public force presence and does not inspect hidden hands', () => {
const {game,flip}=source();
delete game.players[0].advisors!.imperial_basin.aloneFlipHold;
const quote=quoteAutomaticAdvisorFlips(game);
assert.deepEqual(quote,[{player:'b',territory:'imperial_basin',
forces:{'imperial_basin:9':2,'imperial_basin:10':1}}]);
game.players[0].advisors!.imperial_basin.aloneFlipHold=flip.event;
for (const player of game.players) for (const field of ['hand','leaders','traitors','spice'])
Object.defineProperty(player,field,{get() {throw new Error(`Private ${field} inspected`);}});
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(game,flip));
});
+4 -2
View File
@@ -156,11 +156,13 @@ void test('advisor-only forces cannot play Family Atomics', () => {
army(g, 'a', { 'imperial_basin:10': 1 });
army(g, 'b', { 'imperial_basin:10': 1 }, ['imperial_basin']);
g.phase = 0;
g.turn = 2;
g.stormPending = 1;
const atomics = baseDeck().find((c) => c.effect === 'atomics')!;
g.players[1].hand = [atomics];
setBattleHand(g, 'b', [atomics.id]);
assert.throws(
() => applyAction(g, 'b', { type: 'card', card: atomics.id }),
/forces on or adjacent/,
/fighters on or adjacent/,
);
});
void test('direct shipments match existing advisors, while a new direct shipment enters as fighters', () => {
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { actAdvisorPreparation as act, advisorPreparationInventory as inventory,
advisorPreparationPlayer as player, nextAdvisorPreparationStep } from './fixture-advisor-preparation';
export const actAdvisorWormRide = act;
export const advisorWormRidePlayer = player;
export const advisorWormRideInventory = inventory;
/** Genuine Basic-faction Advanced public start, prediction, native placements
* and Traitors. Only the unopened starting Treachery deal is selected. The
* later phase, current ride, queued ride and conserved force/stance positions
* are explicitly staged to isolate the source departure. */
export function createAdvisorWormRideFixture(options: {intrusion?: boolean; partial?: boolean} = {}) {
const bg = 'beneGesserit', fremen = 'fremen', holder = 'atreides';
let game = createGame('ADVISORWORMRIDE', newPlayer(bg,'Bene Gesserit','beneGesserit'),true);
joinGame(game,newPlayer(fremen,'Fremen','fremen'));
joinGame(game,newPlayer(holder,'Atreides','atreides'));
for (const seat of game.players) game = act(game,seat.id,{type:'ready'});
game = act(game,bg,{type:'start',advancedPreview:true});
assert.equal(game.status,'setup');
const originalCards = inventory(game);
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0,2);
assert.equal(ordinary.length,2);
for (const [position,card] of [ordinary[0],ordinary[1],karama].entries()) {
const index = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(index >= position);
[game.deck[position],game.deck[index]] = [game.deck[index],game.deck[position]];
}
for (let count=0; game.status === 'setup' && count<100; count++) {
if (game.setupStage === 'forces' && player(game,fremen).reserves === 20) {
game = act(game,fremen,{type:'fremenSetup',placements:{sietch_tabr:10}});
continue;
}
const step = nextAdvisorPreparationStep(game); assert.ok(step);
game = act(game,step.actor,step.action);
}
assert.equal(game.status,'playing'); assert.equal(game.advanced,true);
assert.ok(player(game,holder).hand.some(card => card.id === karama.id));
assert.deepEqual(inventory(game),originalCards);
const bgSeat = player(game,bg), f = player(game,fremen), a = player(game,holder);
bgSeat.forces = {'red_chasm:7':2, ...(options.intrusion ? {'imperial_basin:10':1} : {})};
bgSeat.reserves = 20 - (options.intrusion ? 3 : 2); bgSeat.tanks = 0;
bgSeat.advisors = {red_chasm:{}};
f.forces = {'red_chasm:7':2, 'hagga_basin:12':1}; f.reserves = 17; f.tanks = 0;
f.elites = {reserves:3,tanks:0,forces:{},revived:0};
a.forces = {}; a.reserves = 20; a.tanks = 0;
Object.assign(game,{phase:1,turn:2,nexus:true,active:null,ready:[],storm:18,
stormPending:null,phaseOpening:null,response:null,truthtrance:null,
spiceWindow:null,spiceResolution:null,spiceSequence:{pile:1,skipped:[]},
wormRides:['hagga_basin'],decision:{kind:'wormRide',player:fremen,territory:'red_chasm'}});
const action:Action = {type:'decision',accept:true,territory:'imperial_basin',sector:10,
forces:{'red_chasm:7':options.partial ? 1 : 2},eliteForces:{'red_chasm:7':0}};
return {game,bg,fremen,holder,karama:karama.id,action,origin:'red_chasm',source:'red_chasm:7',
destination:'imperial_basin:10',second:'hagga_basin',originalCards,partial:!!options.partial,
intrusion:!!options.intrusion};
}
/** Pass only the committed source conversion; preserve any next decision. */
export function passAdvisorWormRide(source:Game):Game {
let game = source;
for (let count=0; game.response?.automaticAdvisorFlip && count<30; count++) {
const actor = game.players.find(seat => !game.response!.passed.includes(seat.id)); assert.ok(actor);
game = act(game,actor.id,{type:'passResponse'});
}
assert.ok(!game.response?.automaticAdvisorFlip); return game;
}
+2
View File
@@ -22,6 +22,8 @@ export function nexusAdvisorFixture(
seatIds: options.seatIds,
phase: 5,
});
// The controlled jump to Movement skips Charity's normal phase-end cleanup.
delete g.charityOrder;
const [owner, opponent] = g.players;
const cards = g.nexusCards!.cards!;
const index = cards.deck.indexOf('beneGesserit');
+6 -8
View File
@@ -5,6 +5,7 @@ import {
createGame,
joinGame,
newPlayer,
initializeBaseGameForAudit,
initializeHomeworldGameForAudit,
normalizeAutomaticGame,
viewGame,
@@ -15,7 +16,7 @@ import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { homeworldGameIntegrity } from '../game/homeworld-game';
function setup(reserves = 11, advanced = true, withKarama = true) {
function setup(reserves = 11, advanced = true, withKarama = true, withHomeworlds = true) {
let g = createGame(
'SPIRITUALHOMEWORLD',
newPlayer('atreides', 'Atreides', 'atreides'),
@@ -23,10 +24,11 @@ function setup(reserves = 11, advanced = true, withKarama = true) {
[],
);
joinGame(g, newPlayer('beneGesserit', 'Bene Gesserit', 'beneGesserit'));
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
if (withHomeworlds)
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
for (const player of g.players)
g = applyAction(g, player.id, { type: 'ready' });
g = initializeHomeworldGameForAudit(g);
g = withHomeworlds ? initializeHomeworldGameForAudit(g) : initializeBaseGameForAudit(g);
for (let i = 0; g.status === 'setup' && i < 30; i++) {
let moved = false;
for (const player of g.players) {
@@ -219,11 +221,7 @@ void test('advisor group size rejects malformed requests before cards, reserves
});
void test('absent Homeworld module preserves ordinary one-force accompaniment and rejects two', () => {
const g = setup(11).g;
// This parity fixture deliberately removes the whole optional module,
// including its setup evidence; it does not represent a legal live action.
g.homeworlds = null;
delete g.homeworldOccupationHistory;
const g = setup(11, true, true, false).g;
const offered = ship(g);
reject(offered, 'beneGesserit', {
type: 'decision',