290 lines
9.3 KiB
TypeScript
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',
|
|
);
|
|
}
|