Keep completed moves while allowing native advisor-to-fighter conversion to be prevented after empty arrival or the last outsider's ground departure. Bind each queued response to its actual whole group and movement. Shared settlement honors the explicit provisional continuously-alone cancellation duration; setup remains automatic and non-ground producers remain open. Validation: 16 related files199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Native34/36-action public Advanced sources earn original setup/counters/Karama. Existing standard controls render; no new component-callback, browser/deployed or full-mode certificate. No saved-room/D1/restart/deployment operation.
140 lines
5.8 KiB
TypeScript
140 lines
5.8 KiB
TypeScript
import type { FactionId } from './catalog';
|
|
import { splitLocation } from './board';
|
|
import { presenceAt, type ForcePresence } from './force-presence';
|
|
|
|
export type AdvisorStance = {
|
|
lockedTurn?: number;
|
|
aloneFlipHold?: string;
|
|
};
|
|
|
|
export type AutomaticAdvisorFlipSource = {
|
|
event: string;
|
|
owner: string;
|
|
territory: string;
|
|
turn: number;
|
|
phase: number;
|
|
producer: 'groundMovement';
|
|
mover: string;
|
|
move: number;
|
|
forces: Record<string, number>;
|
|
};
|
|
|
|
export type AutomaticAdvisorFlipPlayer = ForcePresence & {
|
|
id: string;
|
|
faction: FactionId;
|
|
advisors?: Readonly<Record<string, AdvisorStance>>;
|
|
moved?: number;
|
|
};
|
|
|
|
export type AdvisorSettlementContext = {
|
|
advanced: boolean;
|
|
players: readonly AutomaticAdvisorFlipPlayer[];
|
|
};
|
|
|
|
export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & {
|
|
status: string;
|
|
turn: number;
|
|
phase: number;
|
|
};
|
|
|
|
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 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 && positive(ctx.turn);
|
|
}
|
|
|
|
/** Only the ground-movement 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;
|
|
}
|
|
|
|
/** Validate the same committed move and exact held group before either response outcome. */
|
|
export function validateAutomaticAdvisorFlip(
|
|
ctx: AutomaticAdvisorFlipContext,
|
|
source: AutomaticAdvisorFlipSource,
|
|
): void {
|
|
requireFlip(record(source) &&
|
|
Object.keys(source).sort().join(',') === 'event,forces,move,mover,owner,phase,producer,territory,turn' &&
|
|
identifier(source.event) && identifier(source.owner) && identifier(source.territory) &&
|
|
identifier(source.mover) && source.producer === 'groundMovement' &&
|
|
positive(source.turn) && source.turn === ctx.turn && source.phase === 5 &&
|
|
currentWindow(ctx) && source.phase === ctx.phase,
|
|
'Automatic advisor conversion lost its original current-turn ground movement.');
|
|
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.');
|
|
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.');
|
|
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.');
|
|
}
|