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

+6 -1
View File
@@ -2687,6 +2687,8 @@ export function GameTable({
? `Storm casualties in sector ${g.response.automaticAdvisorFlip.storm.sector} are complete. These surviving advisors are now alone and must become fighters before the remaining storm traversal.`
: g.response.automaticAdvisorFlip.producer === 'specialRelocation'
? `${g.response.automaticAdvisorFlip.relocation.kind === 'ambassador' ? 'Fremen Ambassador relocation' : 'The free Discovery force transfer'} is complete. These advisors are now alone and must become fighters before the original arrival continues.`
: g.response.automaticAdvisorFlip.producer === 'wormRide'
? 'The worm ride and any collection are complete. These advisors are now alone and must become fighters before the original arrival continues.'
: g.response.automaticAdvisorFlip.producer === 'spiritualArrival'
? 'The free Spiritual Advisor packet has arrived and its reserves and any earned income stay committed. These advisors are now alone and must become fighters before the original arrival continues.'
: 'The ground movement is complete. These advisors are now alone and must become fighters.'}
@@ -6439,6 +6441,9 @@ export function GameTable({
territory. Resolve protection now; Nexus and rides
follow the spice blow.
</p>
{g.response?.automaticAdvisorFlip?.producer === 'wormRide' && <p className="notice">
Resolve the completed worm ride’s advisor conversion before summoning another worm.
</p>}
<label htmlFor={`summon-worm-${c.id}`}>
Sand territory
<select
@@ -6473,7 +6478,7 @@ export function GameTable({
card: c.id,
territory: selected,
},
!TERRITORIES.some(
g.response?.automaticAdvisorFlip?.producer === 'wormRide' || !TERRITORIES.some(
(t) => t.id === selected && t.type === 'sand',
),
)}
+21
View File
@@ -1,5 +1,26 @@
# Mandatory advisor conversions
## Native Fremen worm ride — 29 October 2026
**Prototyped / Partial.** When an accepted native Fremen worm ride leaves
Bene Gesserit advisors alone at the origin, the ride and any Sandmaster
collection finish first. The existing mandatory conversion response then
allows or prevents the flip before the original destination intrusion and
entry reactions. Afterward the original remaining worm queue continues once;
no force is moved or spice collected again. A partial ride leaving another
faction present does not open this response.
The selected timing combines the supplied Advanced physical pages 17 and 28 E
with the existing worm sequence; it is a provisional online ordering. A
prevented group retains the existing continuously-alone hold. Fremen's special
Karama to summon another worm waits for this conversion to finish; its nested
losses and altered worm queue need a separate continuation. Nexus Cunning,
Great Maker and unrelated special arrivals also need separate source contracts.
The native tests use genuine Advanced setup with explicitly staged ride and
conserved force positions; the Sandmaster cases use a genuine Skills setup and
staged board and card custody. Complete faction, mode and deployed acceptance
remain open.
## Ambassador and Discovery relocation — 24 October 2026
**Prototyped / Partial.** Compose the supplied
+18
View File
@@ -17,6 +17,24 @@ open mode gates.
## Current checkpoint and work
**Advanced advisor conversion after native worm rides — 29 October.
Prototyped / Partial.** A Fremen ride that leaves Bene Gesserit advisors alone
opens the native conversion/Karama response before destination reactions and
the remaining worm queue. The ride and any Sandmaster collection stay
committed; allow or prevention resumes the original arrival once. Genuine
Advanced setup with staged conserved ride positions covers partial rides,
destination intrusion, the native counter, controls and all four legal bot
profiles. Two Skills cases cover actual Sandmaster collection. Fourteen related
files pass 156 cases, including 14 new cases; type checking, lint and the
production build pass. Fremen's special Karama to summon another worm waits
until this conversion resolves; Nexus Cunning, Great Maker and other special
arrivals remain open. The timing is provisional and deployed acceptance is
unverified. [Scope](ADVISOR_AUTOMATIC_FLIPS.md#native-fremen-worm-ride--29-october-2026).
The broader advisor selection exposed 12 fixture failures reproduced on prior
commit `ad0abfe`. The fixtures now model the Charity phase transition, a game
without Homeworlds, and legal Advanced Atomics timing; their 40 cases pass.
**Upcoming Advanced Charity Truthtrance — 29 October. Prototyped / Partial.**
In quiet Advanced Storm and Spice Blow, a Charity answer can bind the player's
upcoming ordered turn. Existing early readiness and the Charity queue remain;
+4
View File
@@ -2370,6 +2370,10 @@ and parked responses, then consume the private parent before original arrival
resumes once. Continuously-alone cancellation duration remains provisional.
[Connected source and boundaries](ADVISOR_AUTOMATIC_FLIPS.md#ambassador-and-discovery-relocation--24-october-2026).
The native Fremen worm ride now uses the same provisional committed-move
priority: finish the ride and collection, resolve source conversion, then
continue its original arrival and worm queue. [Worm scope](ADVISOR_AUTOMATIC_FLIPS.md#native-fremen-worm-ride--29-october-2026).
## Advisor conversion during storm traversal — 24 October 2026
Compose supplied Advanced physical17/28 E with actual current-sector storm
+42 -1
View File
@@ -78,6 +78,17 @@ export type AutomaticAdvisorRelocation = {
sector: number;
};
/** Completed native Fremen ride, retained while its original arrival waits. */
export type AutomaticAdvisorWormRide = {
event: string;
player: string;
origin: string;
destination: string;
sector: number;
amount: number;
elite: number;
};
export type AutomaticAdvisorFlipSource = {
event: string;
owner: string;
@@ -94,6 +105,7 @@ export type AutomaticAdvisorFlipSource = {
| { producer: 'faceDance'; faceDance: AutomaticAdvisorFaceDance }
| { producer: 'storm'; storm: AutomaticAdvisorStorm }
| { producer: 'specialRelocation'; relocation: AutomaticAdvisorRelocation }
| { producer: 'wormRide'; wormRide: AutomaticAdvisorWormRide }
| { producer: 'spiritualArrival'; spiritual: SpiritualAdvisorSource }
);
@@ -104,6 +116,7 @@ export type AutomaticAdvisorFlipPlayer = ForcePresence & {
moved?: number;
shipped?: boolean;
reserves?: number;
elites?: { forces: Readonly<Record<string, number>> };
};
export type AdvisorSettlementContext = {
@@ -445,6 +458,31 @@ function validateRelocationSource(
'Automatic advisor conversion lost the original relocated actor at its destination.');
}
function validateWormRideSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'wormRide' }>,
) {
const ride = source.wormRide;
requireFlip(record(ride) &&
Object.keys(ride).sort().join(',') === 'amount,destination,elite,event,origin,player,sector' &&
identifier(ride.event) && identifier(ride.player) && identifier(ride.origin) &&
identifier(ride.destination) && ride.origin !== ride.destination &&
nonnegative(ride.sector) && positive(ride.amount) && nonnegative(ride.elite) && ride.elite <= ride.amount,
'Automatic advisor conversion lost its exact completed worm ride.');
const places = gameTerritories(ctx);
requireFlip(places.some(place => place.id === ride.origin) &&
places.some(place => place.id === ride.destination && place.sectors.includes(ride.sector)) &&
(source.territory === ride.origin || source.territory === ride.destination),
'Automatic advisor conversion must belong to the original worm ride origin or destination.');
const riders = ctx.players.filter(player => player.id === ride.player);
const rider = riders[0], location = `${ride.destination}:${ride.sector}`;
const deposited = rider?.forces[location], elite = rider?.elites?.forces[location] ?? 0;
requireFlip(riders.length === 1 && rider.faction === 'fremen' &&
nonnegative(deposited) && nonnegative(elite) && elite <= deposited &&
elite >= ride.elite && deposited - elite >= ride.amount - ride.elite,
'Automatic advisor conversion lost the original native Fremen ride and deposited forces.');
}
function validateSpiritualArrivalSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'spiritualArrival' }>,
@@ -484,6 +522,8 @@ export function validateAutomaticAdvisorFlip(
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,storm,territory,turn') ||
(source.producer === 'specialRelocation' &&
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,relocation,territory,turn') ||
(source.producer === 'wormRide' &&
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,territory,turn,wormRide') ||
(source.producer === 'spiritualArrival' &&
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,spiritual,territory,turn')) &&
identifier(source.event) && identifier(source.owner) && identifier(source.territory) &&
@@ -492,7 +532,7 @@ export function validateAutomaticAdvisorFlip(
? record(source.relocation) && (source.relocation.kind === 'discovery'
? source.phase === 0 : source.relocation.kind === 'ambassador' && [1, 3, 5, 8].includes(source.phase))
: source.producer === 'spiritualArrival' ? [1, 3, 4, 5, 6, 8].includes(source.phase)
: source.phase === (source.producer === 'storm' ? 0 : source.producer === 'gamont' ? 8 :
: source.phase === (source.producer === 'storm' ? 0 : source.producer === 'wormRide' ? 1 : source.producer === 'gamont' ? 8 :
source.producer === 'battleLosses' || source.producer === 'faceDance' ? 6 : 5)) &&
currentWindow(ctx) && source.phase === ctx.phase,
'Automatic advisor conversion lost its original current-turn committed producer.');
@@ -514,6 +554,7 @@ export function validateAutomaticAdvisorFlip(
else if (source.producer === 'faceDance') validateFaceDanceSource(ctx, source);
else if (source.producer === 'storm') validateStormSource(ctx, source);
else if (source.producer === 'specialRelocation') validateRelocationSource(ctx, source);
else if (source.producer === 'wormRide') validateWormRideSource(ctx, source);
else validateSpiritualArrivalSource(ctx, source);
const forces = physicalGroup(owner, source.territory);
requireFlip(record(source.forces) && Object.keys(forces).length > 0 &&
+62 -2
View File
@@ -112,7 +112,7 @@ import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, h
import { bribeReceiptTiming, bribeTimingBlock, bribesCloseBeforeNextMentat, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteAtreidesFullPlan } from './atreides-full-plan';
import { quoteTleilaxuSpecialKarama } from './tleilaxu-special-karama';
import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport, type AutomaticAdvisorFaceDance, type AutomaticAdvisorStorm, type AutomaticAdvisorRelocation } from './advisor-automatic-flip';
import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport, type AutomaticAdvisorFaceDance, type AutomaticAdvisorStorm, type AutomaticAdvisorRelocation, type AutomaticAdvisorWormRide } from './advisor-automatic-flip';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
@@ -1521,6 +1521,7 @@ export type Game = {
signature: string;
resume: { kind: 'ambassador'; event: string } | { kind: 'discovery'; child: DiscoveryEntryArrivalChild };
};
pendingAutomaticAdvisorWormRide?: { receipt: AutomaticAdvisorWormRide; signature: string };
pendingAdvisorMatching?: {
receipt: AdvisorMatchingSource;
signature: string;
@@ -28508,6 +28509,12 @@ function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
'The mandatory conversion has lost its original completed relocation.');
validateRelocationAdvisorParent(g, parent);
}
if (source.producer === 'wormRide') {
const parent = g.pendingAutomaticAdvisorWormRide;
requireRule(parent && JSON.stringify(source.wormRide) === JSON.stringify(parent.receipt),
'The mandatory conversion has lost its original completed worm ride.');
validateWormRideAdvisorParent(g, parent);
}
if(source.producer==='spiritualArrival'){
const frame=spiritualFrameBody(g);
requireRule(frame.stage==='arrival'&&JSON.stringify(frame.source)===JSON.stringify(source.spiritual),
@@ -28922,6 +28929,54 @@ function finishAutomaticAdvisorRelocation(g: Game) {
completeDiscoveryEntryArrival(g, child);
}
}
/** The ride and any destination collection have already happened. Bind the
* untouched worm cursor and the rider's physical pools until arrival resumes. */
function wormRideAdvisorSignature(g: Game, receipt: AutomaticAdvisorWormRide) {
const rider = getPlayer(g, receipt.player);
return JSON.stringify({ receipt, turn:g.turn, phase:g.phase, wormRides:g.wormRides,
spiceSequence:g.spiceSequence, spiceResolution:g.spiceResolution,
summonedNexusBeforeRides:g.summonedNexusBeforeRides, greatMaker:g.greatMaker,
forces:rider.forces, elites:rider.elites, spice:g.spice, riderSpice:rider.spice });
}
function validateWormRideAdvisorParent(g: Game, parent: NonNullable<Game['pendingAutomaticAdvisorWormRide']>) {
requireRule(g.status === 'playing' && g.advanced && g.phase === 1 &&
parent.signature === wormRideAdvisorSignature(g, parent.receipt),
'The mandatory conversion lost its committed worm ride or original continuation.');
}
function prepareWormRideAdvisorFlips(g: Game, ride: Omit<AutomaticAdvisorWormRide, 'event'>) {
const groups = automaticAdvisorRule(() => quoteAutomaticAdvisorFlips(g))
.filter(group => group.territory === ride.origin);
if (!groups.length) return false;
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorWormRide &&
!g.pendingAutomaticAdvisorRelocation && !g.pendingAutomaticAdvisorTransport &&
!g.pendingAutomaticAdvisorHomeworldTransport && !g.pendingAutomaticAdvisorGamont &&
!g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm,
'Finish the original advisor conversion before a worm ride.');
const receipt = { event:crypto.randomUUID(), ...ride };
g.pendingAutomaticAdvisorWormRide = { receipt, signature:wormRideAdvisorSignature(g,receipt) };
for (const group of groups) {
const source: AutomaticAdvisorFlipSource = {event:crypto.randomUUID(), owner:group.player,
territory:group.territory, turn:g.turn, phase:g.phase, forces:group.forces,
producer:'wormRide', wormRide:receipt};
getPlayer(g,group.player).advisors![group.territory].aloneFlipHold = source.event;
(g.pendingAutomaticAdvisorFlips ??= []).push(source);
}
openAutomaticAdvisorFlip(g);
return true;
}
function finishAutomaticAdvisorWormRide(g: Game) {
const parent = g.pendingAutomaticAdvisorWormRide;
if (!parent) return;
requireRule(!g.pendingAutomaticAdvisorFlips?.length,
'Finish the original worm-ride conversions before arrival.');
validateWormRideAdvisorParent(g,parent);
delete g.pendingAutomaticAdvisorWormRide;
const ride = parent.receipt, rider = getPlayer(g,ride.player);
const intruded = intrusion(g,rider,ride.destination,{wormRide:true});
if (openTerritoryEntry(g,rider,ride.destination,ride.sector,ride.amount,ride.elite,'wormRide','wormRide') || intruded)
return;
nextWormRide(g);
}
function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
for (const entry of released)
delete getPlayer(g, entry.player).advisors?.[entry.territory];
@@ -38560,7 +38615,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
}
if (automaticAdvisorFlip) {
if (canceled)
log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance replacement and reveal stay' : automaticAdvisorFlip.producer === 'storm' ? 'current-sector storm casualties stay' : automaticAdvisorFlip.producer === 'specialRelocation' ? 'relocation and any collection stay' : automaticAdvisorFlip.producer === 'spiritualArrival' ? 'free Spiritual force placement and any income stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`,
log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance replacement and reveal stay' : automaticAdvisorFlip.producer === 'storm' ? 'current-sector storm casualties stay' : automaticAdvisorFlip.producer === 'specialRelocation' ? 'relocation and any collection stay' : automaticAdvisorFlip.producer === 'wormRide' ? 'worm ride and any collection stay' : automaticAdvisorFlip.producer === 'spiritualArrival' ? 'free Spiritual force placement and any income stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`,
{ faction: bg.faction, name: 'Mandatory advisor conversion prevented' });
g.pendingAutomaticAdvisorFlips!.shift();
if (!g.pendingAutomaticAdvisorFlips!.length) delete g.pendingAutomaticAdvisorFlips;
@@ -38572,6 +38627,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
finishAutomaticAdvisorFaceDance(g);
finishAutomaticAdvisorStorm(g);
finishAutomaticAdvisorRelocation(g);
finishAutomaticAdvisorWormRide(g);
if(g.pendingSpiritualAdvisor?.stage==='arrival')completeSpiritualAdvisorArrival(g);
} else openAutomaticAdvisorFlip(g);
} else finishAdvisorReaction(g, response);
@@ -41877,6 +41933,8 @@ export function prepareSpecialKaramaIntent(
g.phase === 1 && !g.summonedWorm,
'Summon Shai-Hulud during Spice Blow and Nexus.',
);
requireRule(!g.pendingAutomaticAdvisorWormRide,
'Resolve the completed worm ride’s advisor conversion before summoning another worm.');
const t = stringField(action.territory);
requireRule(
TERRITORIES.some((x) => x.id === t && x.type === 'sand'),
@@ -45700,6 +45758,8 @@ function applyActionInner(
log(g, `${p.name}'s Sandmaster collected 1 spice on arriving in ${territory(to).name} by worm. ${sandmaster.before - 1} spice remains in that pile. This ride does not consume normal movement.`,
{ faction: p.faction, name: 'Sandmaster collection' });
}
if (prepareWormRideAdvisorFlips(g,{player:p.id,origin:decision.territory,
destination:to,sector,amount:total,elite:totalElite})) return g;
const intruded = intrusion(g, p, to, { wormRide: true });
if (
openTerritoryEntry(
+2
View File
@@ -191,6 +191,8 @@ const cancellationSources = {
storm: response.automaticAdvisorFlip.producer === 'storm' ? g.pendingAutomaticAdvisorStorm?.receipt ?? null : null,
...(response.automaticAdvisorFlip.producer === 'specialRelocation'
? { relocation: g.pendingAutomaticAdvisorRelocation?.receipt ?? null } : {}),
...(response.automaticAdvisorFlip.producer === 'wormRide'
? { wormRide: g.pendingAutomaticAdvisorWormRide?.receipt ?? null } : {}),
...(response.automaticAdvisorFlip.producer === 'spiritualArrival'
? { spiritual:g.pendingSpiritualAdvisor?.source??null } : {}),
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
+4
View File
@@ -5294,6 +5294,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Advisors do not fight, contest stronghold victory, block another faction’s entry, collect spice, earn stronghold income, grant ornithopters or enable Family Atomics. Storms, worms, explosions and Atomics still destroy them.',
'In Advanced, advisors may coexist with their ally without triggering the end-turn alliance constraint in either direction. Neither the advisors nor the allied fighters are lost for that coexistence. Bene Gesserit fighters remain subject to ordinary separation; Basic receives no new advisor exception.',
'After Fremen setup, choose the starting advisor’s territory and sector. Setup conversions remain automatic. During play, advisors alone must become fighters. A ground move into an empty territory or the last outsider’s ground departure now offers Karama prevention after the physical move, before fighter benefits. Allow to convert once; cancellation leaves the completed movement intact.',
'If a native Fremen worm ride leaves advisors alone, the same conversion response follows the completed ride and any Sandmaster collection, then the original arrival and remaining worm queue continue. Prevention changes only the advisor conversion. Fremen’s special Karama to summon another worm waits until this conversion finishes; its nested outcome remains unfinished.',
'A normal shipment enters as fighters unless joining existing advisors. Opposite-type normal ground arrivals and normal reserves joining advisors now offer Karama matching prevention before movement or payment. Residents keep their type. Provisionally stop a prevented packet into this territory for that action opportunity, leaving a legal replacement and the unused action available. Same-type moves need no flip. Advisors entering another occupied territory may keep their existing optional fighter choice.',
'An allied Fremen Ambassador relocation also offers incoming matching before any selected force moves or collects spice. Residents keep their type. Provisionally, prevention restores the same relocation chooser while denying that same packet here for this Ambassador event. The already-triggered token and ordinary movement/shipment remain unchanged; another legal packet or destination stays available.',
'Fresh Guild cross-transport and Junction board arrivals also offer incoming matching before their original permission, price, payment or transfer. Junction Homeworld groups provisionally enter fighter-side. Prevention leaves the original shipment and grant unused while that same packet here stays unavailable for this producer opportunity; choose another legal packet or destination. Junction permission and tariff remain immune. Secret Ally keeps its actual Nexus until the shipment commits.',
@@ -5421,6 +5422,8 @@ export const RULE_TOPICS: RuleTopic[] = [
"tests/advisor-gamont-flip-engine.test.ts",
"tests/advisor-transport-flip-engine.test.ts",
"tests/advisor-automatic-flip-engine.test.ts",
"tests/advisor-worm-ride-flip.test.ts",
"tests/advisor-worm-ride-controls.test.tsx",
"game/bots.ts",
"docs/ADVISOR_PREPARATION.md"
]
@@ -5486,6 +5489,7 @@ export const RULE_TOPICS: RuleTopic[] = [
"tests/fixture-advisor-transport-flip.ts",
"tests/advisor-automatic-flip-engine.test.ts",
"tests/advisor-automatic-flip-quote.test.ts",
"tests/advisor-worm-ride-quote.test.ts",
"tests/fixture-advisor-automatic-flip.ts",
"tests/fixture-advisor-preparation.ts",
"tests/advisor-preparation-engine.test.ts",
+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',