Files
Dune/game/discovery-storm.ts
T
Quintine f0d3d9c464 Prototype Jacurutu income, Testing Station and Shrine effects
Connect automatic unambiguous Jacurutu physical-force income, a sole-occupant
Testing Station storm choice and Shrine reciprocal physical card roles to
engine actions, player controls, legal AI and saved continuation. Preserve
Weather Control exclusion, exact card/force custody, private hand boundaries
and committed effects after restart. Shared role checks cover existing ordinary
and special Karama paths; reserved cards cannot enter another pending use.

Keep Orgiz, mixed-force Jacurutu allocation and contested Cistern/Testing
Station benefits explicitly pending. These are bounded prototypes; complete
Discovery/Advanced/expansion and publication gates remain closed.

Validation: types, lint and 4,345 offline tests passed (32 added), production
build passed, and 40 HTTP tests passed. Independent cross-reviews repaired
reserved-card deadlock, physical-card selection, alternate-role permission,
storm source/token binding and public-entitlement bot ordering. The first broad
run found five failures; focused repairs preceded the stable repeated check.

Browser A5EJQ2W4 used genuine Discovery setup/reveal/free entry with targeted QA
positioning. A human increased storm movement from two to three sectors,
moving sector 6 to 9. Refresh restored station forces and the private Snooper.
This was targeted control/recovery evidence, not an unmodified full game.
All 187 opening saved rooms remained unchanged; 31 QA/HTTP rooms were added
(218 total). Reused the running server; no restart or save reset.

Source-bound report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-location-effects/verification-final/report.json
Source fingerprint: beecb85ab7ec39319115389c115434d161591e8549802c542a6343a0fc19bea6
Durations: check 140540ms; build 6741ms; integration 24956ms.
Aggregate goal counter 63,116,444 to 64,295,091 at final verification:
1,178,647 tokens before final Git/push overhead for three bounded functions.
Standing authorization: Push all from now on.
2026-09-13 14:50:09 +10:00

251 lines
7.7 KiB
TypeScript

import { fighterCount } from './advisors';
import type { Game } from './engine';
import { isDiscoveryTokenId, type DiscoveryOpaqueTokenId } from './discoveries';
export class DiscoveryStormError extends Error {}
const whole = (n: unknown): n is number =>
Number.isSafeInteger(n) && (n as number) >= 0;
const record = (x: unknown): x is Record<string, unknown> =>
!!x && typeof x === 'object' && !Array.isArray(x);
function need(x: unknown, message: string): asserts x {
if (!x) throw new DiscoveryStormError(message);
}
export type StormMovementSource = {
turn: number;
kind: 'dials' | 'card' | 'weather';
distance: number;
signature: string;
};
export type DiscoveryStorm = {
event: string;
turn: number;
owner: string;
token: DiscoveryOpaqueTokenId;
from: number;
base: number;
delta: -1 | 0 | 1 | null;
stage: 'choose' | 'traversal' | 'complete';
signature: string;
};
export type DiscoveryStormContext = Pick<
Game,
| 'status'
| 'turn'
| 'phase'
| 'storm'
| 'stormPending'
| 'discoveryEnabled'
| 'discoveries'
| 'players'
> & { stormMovementSource?: StormMovementSource };
export function stormSourceSignature(
source: Omit<StormMovementSource, 'signature'>,
) {
return JSON.stringify([source.turn, source.kind, source.distance]);
}
export function createStormSource(
turn: number,
kind: StormMovementSource['kind'],
distance: number,
): StormMovementSource {
const source = { turn, kind, distance, signature: '' };
source.signature = stormSourceSignature(source);
validateStormSource(source);
return source;
}
export function validateStormSource(source: StormMovementSource) {
need(
record(source) &&
Object.keys(source).sort().join(',') === 'distance,kind,signature,turn' &&
whole(source.turn) &&
source.turn > 0 &&
['dials', 'card', 'weather'].includes(source.kind) &&
whole(source.distance) &&
source.distance <= 40 &&
source.signature === stormSourceSignature(source),
'The storm has lost its original movement source.',
);
}
export function discoveryStormSignature(
frame: Omit<DiscoveryStorm, 'signature'>,
) {
return JSON.stringify([
frame.event,
frame.turn,
frame.owner,
frame.token,
frame.from,
frame.base,
frame.delta,
frame.stage,
]);
}
export function discoveryStormOffer(g: DiscoveryStormContext) {
const token =
g.discoveryEnabled &&
g.discoveries?.tokens.find(
(t) =>
t.face === 'ecological-testing-station' &&
t.status === 'placed' &&
t.revealedTurn !== null,
);
if (!token) return null;
const occupants = g.players.filter(
(p) => fighterCount(p, 'ecological-testing-station') > 0,
);
if (occupants.length !== 1) return null;
const owner = occupants[0].id,
source = g.stormMovementSource;
if (source) validateStormSource(source);
let blocked: string | null = null;
if (g.status !== 'playing' || g.phase !== 0 || g.stormPending === null)
blocked = 'Wait until ordinary storm movement is revealed.';
else if (!source || source.turn !== g.turn)
blocked =
'This storm was already pending before the station became available. Its original movement is preserved.';
else if (source.kind === 'weather')
blocked = 'Ecological Testing Station cannot change Weather Control.';
else if (source.distance !== g.stormPending)
blocked =
'The current storm movement no longer matches this station opportunity.';
const base = g.stormPending ?? 0;
const options = ([-1, 0, 1] as const)
.filter((delta) => base + delta >= 0 && base + delta <= 40)
.map((delta) => ({ delta, distance: base + delta }));
return { owner, token: token.id, base, options, blocked };
}
export function createDiscoveryStorm(
g: DiscoveryStormContext,
event: string,
): DiscoveryStorm {
const offer = discoveryStormOffer(g);
need(
offer && !offer.blocked && typeof event === 'string' && event.length > 0,
offer?.blocked ?? 'No sole occupant may adjust this storm.',
);
const frame: DiscoveryStorm = {
event,
turn: g.turn,
owner: offer.owner,
token: offer.token,
from: g.storm,
base: offer.base,
delta: null,
stage: 'choose',
signature: '',
};
frame.signature = discoveryStormSignature(frame);
validateDiscoveryStorm(g, frame);
return frame;
}
export function validateDiscoveryStorm(
g: DiscoveryStormContext,
frame: DiscoveryStorm,
) {
need(
record(frame) &&
Object.keys(frame).sort().join(',') ===
'base,delta,event,from,owner,signature,stage,token,turn' &&
typeof frame.event === 'string' &&
frame.event.length > 0 &&
typeof frame.owner === 'string' &&
g.players.some((p) => p.id === frame.owner) &&
isDiscoveryTokenId(frame.token) &&
whole(frame.turn) &&
frame.turn > 0 &&
frame.turn <= g.turn &&
whole(frame.from) &&
frame.from <= 18 &&
whole(frame.base) &&
frame.base <= 40 &&
['choose', 'traversal', 'complete'].includes(frame.stage) &&
(frame.stage === 'choose'
? frame.delta === null
: [-1, 0, 1].includes(frame.delta as number) &&
frame.base + frame.delta! >= 0 &&
frame.base + frame.delta! <= 40) &&
frame.signature === discoveryStormSignature(frame),
'The station has lost its signed storm choice.',
);
need(
g.discoveryEnabled &&
g.discoveries?.tokens.some(
(token) =>
token.id === frame.token &&
token.face === 'ecological-testing-station' &&
token.status === 'placed' &&
token.revealedTurn !== null &&
token.revealedTurn <= frame.turn,
),
'The station choice must retain its original revealed physical token.',
);
if (frame.stage === 'complete') return;
need(
g.discoveryEnabled &&
g.status === 'playing' &&
g.phase === 0 &&
g.turn === frame.turn &&
g.storm === frame.from &&
g.stormMovementSource,
'Finish the station choice with its original storm.',
);
validateStormSource(g.stormMovementSource);
need(
g.stormMovementSource.turn === frame.turn &&
g.stormMovementSource.kind !== 'weather' &&
g.stormMovementSource.distance === frame.base,
'The station cannot replace its original storm source or affect Weather Control.',
);
if (frame.stage === 'choose') {
const offer = discoveryStormOffer(g);
need(
offer &&
!offer.blocked &&
offer.owner === frame.owner &&
offer.token === frame.token &&
offer.base === frame.base,
'The station choice no longer belongs to its original occupant and storm.',
);
} else
need(
g.stormPending === frame.base + frame.delta!,
'The adjusted storm must retain the chosen distance.',
);
}
export function chooseDiscoveryStorm(
g: DiscoveryStormContext,
frame: DiscoveryStorm,
owner: string,
event: unknown,
delta: unknown,
): DiscoveryStorm {
validateDiscoveryStorm(g, frame);
need(
frame.stage === 'choose' &&
frame.owner === owner &&
frame.event === event &&
typeof delta === 'number' &&
[-1, 0, 1].includes(delta) &&
frame.base + delta >= 0 &&
frame.base + delta <= 40,
'Choose the current station opportunity: decrease one, keep the distance, or increase one.',
);
const next = {
...frame,
delta: delta as -1 | 0 | 1,
stage: 'traversal' as const,
signature: '',
};
next.signature = discoveryStormSignature(next);
return next;
}
export function finishDiscoveryStorm(frame: DiscoveryStorm): DiscoveryStorm {
need(
frame.stage === 'traversal',
'Only a committed station adjustment can complete.',
);
const next = { ...frame, stage: 'complete' as const, signature: '' };
next.signature = discoveryStormSignature(next);
return next;
}