Files
Dune/game/advisor-automatic-flip.ts
T
Quintine abf0305ddf 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.
2026-10-24 21:32:12 +11:00

498 lines
24 KiB
TypeScript

import type { FactionId } from './catalog';
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import { presenceAt, type ForcePresence } from './force-presence';
import { stormExposesTerritory, stormSectorAfter } from './disaster-rules';
export type AdvisorStance = {
lockedTurn?: number;
aloneFlipHold?: string;
};
export type AutomaticAdvisorTransport = {
event: string;
player: string;
origin: string;
to: string;
sector: number;
amount: number;
elite: number;
group: Record<string, number>;
};
export type AutomaticAdvisorHomeworldTransport = {
event: string;
kind: 'native' | 'junction';
player: string;
origin: string;
destination: string;
amount: number;
elite: number;
group: Record<string, number>;
};
export type AutomaticAdvisorGamont = {
event: string;
player: string;
target: string;
location: string;
elite: number;
returned: boolean;
mentat: boolean;
};
export type AutomaticAdvisorBattle = {
event: string;
turn: number;
territory: string;
combatants: string[];
winner: string | null;
result: 'normal' | 'traitor' | 'mutualTraitors' | 'explosion';
};
export type AutomaticAdvisorFaceDance = {
event: string;
battle: AutomaticAdvisorBattle;
player: string;
territory: string;
sector: number;
sources: Record<string, number>;
amount: number;
returned: Record<string, number>;
};
export type AutomaticAdvisorStorm = {
event: string;
from: number;
distance: number;
traversed: number;
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;
territory: string;
turn: number;
phase: number;
forces: Record<string, number>;
} & (
| { producer: 'groundMovement'; mover: string; move: number }
| { producer: 'guildTransport'; transport: AutomaticAdvisorTransport }
| { producer: 'homeworldTransport'; transport: AutomaticAdvisorHomeworldTransport }
| { producer: 'gamont'; gamont: AutomaticAdvisorGamont }
| { producer: 'battleLosses'; battle: AutomaticAdvisorBattle }
| { producer: 'faceDance'; faceDance: AutomaticAdvisorFaceDance }
| { producer: 'storm'; storm: AutomaticAdvisorStorm }
| { producer: 'specialRelocation'; relocation: AutomaticAdvisorRelocation }
);
export type AutomaticAdvisorFlipPlayer = ForcePresence & {
id: string;
faction: FactionId;
advisors?: Readonly<Record<string, AdvisorStance>>;
moved?: number;
shipped?: boolean;
reserves?: number;
};
export type AdvisorSettlementContext = {
advanced: boolean;
players: readonly AutomaticAdvisorFlipPlayer[];
};
export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & MobileBoard & {
status: string;
turn: number;
phase: number;
choamMentatPending?: boolean;
lastBattleContext?: {
event: string;
turn: number;
territory: string;
combatants: readonly string[];
winner: string | null;
result: string;
} | null;
battle?: unknown;
homeworlds?: unknown;
storm?: number;
shieldWallDestroyed?: boolean;
stormResolution?: {
from: number;
distance: number;
traversed: number;
pending: readonly string[];
} | null;
};
export class AutomaticAdvisorFlipError extends Error {
constructor(message: string) {
super(message);
this.name = 'AutomaticAdvisorFlipError';
}
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const positive = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) > 0;
const nonnegative = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireFlip(value: unknown, message: string): asserts value {
if (!value) throw new AutomaticAdvisorFlipError(message);
}
function physicalGroup(player: AutomaticAdvisorFlipPlayer, territory: string): Record<string, number> {
return Object.fromEntries(Object.entries(player.forces).filter(
([key, count]) => splitLocation(key).territory === territory && count > 0,
));
}
function otherPresence(ctx: AdvisorSettlementContext, player: AutomaticAdvisorFlipPlayer, territory: string) {
return ctx.players.some((other) => other.id !== player.id && presenceAt(other, territory) > 0);
}
function currentWindow(ctx: AutomaticAdvisorFlipContext) {
return ctx.advanced === true && ctx.status === 'playing' &&
[0, 1, 3, 5, 6, 8].includes(ctx.phase) && positive(ctx.turn);
}
/** A supported committed producer opens a response for these current groups.
* No cards, resources, leaders or hidden marker values determine eligibility. */
export function quoteAutomaticAdvisorFlips(ctx: AutomaticAdvisorFlipContext): {
player: string;
territory: string;
forces: Record<string, number>;
}[] {
if (!currentWindow(ctx)) return [];
const flips: { player: string; territory: string; forces: Record<string, number> }[] = [];
for (const player of ctx.players) {
if (player.faction !== 'beneGesserit') continue;
for (const [territory, stance] of Object.entries(player.advisors ?? {})) {
if (stance.aloneFlipHold !== undefined || otherPresence(ctx, player, territory)) continue;
const forces = physicalGroup(player, territory);
if (Object.keys(forces).length) flips.push({ player: player.id, territory, forces });
}
}
return flips;
}
/** Keep a prevented alone occurrence until physical emptiness or foreign entry.
* With no occurrence hold, retain the legacy automatic conversion, including setup. */
export function advisorStanceAfterSettlement(
ctx: AdvisorSettlementContext,
player: AutomaticAdvisorFlipPlayer | string,
territory: string,
): AdvisorStance | undefined {
const current = ctx.players.find((candidate) => candidate.id === (typeof player === 'string' ? player : player.id));
const stance = current?.advisors?.[territory];
if (!current || current.faction !== 'beneGesserit' || !stance) return undefined;
if (!ctx.advanced) return { ...stance };
if (!Object.keys(physicalGroup(current, territory)).length) return undefined;
if (otherPresence(ctx, current, territory)) {
const remaining = { ...stance };
delete remaining.aloneFlipHold;
return remaining;
}
return stance.aloneFlipHold !== undefined ? { ...stance } : undefined;
}
function validateTransportSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'guildTransport' }>,
) {
const transport = source.transport;
requireFlip(record(transport) &&
Object.keys(transport).sort().join(',') === 'amount,elite,event,group,origin,player,sector,to' &&
identifier(transport.event) && identifier(transport.player) && identifier(transport.origin) &&
identifier(transport.to) && nonnegative(transport.sector) && transport.sector <= 18 &&
positive(transport.amount) && nonnegative(transport.elite) && transport.elite <= transport.amount &&
record(transport.group) && Object.values(transport.group).every(positive) &&
Object.values(transport.group).reduce((total, count) => total + count, 0) === transport.amount,
'Automatic advisor conversion lost its exact committed Guild transport metadata.');
const places = gameTerritories(ctx);
const origin = places.find((place) => place.id === transport.origin);
const destination = places.find((place) => place.id === transport.to);
requireFlip(origin && (transport.to === 'reserves' ||
(destination && destination.sectors.includes(transport.sector))) &&
Object.keys(transport.group).every((key) => {
const point = splitLocation(key);
return key === `${point.territory}:${point.sector}` && point.territory === transport.origin &&
origin.sectors.includes(point.sector);
}) && (source.territory === transport.origin ||
(transport.to !== 'reserves' && source.territory === transport.to)),
'Automatic advisor conversion must belong to the original transport origin or destination.');
const actors = ctx.players.filter((player) => player.id === transport.player);
const actor = actors[0];
requireFlip(actors.length === 1 && actor.shipped === true,
'Automatic advisor conversion lost its completed native transport slot.');
const deposited = transport.to === 'reserves'
? actor.reserves : actor.forces[`${transport.to}:${transport.sector}`];
requireFlip(nonnegative(deposited) && deposited >= transport.amount,
'Automatic advisor conversion lost the original transported physical counters.');
}
function validateGamontSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'gamont' }>,
) {
const receipt = source.gamont;
requireFlip(record(receipt) &&
Object.keys(receipt).sort().join(',') === 'elite,event,location,mentat,player,returned,target' &&
identifier(receipt.event) && identifier(receipt.player) && identifier(receipt.target) &&
identifier(receipt.location) && (receipt.elite === 0 || receipt.elite === 1) &&
typeof receipt.returned === 'boolean' && typeof receipt.mentat === 'boolean',
'Automatic advisor conversion lost its exact public Trip to Gamont receipt.');
const point = splitLocation(receipt.location);
requireFlip(receipt.location === `${point.territory}:${point.sector}` &&
gameTerritories(ctx).some((place) => place.id === point.territory && place.sectors.includes(point.sector)) &&
source.territory === point.territory,
'Automatic advisor conversion must use the original current Gamont location.');
const actors = ctx.players.filter((player) => player.id === receipt.player);
const targets = ctx.players.filter((player) => player.id === receipt.target);
requireFlip(actors.length === 1 && actors[0].faction === 'choam' &&
targets.length === 1 && receipt.target !== receipt.player,
'Automatic advisor conversion needs the original native CHOAM actor and distinct Gamont target.');
if (receipt.returned)
requireFlip(nonnegative(targets[0].reserves) && targets[0].reserves >= 1,
'Automatic advisor conversion lost the returned Gamont counter.');
requireFlip(!receipt.mentat || ctx.choamMentatPending === true,
'Automatic advisor conversion lost its final CHOAM Mentat opportunity.');
}
function validateHomeworldTransportSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'homeworldTransport' }>,
) {
const transport = source.transport;
requireFlip(record(ctx.homeworlds) && record(ctx.homeworlds.custody) && record(transport) &&
Object.keys(transport).sort().join(',') === 'amount,destination,elite,event,group,kind,origin,player' &&
identifier(transport.event) && (transport.kind === 'native' || transport.kind === 'junction') &&
identifier(transport.player) && identifier(transport.origin) && identifier(transport.destination) &&
positive(transport.amount) && nonnegative(transport.elite) && transport.elite <= transport.amount &&
record(transport.group) && Object.values(transport.group).every(positive) &&
Object.values(transport.group).reduce((total, count) => total + count, 0) === transport.amount,
'Automatic advisor conversion lost its public Homeworld transport receipt or module.');
const actors = ctx.players.filter((player) => player.id === transport.player);
const actor = actors[0];
requireFlip(actors.length === 1 && actor.shipped === true,
'Automatic advisor conversion lost its completed Homeworld transport slot.');
const places = gameTerritories(ctx);
const origin = places.find((place) => place.id === transport.origin);
const point = splitLocation(transport.destination);
const boardDestination = places.find((place) =>
transport.destination === `${point.territory}:${point.sector}` &&
place.id === point.territory && place.sectors.includes(point.sector));
const worldDestination = ctx.players.some((player) =>
transport.destination === `homeworld:${player.faction}` ||
(player.faction === 'emperor' && transport.destination === 'homeworld:emperor:salusa'));
requireFlip(origin && Object.keys(transport.group).every((key) => {
const selected = splitLocation(key);
return key === `${selected.territory}:${selected.sector}` && selected.territory === transport.origin &&
origin.sectors.includes(selected.sector);
}) && (source.territory === transport.origin ||
(boardDestination && source.territory === boardDestination.id)) &&
(transport.kind === 'native'
? actor.faction === 'guild' && worldDestination
: ctx.players.filter((player) => player.faction === 'guild').length === 1 &&
(worldDestination || !!boardDestination)),
'Automatic advisor conversion needs the original native or Junction Homeworld transport locations.');
if (boardDestination) {
const deposited = actor.forces[transport.destination];
requireFlip(nonnegative(deposited) && deposited >= transport.amount,
'Automatic advisor conversion lost the Homeworld transport board deposit.');
}
}
function validatePublicBattleIdentity(
ctx: AutomaticAdvisorFlipContext,
receipt: AutomaticAdvisorBattle,
turn: number,
) {
requireFlip(record(receipt) &&
Object.keys(receipt).sort().join(',') === 'combatants,event,result,territory,turn,winner' &&
identifier(receipt.event) && positive(receipt.turn) && receipt.turn === turn &&
identifier(receipt.territory) &&
Array.isArray(receipt.combatants) && receipt.combatants.length === 2 &&
receipt.combatants.every(identifier) && new Set(receipt.combatants).size === 2 &&
receipt.combatants.every((id) => ctx.players.filter((player) => player.id === id).length === 1) &&
['normal', 'traitor', 'mutualTraitors', 'explosion'].includes(receipt.result) &&
(receipt.result === 'normal' || receipt.result === 'traitor'
? identifier(receipt.winner) && receipt.combatants.includes(receipt.winner)
: receipt.winner === null),
'Automatic advisor conversion needs the original public battle result.');
const current = ctx.lastBattleContext;
requireFlip(!ctx.battle && record(current) && current.event === receipt.event &&
current.turn === receipt.turn && current.territory === receipt.territory &&
current.winner === receipt.winner && current.result === receipt.result &&
Array.isArray(current.combatants) && current.combatants.length === receipt.combatants.length &&
current.combatants.every((id, index) => id === receipt.combatants[index]),
'Automatic advisor conversion lost its original completed battle context.');
}
function validateBattleSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'battleLosses' }>,
) {
validatePublicBattleIdentity(ctx, source.battle, source.turn);
requireFlip(source.battle.territory === source.territory &&
!source.battle.combatants.includes(source.owner) &&
gameTerritories(ctx).some((place) => place.id === source.battle.territory),
'Automatic advisor conversion needs the original public battle result and bystander group.');
}
function validateFaceDanceSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'faceDance' }>,
) {
const receipt = source.faceDance;
requireFlip(record(receipt) &&
Object.keys(receipt).sort().join(',') === 'amount,battle,event,player,returned,sector,sources,territory' &&
identifier(receipt.event) && identifier(receipt.player) && identifier(receipt.territory) &&
nonnegative(receipt.sector) && nonnegative(receipt.amount) && record(receipt.sources) &&
Object.values(receipt.sources).every(positive) &&
Object.values(receipt.sources).reduce((total, count) => total + count, 0) === receipt.amount &&
record(receipt.returned) && Object.values(receipt.returned).every(positive) &&
Object.keys(receipt.returned).every((id) =>
identifier(id) && ctx.players.filter((player) => player.id === id).length === 1),
'Automatic advisor conversion lost its exact public Face Dance receipt.');
validatePublicBattleIdentity(ctx, receipt.battle, source.turn);
const actors = ctx.players.filter((player) => player.id === receipt.player);
const actor = actors[0];
requireFlip(actors.length === 1 && actor.faction === 'tleilaxu' &&
receipt.battle.winner !== null && receipt.player !== receipt.battle.winner &&
(receipt.battle.result === 'normal' || receipt.battle.result === 'traitor') &&
receipt.territory === receipt.battle.territory,
'Automatic advisor conversion needs the original other winner and native Face Dance actor.');
const places = gameTerritories(ctx);
const destination = places.find((place) => place.id === receipt.territory && place.sectors.includes(receipt.sector));
const worldDestination = receipt.territory === 'homeworld:tleilaxu' && receipt.sector === 0 &&
record(ctx.homeworlds) && record(ctx.homeworlds.custody);
requireFlip((!!destination || worldDestination) && Object.keys(receipt.sources).every((key) => {
if (key === 'reserves') return true;
const point = splitLocation(key);
return key === `${point.territory}:${point.sector}` &&
places.some((place) => place.id === point.territory && place.sectors.includes(point.sector));
}) && ((!!destination && source.territory === receipt.territory) || Object.keys(receipt.sources).some((key) =>
key !== 'reserves' && splitLocation(key).territory === source.territory)),
'Automatic advisor conversion must use the Face Dance destination or a positive board source.');
if (destination) {
const current = actor.forces[`${receipt.territory}:${receipt.sector}`];
const deposited = current === undefined ? 0 : current;
requireFlip(nonnegative(deposited) && deposited >= receipt.amount,
'Automatic advisor conversion lost the Face Dance board replacement.');
}
}
function validateStormSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'storm' }>,
) {
const receipt = source.storm;
requireFlip(record(receipt) &&
Object.keys(receipt).sort().join(',') === 'distance,event,from,sector,traversed' &&
identifier(receipt.event) && nonnegative(receipt.from) && receipt.from <= 18 &&
ctx.storm === receipt.from && positive(receipt.distance) && receipt.distance <= 40 &&
positive(receipt.traversed) && receipt.traversed <= receipt.distance &&
positive(receipt.sector) && receipt.sector <= 18 &&
receipt.sector === stormSectorAfter(receipt.from, receipt.traversed),
'Automatic advisor conversion lost its exact public current-sector storm receipt.');
const current = ctx.stormResolution;
requireFlip(record(current) && current.from === receipt.from && current.distance === receipt.distance &&
current.traversed === receipt.traversed && Array.isArray(current.pending) && current.pending.length === 0,
'Automatic advisor conversion must finish the original current-sector storm casualties first.');
const place = gameTerritories(ctx).find((candidate) => candidate.id === source.territory);
requireFlip(place && place.sectors.includes(receipt.sector) &&
stormExposesTerritory(place.id, ctx.shieldWallDestroyed === true),
'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,
source: AutomaticAdvisorFlipSource,
): void {
requireFlip(record(source) &&
((source.producer === 'groundMovement' && identifier(source.mover) &&
Object.keys(source).sort().join(',') === 'event,forces,move,mover,owner,phase,producer,territory,turn') ||
(source.producer === 'guildTransport' &&
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,territory,transport,turn') ||
(source.producer === 'homeworldTransport' &&
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,territory,transport,turn') ||
(source.producer === 'gamont' &&
Object.keys(source).sort().join(',') === 'event,forces,gamont,owner,phase,producer,territory,turn') ||
(source.producer === 'battleLosses' &&
Object.keys(source).sort().join(',') === 'battle,event,forces,owner,phase,producer,territory,turn') ||
(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') ||
(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.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);
const owner = owners[0];
requireFlip(owners.length === 1 && owner?.faction === 'beneGesserit' &&
owner.advisors?.[source.territory]?.aloneFlipHold === source.event,
'Automatic advisor conversion needs its original native owner and held occurrence.');
requireFlip(!otherPresence(ctx, owner, source.territory),
'Automatic advisor conversion requires the original group to remain alone.');
if (source.producer === 'groundMovement') {
const movers = ctx.players.filter((player) => player.id === source.mover);
requireFlip(movers.length === 1 && positive(source.move) && movers[0].moved === source.move,
'Automatic advisor conversion lost its original completed movement.');
} else if (source.producer === 'guildTransport') validateTransportSource(ctx, source);
else if (source.producer === 'homeworldTransport') validateHomeworldTransportSource(ctx, source);
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 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) &&
Object.keys(forces).sort().join(',') === Object.keys(source.forces).sort().join(',') &&
Object.entries(forces).every(([key, count]) => source.forces[key] === count),
'Automatic advisor conversion needs the same complete physical advisor group.');
}