Resolve advisor conversion after Ambassador and Discovery transfers
Preserve original transfer and arrival cursors, admit no new Discovery advisor sources, and offer one mandatory counter for empty Ambassador advisor arrivals.
This commit is contained in:
1 parent
d66b594770
commit
abf0305ddf
12 files changed
+996
-18
No files matched your search
@@ -68,6 +68,15 @@ export type AutomaticAdvisorStorm = {
|
||||
sector: number;
|
||||
};
|
||||
|
||||
export type AutomaticAdvisorRelocation = {
|
||||
event: string;
|
||||
kind: 'ambassador' | 'discovery';
|
||||
player: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
sector: number;
|
||||
};
|
||||
|
||||
export type AutomaticAdvisorFlipSource = {
|
||||
event: string;
|
||||
owner: string;
|
||||
@@ -83,6 +92,7 @@ export type AutomaticAdvisorFlipSource = {
|
||||
| { producer: 'battleLosses'; battle: AutomaticAdvisorBattle }
|
||||
| { producer: 'faceDance'; faceDance: AutomaticAdvisorFaceDance }
|
||||
| { producer: 'storm'; storm: AutomaticAdvisorStorm }
|
||||
| { producer: 'specialRelocation'; relocation: AutomaticAdvisorRelocation }
|
||||
);
|
||||
|
||||
export type AutomaticAdvisorFlipPlayer = ForcePresence & {
|
||||
@@ -155,7 +165,7 @@ function otherPresence(ctx: AdvisorSettlementContext, player: AutomaticAdvisorFl
|
||||
|
||||
function currentWindow(ctx: AutomaticAdvisorFlipContext) {
|
||||
return ctx.advanced === true && ctx.status === 'playing' &&
|
||||
(ctx.phase === 0 || ctx.phase === 5 || ctx.phase === 6 || ctx.phase === 8) && positive(ctx.turn);
|
||||
[0, 1, 3, 5, 6, 8].includes(ctx.phase) && positive(ctx.turn);
|
||||
}
|
||||
|
||||
/** A supported committed producer opens a response for these current groups.
|
||||
@@ -408,6 +418,27 @@ function validateStormSource(
|
||||
'Automatic advisor conversion needs its current exposed storm territory.');
|
||||
}
|
||||
|
||||
function validateRelocationSource(
|
||||
ctx: AutomaticAdvisorFlipContext,
|
||||
source: Extract<AutomaticAdvisorFlipSource, { producer: 'specialRelocation' }>,
|
||||
) {
|
||||
const receipt = source.relocation;
|
||||
requireFlip(record(receipt) &&
|
||||
Object.keys(receipt).sort().join(',') === 'destination,event,kind,origin,player,sector' &&
|
||||
identifier(receipt.event) && (receipt.kind === 'ambassador' || receipt.kind === 'discovery') &&
|
||||
identifier(receipt.player) && identifier(receipt.origin) && identifier(receipt.destination) &&
|
||||
nonnegative(receipt.sector),
|
||||
'Automatic advisor conversion lost its exact public special-relocation receipt.');
|
||||
const places = gameTerritories(ctx);
|
||||
requireFlip(places.some((place) => place.id === receipt.origin) &&
|
||||
places.some((place) => place.id === receipt.destination && place.sectors.includes(receipt.sector)) &&
|
||||
(source.territory === receipt.origin || source.territory === receipt.destination),
|
||||
'Automatic advisor conversion needs the current special-relocation origin or destination.');
|
||||
const actors = ctx.players.filter((player) => player.id === receipt.player);
|
||||
requireFlip(actors.length === 1 && presenceAt(actors[0], receipt.destination) > 0,
|
||||
'Automatic advisor conversion lost the original relocated actor at its destination.');
|
||||
}
|
||||
|
||||
/** Validate the same committed producer and exact held group before either response outcome. */
|
||||
export function validateAutomaticAdvisorFlip(
|
||||
ctx: AutomaticAdvisorFlipContext,
|
||||
@@ -427,11 +458,16 @@ export function validateAutomaticAdvisorFlip(
|
||||
(source.producer === 'faceDance' &&
|
||||
Object.keys(source).sort().join(',') === 'event,faceDance,forces,owner,phase,producer,territory,turn') ||
|
||||
(source.producer === 'storm' &&
|
||||
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,storm,territory,turn')) &&
|
||||
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')) &&
|
||||
identifier(source.event) && identifier(source.owner) && identifier(source.territory) &&
|
||||
positive(source.turn) && source.turn === ctx.turn &&
|
||||
source.phase === (source.producer === 'storm' ? 0 : source.producer === 'gamont' ? 8 :
|
||||
source.producer === 'battleLosses' || source.producer === 'faceDance' ? 6 : 5) &&
|
||||
(source.producer === 'specialRelocation'
|
||||
? record(source.relocation) && (source.relocation.kind === 'discovery'
|
||||
? source.phase === 0 : source.relocation.kind === 'ambassador' && [1, 3, 5, 8].includes(source.phase))
|
||||
: source.phase === (source.producer === 'storm' ? 0 : 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.');
|
||||
const owners = ctx.players.filter((player) => player.id === source.owner);
|
||||
@@ -450,7 +486,8 @@ export function validateAutomaticAdvisorFlip(
|
||||
else if (source.producer === 'gamont') validateGamontSource(ctx, source);
|
||||
else if (source.producer === 'battleLosses') validateBattleSource(ctx, source);
|
||||
else if (source.producer === 'faceDance') validateFaceDanceSource(ctx, source);
|
||||
else validateStormSource(ctx, source);
|
||||
else if (source.producer === 'storm') validateStormSource(ctx, source);
|
||||
else validateRelocationSource(ctx, source);
|
||||
const forces = physicalGroup(owner, source.territory);
|
||||
requireFlip(record(source.forces) && Object.keys(forces).length > 0 &&
|
||||
Object.values(forces).every(positive) && Object.values(source.forces).every(positive) &&
|
||||
|
||||
+126
-4
@@ -12,7 +12,7 @@ import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, h
|
||||
import { bribeTimingBlock, 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 } 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 } 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';
|
||||
@@ -1278,6 +1278,11 @@ export type Game = {
|
||||
};
|
||||
pendingAutomaticAdvisorFaceDance?: AutomaticAdvisorFaceDance;
|
||||
pendingAutomaticAdvisorStorm?: { receipt: AutomaticAdvisorStorm; ready: boolean; cursor?: string };
|
||||
pendingAutomaticAdvisorRelocation?: {
|
||||
receipt: AutomaticAdvisorRelocation;
|
||||
signature: string;
|
||||
resume: { kind: 'ambassador'; event: string } | { kind: 'discovery'; child: DiscoveryEntryArrivalChild };
|
||||
};
|
||||
advisorPreparation?: AdvisorPreparationWindow;
|
||||
ecazOccupyPreview?: boolean;
|
||||
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
|
||||
@@ -12005,6 +12010,20 @@ function discoveryEntryIntegrity(g: Game) {
|
||||
requireRule(g.discoveryEnabled && g.discoveries && g.status === 'playing' && g.phase === 0 &&
|
||||
frame.stage !== 'complete', 'Discovery entry must finish before the Storm Phase begins.');
|
||||
nexusRule(() => validateDiscoveryEntryRound({...g, discoveries:g.discoveries!},frame));
|
||||
const automatic = g.pendingAutomaticAdvisorRelocation;
|
||||
if (automatic?.receipt.kind === 'discovery') {
|
||||
requireRule(frame.stage === 'arrival' && !choices.length && !children.length,
|
||||
'The Discovery conversion must precede its original arrival interaction.');
|
||||
validateRelocationAdvisorParent(g, automatic);
|
||||
const responses = controls.flatMap(control => {
|
||||
const pending = control && 'pendingKarama' in control ? control.pendingKarama as Game['pendingKarama'] : null;
|
||||
return [control?.response, pending?.use.kind === 'cancel' ? pending.use.response : null];
|
||||
});
|
||||
for (const {pending} of savedKaramaContexts(g))
|
||||
if (pending.use.kind === 'cancel') responses.push(pending.use.response);
|
||||
validateRelocationAdvisorResponse(g, automatic, responses);
|
||||
return;
|
||||
}
|
||||
if (frame.stage === 'choose') {
|
||||
const owner = frame.order[frame.cursor % frame.order.length];
|
||||
requireRule(!children.length && choices.length === 1 && choices[0].player === owner &&
|
||||
@@ -12070,6 +12089,8 @@ function decideDiscoveryEntry(g: Game, p: Player, action: Action) {
|
||||
log(g, `${p.name} moved ${arrival.amount} ${arrival.amount === 1 ? 'force' : 'forces'}${arrival.elite ? `, including ${arrival.elite} elite ${arrival.elite === 1 ? 'force' : 'forces'},` : ''} from ${territory(arrival.parent).name} into ${territory(arrival.destination).name} before the storm. This one-time Discovery entry spends no spice or normal movement.`);
|
||||
const child: DiscoveryEntryArrivalChild = {owner:p.id,territory:arrival.destination,sector:0,
|
||||
amount:arrival.amount,elite:arrival.elite,discoveryEntry:arrival.signature};
|
||||
if (prepareRelocationAdvisorFlips(g, {kind:'discovery',player:p.id,origin:arrival.parent,
|
||||
destination:arrival.destination,sector:0}, {kind:'discovery',child})) return;
|
||||
if (!intrusion(g,p,arrival.destination,{discoveryEntry:child})) completeDiscoveryEntryArrival(g,child);
|
||||
}
|
||||
function resolveDiscoveryBlow(g: Game, card: Extract<SpiceCard, {territory:string}>) {
|
||||
@@ -17755,6 +17776,13 @@ function ambassadorRelocationIntegrity(g: Game) {
|
||||
'This Ambassador relocation cannot own an accompaniment decision.',
|
||||
);
|
||||
const next = entry.relocation?.next;
|
||||
if (next === 'flip' && g.pendingAutomaticAdvisorRelocation?.receipt.kind === 'ambassador') {
|
||||
requireRule(!relatedResponses.length && !relatedDecisions.length && !relatedTerror,
|
||||
'Complete mandatory advisor conversion before original Ambassador arrival reactions.');
|
||||
validateRelocationAdvisorParent(g, g.pendingAutomaticAdvisorRelocation);
|
||||
validateRelocationAdvisorResponse(g, g.pendingAutomaticAdvisorRelocation, responses);
|
||||
return;
|
||||
}
|
||||
requireRule(
|
||||
next === 'intrusion' || next === 'terror' || next === 'finish',
|
||||
'The saved Ambassador arrival has an invalid continuation stage.',
|
||||
@@ -18571,6 +18599,7 @@ function ambassadorRelocationMovement(g: Game, player: string) {
|
||||
function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
|
||||
const { entry, beneficiary: p } = currentFremenAmbassador(g);
|
||||
validateAmbassadorRelocationArrival(g, move);
|
||||
const emptyAdvisorArrival = emptyRelocatedAdvisorArrival(g, p, move.origin, move.to);
|
||||
if (move.ambassadorSandmaster) {
|
||||
const { key } = move.ambassadorSandmaster;
|
||||
g.spice[key]--;
|
||||
@@ -18595,7 +18624,7 @@ function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
|
||||
removeGroup(p, move.group, move.eliteGroup);
|
||||
const physical = move.total - (move.noField ? 1 : 0);
|
||||
if (physical) place(p, move.to, move.sector, physical, move.elite);
|
||||
if (move.advisors) {
|
||||
if (move.advisors || emptyAdvisorArrival) {
|
||||
const lockedTurn = Math.max(move.lockedTurn ?? 0, p.advisors?.[move.to]?.lockedTurn ?? 0);
|
||||
(p.advisors ??= {})[move.to] = { ...p.advisors?.[move.to], ...(lockedTurn ? { lockedTurn } : {}) };
|
||||
} else if (p.advisors) delete p.advisors[move.to];
|
||||
@@ -18606,6 +18635,8 @@ function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
|
||||
`${p.name} relocated ${physical} physical forces${move.noField ? ' and a concealed No-Field' : ''} from ${territory(move.origin).name} to ${territory(move.to).name}, sector ${move.sector}, through the Fremen Ambassador. Their ordinary shipment and movement allowances are unchanged.`,
|
||||
{ faction: p.faction, name: 'Fremen Ambassador relocation' },
|
||||
);
|
||||
if (prepareRelocationAdvisorFlips(g, {kind:'ambassador',player:p.id,origin:move.origin,
|
||||
destination:move.to,sector:move.sector}, {kind:'ambassador',event:entry.event})) return;
|
||||
continueAmbassadorRelocation(g, entry.event);
|
||||
}
|
||||
function continueAmbassadorRelocation(g: Game, event: string) {
|
||||
@@ -20340,6 +20371,12 @@ function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
|
||||
'The mandatory conversion has lost its original storm sector.');
|
||||
validateStormAdvisorCursor(g, parent);
|
||||
}
|
||||
if (source.producer === 'specialRelocation') {
|
||||
const parent = g.pendingAutomaticAdvisorRelocation;
|
||||
requireRule(parent && JSON.stringify(source.relocation) === JSON.stringify(parent.receipt),
|
||||
'The mandatory conversion has lost its original completed relocation.');
|
||||
validateRelocationAdvisorParent(g, parent);
|
||||
}
|
||||
automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source));
|
||||
return source;
|
||||
}
|
||||
@@ -20665,6 +20702,90 @@ function finishAutomaticAdvisorStorm(g: Game) {
|
||||
delete g.pendingAutomaticAdvisorStorm;
|
||||
continueStorm(g);
|
||||
}
|
||||
function emptyRelocatedAdvisorArrival(g: Game, p: Player, origin: string, destination: string) {
|
||||
return g.advanced && isAdvisor(p, origin) &&
|
||||
!g.players.some(other => presenceAt(other, destination) > 0);
|
||||
}
|
||||
function relocationAdvisorSignature(g: Game, kind: AutomaticAdvisorRelocation['kind']) {
|
||||
return JSON.stringify(kind === 'ambassador' ? g.pendingAmbassador : g.discoveryEntry);
|
||||
}
|
||||
function prepareRelocationAdvisorFlips(g: Game, relocation: Omit<AutomaticAdvisorRelocation, 'event'>,
|
||||
resume: NonNullable<Game['pendingAutomaticAdvisorRelocation']>['resume']) {
|
||||
const groups = automaticAdvisorRule(() => quoteAutomaticAdvisorFlips(g)).filter(group =>
|
||||
group.territory === relocation.origin || group.territory === relocation.destination);
|
||||
if (!groups.length) return false;
|
||||
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorRelocation &&
|
||||
!g.pendingAutomaticAdvisorTransport && !g.pendingAutomaticAdvisorHomeworldTransport &&
|
||||
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle &&
|
||||
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm,
|
||||
'Finish the original advisor conversion before a special relocation.');
|
||||
const receipt = { event: crypto.randomUUID(), ...relocation };
|
||||
const parent = { receipt, resume, signature: relocationAdvisorSignature(g, receipt.kind) };
|
||||
g.pendingAutomaticAdvisorRelocation = parent;
|
||||
validateRelocationAdvisorParent(g, parent);
|
||||
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: 'specialRelocation', relocation: receipt };
|
||||
getPlayer(g, group.player).advisors![group.territory].aloneFlipHold = source.event;
|
||||
(g.pendingAutomaticAdvisorFlips ??= []).push(source);
|
||||
}
|
||||
openAutomaticAdvisorFlip(g);
|
||||
return true;
|
||||
}
|
||||
function validateRelocationAdvisorParent(g: Game, parent: NonNullable<Game['pendingAutomaticAdvisorRelocation']>) {
|
||||
const receipt = parent.receipt;
|
||||
requireRule(receipt.kind === parent.resume.kind &&
|
||||
parent.signature === relocationAdvisorSignature(g, receipt.kind),
|
||||
'The mandatory conversion lost its exact original relocation continuation.');
|
||||
if (parent.resume.kind === 'ambassador') {
|
||||
const {entry,beneficiary} = currentFremenAmbassador(g, parent.resume.event, true);
|
||||
const move = entry.relocation!.order;
|
||||
requireRule(entry.relocation!.next === 'flip' && receipt.player === beneficiary.id &&
|
||||
receipt.origin === move.origin && receipt.destination === move.to && receipt.sector === move.sector,
|
||||
'Finish the pending conversion before the original Ambassador arrival.');
|
||||
// Validate completed physical provenance without consuming the first flip stage.
|
||||
const context = { ...g, pendingAmbassador: { ...entry,
|
||||
relocation: { ...entry.relocation!, next: 'intrusion' as const } } };
|
||||
try { validateAmbassadorRelocationContext(context, entry.event, 'intrusion'); }
|
||||
catch (error) { if (error instanceof MovementCancellationError) throw new RuleError(error.message); throw error; }
|
||||
} else {
|
||||
const frame = g.discoveryEntry, arrival = frame?.arrival;
|
||||
requireRule(g.phase === 0 && frame?.stage === 'arrival' && arrival &&
|
||||
receipt.player === arrival.owner && receipt.origin === arrival.parent &&
|
||||
receipt.destination === arrival.destination && receipt.sector === 0,
|
||||
'Finish the pending conversion before the original Discovery arrival.');
|
||||
const child = parent.resume.child;
|
||||
nexusRule(() => validateDiscoveryEntryArrivalChild(frame, child));
|
||||
const owner = getPlayer(g, arrival.owner), key = `${arrival.destination}:0`;
|
||||
requireRule(owner.forces[key] === arrival.destinationBefore + arrival.amount &&
|
||||
(owner.elites?.forces[key] ?? 0) === arrival.destinationEliteBefore + arrival.elite,
|
||||
'The completed Discovery transfer lost its original ordinary or elite counters.');
|
||||
}
|
||||
}
|
||||
function validateRelocationAdvisorResponse(g: Game, parent: NonNullable<Game['pendingAutomaticAdvisorRelocation']>,
|
||||
responses: readonly (ResponseWindow | null | undefined)[]) {
|
||||
const source = g.pendingAutomaticAdvisorFlips?.[0];
|
||||
requireRule(source?.producer === 'specialRelocation' &&
|
||||
JSON.stringify(source.relocation) === JSON.stringify(parent.receipt) &&
|
||||
responses.some(response => response?.kind === 'advisorFlip' &&
|
||||
JSON.stringify(response.automaticAdvisorFlip) === JSON.stringify(source)),
|
||||
'The original relocation has lost its owned mandatory advisor response.');
|
||||
}
|
||||
function finishAutomaticAdvisorRelocation(g: Game) {
|
||||
const parent = g.pendingAutomaticAdvisorRelocation;
|
||||
if (!parent) return;
|
||||
requireRule(!g.pendingAutomaticAdvisorFlips?.length,
|
||||
'Finish all original relocation conversions before arrival.');
|
||||
validateRelocationAdvisorParent(g, parent);
|
||||
delete g.pendingAutomaticAdvisorRelocation;
|
||||
if (parent.resume.kind === 'ambassador') continueAmbassadorRelocation(g, parent.resume.event);
|
||||
else {
|
||||
const p = getPlayer(g, parent.receipt.player), child = parent.resume.child;
|
||||
if (!intrusion(g, p, parent.receipt.destination, {discoveryEntry:child}))
|
||||
completeDiscoveryEntryArrival(g, child);
|
||||
}
|
||||
}
|
||||
function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
|
||||
for (const entry of released)
|
||||
delete getPlayer(g, entry.player).advisors?.[entry.territory];
|
||||
@@ -22685,7 +22806,7 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
|
||||
function victory(g: Game, quote?: VictoryQuote) {
|
||||
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport &&
|
||||
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport &&
|
||||
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm,
|
||||
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation,
|
||||
'Resolve mandatory advisor conversions before checking victory.');
|
||||
quote ??= currentVictoryQuote(g);
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
@@ -28507,7 +28628,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' : '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' : '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;
|
||||
@@ -28518,6 +28639,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
finishAutomaticAdvisorHomeworldTransport(g);
|
||||
finishAutomaticAdvisorFaceDance(g);
|
||||
finishAutomaticAdvisorStorm(g);
|
||||
finishAutomaticAdvisorRelocation(g);
|
||||
} else openAutomaticAdvisorFlip(g);
|
||||
} else finishAdvisorReaction(g, response);
|
||||
}
|
||||
|
||||
@@ -187,6 +187,8 @@ const cancellationSources = {
|
||||
homeworld: response.automaticAdvisorFlip.producer === 'homeworldTransport' ? g.pendingAutomaticAdvisorHomeworldTransport?.receipt ?? null : null,
|
||||
faceDance: response.automaticAdvisorFlip.producer === 'faceDance' ? g.pendingAutomaticAdvisorFaceDance ?? null : null,
|
||||
storm: response.automaticAdvisorFlip.producer === 'storm' ? g.pendingAutomaticAdvisorStorm?.receipt ?? null : null,
|
||||
...(response.automaticAdvisorFlip.producer === 'specialRelocation'
|
||||
? { relocation: g.pendingAutomaticAdvisorRelocation?.receipt ?? null } : {}),
|
||||
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
|
||||
: response.advisorPreparationSource
|
||||
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
|
||||
|
||||
+11
-6
@@ -4321,7 +4321,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Native Guild board-to-Homeworld and Junction board transfers can leave advisors alone after payment and physical transfer. Karama prevents only the resulting BG conversion; Junction permission and tariff stay immune. Original income resolves first, then any Junction board arrival resumes once. Original declaration snapshots stay private.',
|
||||
'A completed Face Dance can leave advisors alone at remote board sources or, with zero replacement, at the battle target. Original force returns, replacement, leader death, revelation and any card cycle finish first. Karama can then prevent only the BG conversion; native Face Dance remains immune. Afterward the original aftermath resumes once.',
|
||||
'After a storm sector removes the last outsider, surviving advisors elsewhere in that territory must convert. Provisionally finish every current-sector casualty choice first, then offer Karama prevention before the remaining traversal. Completed losses stay committed. Later sectors can still destroy advisors or fighters; advisors destroyed in the same sector create no conversion.',
|
||||
'Provisional cancellation duration: a prevented advisor group stays advisors while continuously alone, including across phase and turn changes. Another faction returning or the group becoming empty ends that held occurrence. Later arrivals joining that same group match its stance. Special Ambassador/Discovery relocation and matching-group flip counters remain unfinished.',
|
||||
'A completed Fremen Ambassador relocation or next-turn Discovery force transfer can leave advisors alone at its source. Ambassador movement can also bring advisors to an empty destination. Provisionally resolve their mandatory conversion after movement and any original collection, before the original arrival reactions continue. Karama keeps the transfer committed. Discovery free entry still admits non-advisor forces only.',
|
||||
'Provisional cancellation duration: a prevented advisor group stays advisors while continuously alone, including across phase and turn changes. Another faction returning or the group becoming empty ends that held occurrence. Later arrivals joining that same group match its stance. Matching-group flip counters and wider combinations remain unfinished.',
|
||||
'When another faction ships, moves or worm rides into your fighters, immediately choose whether to become advisors. The choice opens a Karama response. Repeated entry can offer a new choice.',
|
||||
'After another faction’s off-planet shipment, choose a free force in the Polar Sink or an accompanying force at the shipment destination. An accompanying advisor group cannot flip to fighters in that turn while other factions remain.',
|
||||
'After Spice Blow and Nexus, use Prepare advisors for battle during Charity, Bidding, Revival or before the first actual shipment. Quiet boundaries retain current mandatory priority. Each whole-territory flip has its original Karama response; its direct continuation preserves the current phase, auction, readiness and active movement. Declining the initial phase-five chooser leaves this window open. Ordinary allied presence, storm and fresh-advisor locks still restrict eligibility; the recorded supported Ecaz co-side composition remains separate.',
|
||||
@@ -4332,7 +4333,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Implementation",
|
||||
"status": "Partial",
|
||||
"detail": "Storm traversal now captures surviving alone advisors after current-sector losses and retains them through original Fremen casualty choices. A public sector receipt binds a private ready/cursor parent; consume it before continuing once. Existing ground, transport, Gamont, battle and Face Dance contracts remain. Special relocation and matching-group causes remain unfinished.",
|
||||
"detail": "Fremen Ambassador and Discovery free entry now capture resulting alone advisors before original arrivals. Preserve an empty Ambassador advisor arrival through one mandatory counter, without changing Discovery non-advisor admission. Private parent cursors retain original flip/entry stages; public six-field location evidence omits orders, No-Fields and skills. Matching-group and wider combinations remain unfinished.",
|
||||
"evidence": [
|
||||
"game/advisor-automatic-flip.ts",
|
||||
"game/advisors.ts",
|
||||
@@ -4347,7 +4348,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Player controls",
|
||||
"status": "Partial",
|
||||
"detail": "Standard Karama/Allow controls identify completed current-sector casualties and the remaining traversal. Prevention keeps actual losses; later sectors may still destroy the group. Existing producer and preparation guidance remains, without a new confirmation or timer.",
|
||||
"detail": "Standard Karama/Allow controls distinguish completed special force transfer from pending conversion and original arrival. Existing producer and preparation guidance remains, without another confirmation or timer.",
|
||||
"evidence": [
|
||||
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
|
||||
"components/advisor-preparation.tsx",
|
||||
@@ -4358,8 +4359,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "AI",
|
||||
"status": "Partial",
|
||||
"detail": "Eight existing-profile native ending/continuing Storm conversion paths resolve legally through the original next phase. Completed current-sector losses remain; a later sector can still destroy BG. Earlier producer paths retain separate evidence. No strategy refinement or difficulty calibration.",
|
||||
"detail": "Eight existing-profile native Ambassador/Discovery conversion paths resolve legally through original arrival completion. Transfers, allowances and original parent flow remain. Earlier producers retain separate evidence; no strategy refinement or difficulty calibration.",
|
||||
"evidence": [
|
||||
"tests/fixture-advisor-relocation-flip.ts",
|
||||
"tests/advisor-relocation-flip-engine.test.ts",
|
||||
"tests/storm-worm-cancellation.test.ts",
|
||||
"tests/fixture-advisor-storm-flip.ts",
|
||||
"tests/advisor-storm-flip-engine.test.ts",
|
||||
@@ -4379,7 +4382,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Documentation",
|
||||
"status": "Partial",
|
||||
"detail": "Physical17/28 E composes the mandatory counter with current-sector losses. The selected priority after all current-sector casualties and before remaining traversal is explicitly provisional, as is continuously-alone cancellation duration. Public sector facts stay separate from private forecasts/protection/continuation.",
|
||||
"detail": "Physical17/28 E composes conversion with original independent relocation. Priority after physical transfer/collection and before original arrivals is explicitly provisional. Original Ambassador phase permission and Discovery non-advisor-only admission remain; cancellation duration and wider combinations stay explicit.",
|
||||
"evidence": [
|
||||
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
|
||||
"docs/ADVISOR_PREPARATION.md",
|
||||
@@ -4390,8 +4393,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Verification",
|
||||
"status": "Partial",
|
||||
"detail": "Storm conversion passes frozen17-file220/220, types/lint/build, five native Storm/response/GameTable SSR journeys and eight profile paths. Genuine48-action sources earn base33, first dials, paid shipment/Intrusion and native turn-two Cards3/4. Ending9 preserves BG10; continuing10 destroys it after either response. Same-sector destruction has no conversion; controlled mixed-Fremen choices retain priority. Type feedback repairs only an empty-array fixture assertion. Wider combinations, special relocation/matching-group interpretation, complete modes and callback/browser/mobile/deployed/comprehensive acceptance remain open.",
|
||||
"detail": "Special relocation passes frozen19-file270/270, types/lint/build, five native transfer/response/GameTable SSR journeys, two labelled controlled empty-arrival cases and eight profile paths. Genuine44/49-action base33 sources earn native packet/placement/trigger or actual Cistern placement, Intrusion, Collection reveal and next-turn entry. Both outcomes retain transfer; original decline preserves its group. Controlled alliances are not native-alliance evidence. Focused compatibility regression retains older serialized counters. Wider No-Field/skills/Homeworld/Nexus combinations, native allied BG arrival, matching-group interpretation, full modes and final assurance remain open.",
|
||||
"evidence": [
|
||||
"tests/fixture-advisor-relocation-flip.ts",
|
||||
"tests/advisor-relocation-flip-engine.test.ts",
|
||||
"tests/storm-worm-cancellation.test.ts",
|
||||
"tests/fixture-advisor-storm-flip.ts",
|
||||
"tests/advisor-storm-flip-engine.test.ts",
|
||||
|
||||
Reference in new issue
Block a user