Files
Dune/game/disaster-preflight.ts
T
Quintine b2fcef8d51 Add Storm and Spice Blow practice
Expand the interactive introduction to thirteen lessons and eleven Basic examples. Separate storm sealing, reveal and movement; compare first-turn and later-turn ranges, wraparound, exposure and shelter. Offer four spice sequences for accumulation, storm loss, later worms and first-turn worm return, with Nexus opening only after the replacement blow finishes. Reuse existing card inspection and preserve all previous lesson identities and results through version 6 migration.

Extract unchanged storm-sector, exposure, worm-protection and spice-placement calculations into a focused module shared by engine, preflight and teaching. Existing battle-wheel defaults stay intact while storm practice supplies its minimum and accessible label. No live state schema, rules interpretation, mode gate or AI strategy changes.

All 4954 offline tests, types, lint and production build pass on stable final source. Focused tests compare all 24 offered storm dials and four spice cases with actual engine actions, verify exact ignored-worm return, and exercise every saved stage and v1–v5 migration. Independent review found no material findings. Earlier broad runs passed but were invalidated by a final rules-copy clarification and migration documentation correction; final3 verifies the stable source.

Browser checks retain the earlier Fremen revival outcome after migration, restore a sealed storm and completed Nexus across refresh, verify wrap/shelter/zero movement, exercise all four spice cases and inspect the revealed worm. Mobile controls and modal fit 390px; Escape restores the trigger. Screenshot capture remains unavailable. Full faction, Fremen-ride, Advanced/expansion teaching and complete game acceptance remain open.

All 1373 rooms, 2883 seats and auxiliary rows remain unchanged; SQLite integrity and foreign keys pass. Server reused without restart. HTTP tests not repeated because HTTP, auth, persistence and state shape are unchanged.

Private evidence: 20260921-opening-phase-practice.
Verified source tree: 6bd333ec6938904b31523f8b20042e516ba55b6ac4c63973e5bb7ee74baa71fb
2026-09-21 09:24:51 +10:00

271 lines
8.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 { revealRicheseNoField } from './richese-no-field';
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',
) {
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 &&
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');
}
export function validateWormDevouring(
g: Game,
t: string,
protectedAlly?: 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.
validateWormDevouring(
g,
response.location,
response.kind === 'wormSurvival' ? response.recipient : undefined,
response.kind === 'wormAllyProtection',
);
}