Files
Dune/game/advisor-automatic-flip.ts
T
Quintine 9bce22abf5 Resolve advisor conversion after complete Face Dance replacement
Capture surviving alone BG groups at positive remote board sources and zero
replacement targets. Preserve returned armies, replacement, original leader
death, revelation and card cycle. Native Face Dance stays immune; consume the
publicly sourced parent before one original aftermath continuation.

Validation: 16 affected files201/201, types/lint/build, five native Face Dance
and GameTable SSR journeys, eight existing-profile response paths. Genuine
67/66-action sources earn original Ix47, native Face Dancers and actual battle.
Both conversion outcomes preserve nine returned survivors and one leader death;
actual BG city income is2/0. Correct a fixture's expected deck count43 to47
without changing cards or rules. Full combinations/modes remain unfinished.
Not deployed; no saved-room/D1, reset or restart operation. Runtime fingerprint
78239a9def5dd70ae404d5a614a21ea09ea615dd2a6cde7d05760715416d7401;
checkpoint prose/reference overlays are reviewed separately.
2026-10-24 21:00:58 +11:00

417 lines
20 KiB
TypeScript

import type { FactionId } from './catalog';
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import { presenceAt, type ForcePresence } from './force-presence';
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 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 }
);
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;
};
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' &&
(ctx.phase === 5 || ctx.phase === 6 || ctx.phase === 8) && 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.');
}
}
/** 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')) &&
identifier(source.event) && identifier(source.owner) && identifier(source.territory) &&
positive(source.turn) && source.turn === ctx.turn &&
source.phase === (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 validateFaceDanceSource(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.');
}