Files
Dune/game/disaster-preflight.ts
Quintine 40c9651b80
Verify and publish checkpoint container / container (push) Successful in 10m58s
Support group Fremen worm protection
2026-10-29 00:05:12 +11:00

290 lines
9.3 KiB
TypeScript

import { stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import type { Game, Player, ResponseWindow } from './engine';
import { splitLocation, territory, validLocation } from './board';
import { SPICE_CARDS } from './cards';
import { isBoardNoFieldLocation, revealRicheseNoField } from './richese-no-field';
import { DISCOVERY_SPICE_CARDS } from './discoveries';
export class DisasterPreflightError extends Error {}
function requireDisaster(
condition: unknown,
message: string,
): asserts condition {
if (!condition) throw new DisasterPreflightError(message);
}
const whole = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) >= 0;
function boardKey(key: string) {
const loc = splitLocation(key);
requireDisaster(
key === `${loc.territory}:${loc.sector}` &&
validLocation(loc.territory, loc.sector),
'The disaster needs a valid board location.',
);
return loc;
}
function exposed(g: Game, key: string) {
return stormExposesTerritory(boardKey(key).territory, g.shieldWallDestroyed);
}
/** Validate only physical groups that this disaster can destroy. Concealed
* materialization is quoted without allocating its actual reveal event. */
function casualtyCustody(
g: Game,
p: Player,
affected: (key: string) => boolean,
cause: 'storm' | 'worm' | 'discoveryBlow',
) {
let losses = 0,
eliteLosses = 0;
for (const key of new Set([
...Object.keys(p.forces),
...Object.keys(p.elites?.forces ?? {}),
])) {
if (!affected(key)) continue;
const n = p.forces[key] ?? 0,
elite = p.elites?.forces[key] ?? 0;
requireDisaster(
whole(n) && whole(elite) && elite <= n,
'Disaster casualties no longer match the ordinary and elite forces.',
);
losses += n;
eliteLosses += elite;
}
const marker = p.noField?.deployed;
if (
marker && isBoardNoFieldLocation(marker.location) &&
affected(`${marker.location.territory}:${marker.location.sector}`)
) {
requireDisaster(
marker.controller === p.id && p.faction === 'richese',
'The exposed No-Field no longer belongs to its force controller.',
);
try {
const reveal = revealRicheseNoField(p.noField!, {
tokenId: marker.tokenId,
reserves: p.reserves,
cause,
});
losses += reveal.forces;
} catch (error) {
throw new DisasterPreflightError(
error instanceof Error
? error.message
: 'The exposed No-Field is invalid.',
);
}
}
requireDisaster(
whole(p.tanks) &&
whole(p.tanks + losses) &&
(!p.elites ||
(whole(p.elites.tanks) &&
p.elites.tanks <= p.tanks &&
whole(p.elites.tanks + eliteLosses))),
'Disaster casualties need valid remaining tank capacity.',
);
}
export function validateStormTraversal(g: Game) {
const r = g.stormResolution;
requireDisaster(
g.status === 'playing' &&
g.phase === 0 &&
r &&
whole(r.from) &&
r.from >= 0 &&
r.from <= 18 &&
whole(r.distance) &&
r.distance <= 40 &&
whole(r.traversed) &&
r.traversed <= r.distance &&
Array.isArray(r.pending) &&
new Set(r.pending).size === r.pending.length,
'The pending storm needs a valid, finite traversal.',
);
const current = stormSectorAfter(r.from, r.traversed);
requireDisaster(
r.pending.every(
(key) =>
r.traversed > 0 && exposed(g, key) && boardKey(key).sector === current,
),
'The pending storm loss groups do not belong to its current sector.',
);
const crossed = new Set(
Array.from(
{ length: r.distance - r.traversed },
(_, i) => stormSectorAfter(r.from, r.traversed + i + 1),
),
);
for (const p of g.players)
casualtyCustody(
g,
p,
(key) => {
const loc = boardKey(key);
return (
exposed(g, key) &&
(crossed.has(loc.sector) ||
(p.faction === 'fremen' && r.pending.includes(key))) &&
!(p.faction === 'choam' && r.choamProtected?.includes(loc.territory))
);
},
'storm',
);
}
export function validateStormCancellation(g: Game, response: ResponseWindow) {
const p = g.players.find((p) => p.id === response.owner);
requireDisaster(
response.kind === 'stormProtection' &&
g.status === 'playing' &&
g.advanced &&
p?.faction === 'fremen',
'This storm protection needs its current Fremen owner.',
);
if (response.resume === 'storm') {
validateStormTraversal(g);
requireDisaster(
g.stormResolution!.traversed === 0 &&
g.stormResolution!.pending.length === 0,
'Storm protection must precede the pending traversal.',
);
return;
}
requireDisaster(
response.resume === 'shipment' &&
g.phase === 5 &&
g.active === p.id &&
p.shipped &&
typeof response.location === 'string',
'This storm protection needs its original shipment.',
);
const key = response.location,
loc = boardKey(key),
n = response.amount,
elite = response.elite ?? 0;
requireDisaster(
loc.sector === g.storm &&
whole(n) &&
n > 0 &&
whole(elite) &&
elite <= n &&
n <= (p.forces[key] ?? 0) &&
elite <= (p.elites?.forces[key] ?? 0) &&
n - elite <= (p.forces[key] ?? 0) - (p.elites?.forces[key] ?? 0),
'The canceled shipment no longer has its declared ordinary and elite cohort.',
);
casualtyCustody(g, p, (candidate) => candidate === key, 'storm');
}
/** A printed Discovery blow destroys the entire named territory. Native worm
* survival and allied protection do not exempt any faction from this effect. */
export function validateDiscoveryBlow(g: Game, t: string) {
requireDisaster(
DISCOVERY_SPICE_CARDS.some(card => card.territory === t),
'The Discovery spice blow needs a printed Discovery territory.',
);
for (const p of g.players)
casualtyCustody(
g,
p,
(key) => splitLocation(key).territory === t && !!boardKey(key),
'discoveryBlow',
);
}
export function validateWormDevouring(
g: Game,
t: string,
protectedAlly?: string | readonly string[],
protectFremen = true,
) {
requireDisaster(validTerritory(t), 'The worm needs a valid territory.');
for (const p of g.players)
if (wormConsumesForces(p, protectedAlly, protectFremen))
casualtyCustody(
g,
p,
(key) => splitLocation(key).territory === t && !!boardKey(key),
'worm',
);
}
function validTerritory(t: unknown): t is string {
if (typeof t !== 'string') return false;
try {
return territory(t).type === 'sand' || SPICE_CARDS.some(([id]) => id === t);
} catch {
return false;
}
}
export function validateWormCancellation(g: Game, response: ResponseWindow) {
const fremen = g.players.find((p) => p.id === response.owner);
requireDisaster(
g.status === 'playing' &&
g.phase === 1 &&
fremen?.faction === 'fremen' &&
validTerritory(response.location) &&
['wormSurvival', 'wormAllyProtection', 'wormPlacement'].includes(
response.kind,
),
'This worm cancellation needs its original Fremen response and territory.',
);
requireDisaster(
g.summonedWorm
? g.summonedWorm.territory === response.location &&
!!g.summonedWorm.resume
: !!(g.spiceSequence || g.spiceResolution),
'This worm needs its pending spice blow or summoned continuation.',
);
requireDisaster(
!g.spiceSequence ||
((g.spiceSequence.pile === 0 || g.spiceSequence.pile === 1) &&
Array.isArray(g.spiceSequence.skipped)),
'The worm needs a valid spice discard pile.',
);
requireDisaster(
!g.spiceResolution || Array.isArray(g.spiceResolution.skipped),
'The worm needs a valid deferred spice-card list.',
);
requireDisaster(
Array.isArray(g.spiceDiscard[0]) &&
Array.isArray(g.spiceDiscard[1]) &&
Array.isArray(g.spiceDeck),
'The worm needs its spice deck and discard piles.',
);
requireDisaster(
Array.isArray(g.wormRides),
'The worm needs its current ride list.',
);
if (g.summonedWorm) {
const parent = g.summonedWorm.resume;
requireDisaster(
Array.isArray(parent.wormRides) &&
parent.wormRides.every(validTerritory) &&
typeof parent.nexus === 'boolean',
'The summoned worm needs its saved ride and Nexus continuation.',
);
}
if (response.recipient !== undefined)
requireDisaster(
g.players.some((p) => p.id === response.recipient),
'The protected ally is no longer seated.',
);
if (response.kind === 'wormPlacement') {
requireDisaster(
g.advanced &&
!g.summonedWorm &&
territory(response.location).type === 'sand',
'Additional worm placement requires its Advanced spice sequence.',
);
return;
}
// Canceling allied protection may first open the separate survival decision;
// validate the affected army now without executing that future window.
const group = response.coalitionWormEvent && g.coalitionWormProtection?.event === response.coalitionWormEvent
? g.coalitionWormProtection.outcomes.filter(outcome => outcome.outcome === 'allowed').map(outcome => outcome.ally)
: undefined;
validateWormDevouring(
g,
response.location,
group ?? (response.kind === 'wormSurvival' ? response.recipient : undefined),
response.kind === 'wormAllyProtection',
);
}