Files
Dune/game/coalition-worm-protection.ts
T
Quintine 3ba9087405
Verify and publish checkpoint container / container (push) Successful in 10m53s
Connect staged Advanced classic group alliances
2026-10-29 02:37:23 +11:00

125 lines
7.1 KiB
TypeScript

import { TERRITORIES } from './board';
import { SPICE_CARDS } from './cards';
import { coalitionOf, CoalitionAllianceError } from './coalition-alliance';
import { presenceAt } from './force-presence';
import type { Game } from './engine';
/** A single worm's group decisions are resolved before it destroys forces.
* A separate native Fremen-survival response may follow the complete frame. */
export type CoalitionWormProtectionOutcome = {
ally: string;
outcome: 'declined' | 'allowed' | 'canceled';
};
export type CoalitionWormProtectionFrame = {
event: string;
turn: number;
territory: string;
owner: string;
/** Threatened group allies in the original public storm order. */
order: string[];
outcomes: CoalitionWormProtectionOutcome[];
stage: 'decision' | 'response' | 'complete';
};
export class CoalitionWormProtectionError extends Error {
constructor(message: string) { super(message); this.name = 'CoalitionWormProtectionError'; }
}
function requireProtection(value: unknown, message: string): asserts value {
if (!value) throw new CoalitionWormProtectionError(message);
}
const id = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value.trim() === value;
const object = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const classic = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen']);
const validTerritory = (value: string) => TERRITORIES.some(row => row.id === value &&
(row.type === 'sand' || SPICE_CARDS.some(([territory]) => territory === value)));
/** Only the unexposed bare Basic/Advanced classic group profile is supported.
* Return null for all legacy pair games and other profiles. */
function threatened(g: Game, owner: string, territory: string): string[] | null {
if (!g.coalitionState || g.status !== 'playing' || g.phase !== 1 ||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
!g.players.every(player => classic.has(player.faction))) return null;
const fremen = g.players.find(player => player.id === owner);
if (!fremen || fremen.faction !== 'fremen' ||
g.players.filter(player => player.faction === 'fremen').length !== 1) return null;
let group;
try { group = coalitionOf(g.players, g.coalitionState, owner); }
catch (error) {
if (error instanceof CoalitionAllianceError) throw new CoalitionWormProtectionError(error.message);
throw error;
}
if (!group || group.members.length < 3) return null;
requireProtection(Array.isArray(g.order) && g.order.length === g.players.length &&
new Set(g.order).size === g.order.length && g.order.every(seat => g.players.some(player => player.id === seat)),
'Group worm protection requires the current complete storm order.');
return g.order.filter(seat => seat !== owner && group!.members.includes(seat) &&
presenceAt(g.players.find(player => player.id === seat)!, territory) > 0);
}
export function createCoalitionWormProtection(
g: Game, owner: string, territory: string, event: string,
): CoalitionWormProtectionFrame | null {
requireProtection(id(owner) && id(event) && id(territory) && validTerritory(territory) &&
Number.isSafeInteger(g.turn) && g.turn > 0, 'Choose the original worm occurrence and Fremen owner.');
const order = threatened(g, owner, territory);
if (!order?.length) return null;
return {event, turn:g.turn, territory, owner, order, outcomes:[], stage:'decision'};
}
/** Recheck the captured occurrence and exact threatened order before every
* transition; no card, casualty or force transfer is performed here. */
export function validateCoalitionWormProtection(g: Game, frame: CoalitionWormProtectionFrame): void {
requireProtection(object(frame) && Object.keys(frame).sort().join(',') === 'event,order,outcomes,owner,stage,territory,turn' &&
id(frame.event) && id(frame.owner) && id(frame.territory) && validTerritory(frame.territory) &&
Number.isSafeInteger(frame.turn) && frame.turn === g.turn && Array.isArray(frame.order) &&
frame.order.length > 0 && frame.order.length <= 5 && frame.order.every(id) &&
new Set(frame.order).size === frame.order.length && Array.isArray(frame.outcomes) &&
['decision', 'response', 'complete'].includes(frame.stage),
'The group worm protection has lost its original occurrence.');
const current = threatened(g, frame.owner, frame.territory);
requireProtection(current && JSON.stringify(current) === JSON.stringify(frame.order),
'The original group worm allies or storm order changed.');
requireProtection(frame.outcomes.length <= frame.order.length && frame.outcomes.every((outcome, index) =>
object(outcome) && Object.keys(outcome).sort().join(',') === 'ally,outcome' &&
outcome.ally === frame.order[index] && ['declined', 'allowed', 'canceled'].includes(outcome.outcome)) &&
(frame.stage === 'complete' ? frame.outcomes.length === frame.order.length : frame.outcomes.length < frame.order.length),
'The group worm protection lost its ordered decisions.');
}
/** The current ally is public; the decision remains Fremen's. */
export function currentCoalitionWormAlly(g: Game, frame: CoalitionWormProtectionFrame): string | null {
validateCoalitionWormProtection(g, frame);
return frame.stage === 'complete' ? null : frame.order[frame.outcomes.length];
}
export function decideCoalitionWormProtection(
g: Game, frame: CoalitionWormProtectionFrame, owner: string, event: string, ally: string, accept: boolean,
): CoalitionWormProtectionFrame {
const current = currentCoalitionWormAlly(g, frame);
requireProtection(frame.stage === 'decision' && owner === frame.owner && event === frame.event &&
ally === current && typeof accept === 'boolean', 'Choose the current original Fremen ally decision.');
if (accept) return {...frame, stage:'response'};
const outcomes: CoalitionWormProtectionOutcome[] = [...frame.outcomes, {ally, outcome:'declined'}];
return {...frame, outcomes, stage:outcomes.length === frame.order.length ? 'complete' : 'decision'};
}
export function resolveCoalitionWormProtection(
g: Game, frame: CoalitionWormProtectionFrame, owner: string, event: string, ally: string, canceled: boolean,
): CoalitionWormProtectionFrame {
const current = currentCoalitionWormAlly(g, frame);
requireProtection(frame.stage === 'response' && owner === frame.owner && event === frame.event &&
ally === current && typeof canceled === 'boolean', 'Resolve the original group ally Karama response.');
const outcomes: CoalitionWormProtectionOutcome[] = [...frame.outcomes,
{ally, outcome:canceled ? 'canceled' : 'allowed'}];
return {...frame, outcomes, stage:outcomes.length === frame.order.length ? 'complete' : 'decision'};
}
export function protectedCoalitionWormAllies(g: Game, frame: CoalitionWormProtectionFrame): string[] {
validateCoalitionWormProtection(g, frame);
requireProtection(frame.stage === 'complete', 'Finish all original group worm decisions first.');
return frame.outcomes.filter(outcome => outcome.outcome === 'allowed').map(outcome => outcome.ally);
}