Handle advisor conversion after worm rides
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
ad0abfef98
commit
c608d6fea6
15 files changed
+507
-14
No files matched your search
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user