125 lines
7.1 KiB
TypeScript
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);
|
|
}
|