Add authorized Storm Card inspection
Connect the six numeric reference faces, the existing private Fremen forecast and actual public reveals to a shared enlarged inspector. Create the structured public chronicle component only where beginStormTurn reveals the card. Weather Control and Ecological Testing Station may change effective movement but never rewrite the original receipt. No draw, movement, AI strategy or mode gate changes. The face reader accepts only exact stormCard metadata with an integer value1 through6. Legacy text-only history still renders normally. Existing JSON saves preserve valid receipts within the normal250-entry chronicle; no card is inferred from a private forecast, current pending distance or old log text. Five new tests use genuine Advanced fixtures to verify forecast allowance/cancellation and privacy, public reveal, JSON/legacy continuation and original-versus-modified distances. The related reference suite also passes (17 focused cases total). Independent privacy/provenance/UI review found no material findings. The initial check stopped at an accessibility lint rule; replacing the numeric group with a semantic figure resolved it, followed by a fresh final run and mobile recheck. Required types, lint, 4946 offline tests, production build and 46 HTTP tests pass on stable final source. Browser QA opens all six reference faces, uses keyboard Enter/Escape and checks settled modal focus/return. At390px the modal is359px wide and fits the viewport; Close is44px high. The viewport was reset. Screenshot capture remains unavailable, so full visual acceptance is not claimed. Live Advanced browser play and final manufactured-face/artwork verification remain open; fixture privacy checks and gallery controls are narrower evidence. The existing server remained running. All 1337 original rooms, 2805 seats and existing recovery/entry/handover/delegation/migration rows remain unchanged; SQLite integrity and foreign keys pass. HTTP tests created only their own additional QA records. No database migration/reset, hourly automation, account-usage check or AI calibration. Standing automatic push authorization retained. Private evidence: 20260921-storm-card-inspection. Verified source tree: 3856e73f3cc3bb50003b6afda6ab4d08a513191c4e300e7742c2b5179a05cff1
This commit is contained in:
1 parent
67e429e32f
commit
ed53eb7d76
11 files changed
+399
-18
No files matched your search
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
import { EcazSetup } from './ecaz-setup';
|
||||
import { StormCardInspector, StormCardLogInspector } from './storm-cards';
|
||||
import { LobbyBotControls } from './lobby-bot-controls';
|
||||
import { IxRicheseTechnology } from './ix-richese-technology';
|
||||
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
|
||||
@@ -1210,9 +1211,10 @@ export function GameTable({
|
||||
</p>
|
||||
)}
|
||||
{g.stormForecast !== null && (
|
||||
<p className="fine">
|
||||
Your storm forecast: {g.stormForecast} sectors next turn.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p className="fine">Your storm forecast: {g.stormForecast} sectors next turn.</p>
|
||||
<StormCardInspector distance={g.stormForecast} context="forecast" />
|
||||
</div>
|
||||
)}
|
||||
<div className="resource-readout">
|
||||
<Coins size={18} />
|
||||
@@ -5469,10 +5471,13 @@ export function GameTable({
|
||||
{[...g.log].reverse().map((e) => (
|
||||
<li key={e.seq}>
|
||||
<span>{String(e.seq).padStart(3, '0')}</span>
|
||||
{e.text.replace(
|
||||
/used Karama to cancel fremenMovement\.$/,
|
||||
'used Karama to cancel Fremen movement.',
|
||||
)}
|
||||
<div className="min-w-0 space-y-2">
|
||||
<p className="m-0">{e.text.replace(
|
||||
/used Karama to cancel fremenMovement\.$/,
|
||||
'used Karama to cancel Fremen movement.',
|
||||
)}</p>
|
||||
<StormCardLogInspector component={e.component} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from 'next/link';
|
||||
import { StrongholdCardGallery } from './stronghold-cards';
|
||||
import { HomeworldCardGallery } from './homeworld-cards';
|
||||
import { NexusCardGallery } from './nexus-cards';
|
||||
import { StormCardGallery } from './storm-cards';
|
||||
import { createStrongholdCards } from '@/game/stronghold-cards';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useState, useEffect, useRef } from 'react';
|
||||
@@ -196,6 +197,7 @@ function RulesReferenceContent() {
|
||||
)}
|
||||
{topic.id === 'homeworlds' && <HomeworldCardGallery />}
|
||||
{topic.id === 'nexus-cards' && <NexusCardGallery />}
|
||||
{topic.id === 'storm-cards' && <StormCardGallery />}
|
||||
{topic.id === 'richese-cards' && (
|
||||
<section
|
||||
aria-label="Richese card collection"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
||||
import { Eye, Wind, X } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogClose, DialogDescription, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { STORM_CARD_DISTANCES, isStormCardDistance, stormCardDistance, type StormCardDistance } from '@/game/storm-cards';
|
||||
|
||||
const sectors = (distance: number) => `${distance} ${distance === 1 ? 'sector' : 'sectors'}`;
|
||||
const contexts = {
|
||||
forecast: {
|
||||
label: 'Private forecast',
|
||||
description: 'This is your private forecast for the next turn. Inspecting it does not reveal it to other players.',
|
||||
},
|
||||
reveal: {
|
||||
label: 'Public reveal',
|
||||
description: 'This card was revealed during a Storm phase. It records that revealed face, not the next private forecast.',
|
||||
},
|
||||
reference: {
|
||||
label: 'Rules reference',
|
||||
description: 'This is one of the six possible Storm Card faces. The reference does not show which card a game has drawn.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function StormCardFace({ distance }: { distance: StormCardDistance }) {
|
||||
return <figure className="mx-auto my-0 flex min-h-52 w-full max-w-72 flex-col items-center justify-center gap-3 rounded-xl border-2 border-[#96815a] bg-[#252b25] p-5 text-center text-[#f5e8c9]" aria-label={`Storm Card: ${sectors(distance)}`}>
|
||||
<div className="flex items-center gap-2 text-base font-semibold tracking-widest uppercase"><Wind aria-hidden="true" size={20} />Storm</div>
|
||||
<strong className="font-serif text-7xl leading-none" aria-hidden="true">{distance}</strong>
|
||||
<span className="text-base">{distance === 1 ? 'Sector' : 'Sectors'} counterclockwise</span>
|
||||
</figure>;
|
||||
}
|
||||
|
||||
/** Accept one already-authorized face, never a game, deck or forecast authority. */
|
||||
export function StormCardInspector({ distance, context = 'reference' }: {
|
||||
distance: unknown;
|
||||
context?: keyof typeof contexts;
|
||||
}) {
|
||||
if (!isStormCardDistance(distance)) return null;
|
||||
const guide = contexts[context];
|
||||
return <Dialog>
|
||||
<DialogTrigger render={<Button variant="outline" className="min-h-11 max-w-full whitespace-normal text-sm motion-reduce:transition-none" />}
|
||||
aria-label={`Inspect Storm Card: ${sectors(distance)} · ${guide.label}`}>
|
||||
<Eye aria-hidden="true" />Inspect Storm Card
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
|
||||
<DialogPrimitive.Popup className="fixed top-1/2 left-1/2 z-50 flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-xl -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-[#8e7952] bg-[#141814] text-[#eeeae0] shadow-2xl outline-none">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#414536] px-4 py-3">
|
||||
<span className="text-sm font-semibold text-[#ddbc77]">{guide.label}</span>
|
||||
<DialogClose render={<Button variant="outline" className="min-h-11 min-w-11 motion-reduce:transition-none" />}><X aria-hidden="true" />Close</DialogClose>
|
||||
</div>
|
||||
<div className="min-h-0 space-y-5 overflow-y-auto overscroll-contain p-5 sm:p-6">
|
||||
<DialogTitle className="m-0 font-serif text-2xl leading-tight text-[#fff3d6]">Storm Card — {sectors(distance)}</DialogTitle>
|
||||
<DialogDescription className="m-0 text-base leading-7 text-[#d9d4c7]">{guide.description}</DialogDescription>
|
||||
<StormCardFace distance={distance} />
|
||||
<p className="m-0 text-base leading-7">In the Advanced game with Fremen, the first storm uses the Battle Wheels.
|
||||
For later turns, the selected Storm Card is revealed and supplies the storm’s counterclockwise distance.
|
||||
It returns to the deck before the next forecast is drawn.</p>
|
||||
<p className="m-0 text-base leading-7">This face shows the card’s value. Weather Control and other effects can change
|
||||
the actual movement; the table’s storm result records that movement.</p>
|
||||
</div>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
export function StormCardLogInspector({ component }: { component: unknown }) {
|
||||
const distance = stormCardDistance(component);
|
||||
return distance === null ? null : <StormCardInspector distance={distance} context="reveal" />;
|
||||
}
|
||||
|
||||
export function StormCardGallery() {
|
||||
return <section aria-label="Storm Card collection" className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{STORM_CARD_DISTANCES.map(distance => <article key={distance} className="flex min-w-0 flex-col items-center gap-3 rounded-lg border border-[#454b3c] bg-[#1d221b] p-4">
|
||||
<h3 className="m-0 font-serif text-xl text-[#efd9a8]">Storm Card {distance}</h3>
|
||||
<StormCardFace distance={distance} />
|
||||
<StormCardInspector distance={distance} />
|
||||
</article>)}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
# Component inventory and verification ledger
|
||||
|
||||
**Storm Card inspection, 21 September 2026:** six numeric reference faces,
|
||||
authorized private Fremen forecasts and structured public reveal receipts now
|
||||
share an enlarged inspector. The actual card value survives separately from
|
||||
Weather Control or Testing Station movement changes. Existing text-only logs
|
||||
remain readable; no private draw is inferred from public movement. This updates
|
||||
the historical Storm Card enlargement gap, not complete manufactured-face or
|
||||
artwork certification. See [scope and evidence](STORM_CARD_INSPECTION.md).
|
||||
|
||||
**Leader Skills update, 13 September 2026:** all fourteen unique physical cards
|
||||
now have sourced names and complete paraphrased normal/battle text, shared
|
||||
inspectors, private two-card offers and one-card assignment custody. Five role
|
||||
|
||||
+10
-9
@@ -6,22 +6,23 @@ gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Latest delivered checkpoint: `c4ff75a`, [interactive alliance practice](INTERACTIVE_INTRODUCTION.md).
|
||||
Types, lint, **4,934 offline tests** and production build passed. All 1,337 rooms,
|
||||
Latest delivered checkpoint: `67e429e`, [Charity and Revival practice](INTERACTIVE_INTRODUCTION.md).
|
||||
Types, lint, **4,941 offline tests** and production build passed. All 1,337 rooms,
|
||||
2,805 seats and existing recovery/entry/handover/delegation rows stayed unchanged.
|
||||
The stopped server was started against existing state; an existing QA seat restored.
|
||||
The preceding `b7ec940` connects bidding practice, `b9718ac` connects
|
||||
The running server was reused; prior lesson results and paid returns restore.
|
||||
The preceding `c4ff75a` connects alliance practice, `b7ec940` connects bidding,
|
||||
and `b9718ac` connects
|
||||
[lobby AI configuration](LOBBY_AI_CONFIGURATION.md), and `b21b2bc` connects
|
||||
[one-use AI permission](SEAT_AI_PERMISSION.md). The `8f7a62c` policy checkpoint
|
||||
removed the usage cutoff and deferred AI refinement until feature completion,
|
||||
with three adjacent 75% targets. Earlier evidence remains in the
|
||||
[implementation history](IMPLEMENTATION_STATUS.md).
|
||||
|
||||
Current work: [Charity and Revival practice](INTERACTIVE_INTRODUCTION.md) adds two
|
||||
Basic phase lessons, cumulative free/paid returns, leader eligibility and shared
|
||||
spending through existing production calculations. Saved choices migrate every
|
||||
earlier lesson and commitment. No AI strategy changes. Final independent review,
|
||||
browser/check/build/preservation and Git evidence belong to its private report.
|
||||
Current work: [Storm Card inspection](STORM_CARD_INSPECTION.md) connects six
|
||||
reference faces, the permitted Fremen forecast and public chronicle receipts to
|
||||
enlarged inspection. Original card values stay separate from modified movement;
|
||||
legacy text logs remain valid. No rules or AI strategy changes. Independent review,
|
||||
browser/check/build/HTTP/preservation and Git evidence belong to its private report.
|
||||
A bounded current-state Duke review confirmed revival/loan paths still cross
|
||||
recorded custody, return and cycle questions; existing identity/price helpers and
|
||||
non-Ecaz guards are already present. No duplicate ruling or invented outcome.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 21 September 2026 — Storm Card inspection
|
||||
|
||||
The [Storm Card inspector](STORM_CARD_INSPECTION.md) connects six numeric reference
|
||||
faces, the existing private Fremen forecast and actual public reveal receipts in
|
||||
the chronicle. Inspection is read-only. Saved public records retain their original
|
||||
face after movement or Weather Control, separately from effective storm distance.
|
||||
Earlier text-only history remains valid; unknown component metadata fails closed.
|
||||
No AI strategy or rules mode changes. Focused projection/provenance checks,
|
||||
independent review, browser controls and final check/build/HTTP/preservation/Git
|
||||
evidence belong to the private checkpoint. Complete physical face/artwork and
|
||||
broader component acceptance remain unfinished.
|
||||
|
||||
## 21 September 2026 — Charity and Revival practice
|
||||
|
||||
The [introduction](INTERACTIVE_INTRODUCTION.md) expands to eleven lessons and nine
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Storm Card inspection
|
||||
|
||||
21 September 2026. Prototyped component inspection; Advanced and expansion gates
|
||||
remain unchanged. Six numeric Storm Card faces can be enlarged from the internal
|
||||
reference. An entitled Fremen player can inspect the existing private forecast,
|
||||
and every viewer can inspect a card after its public reveal in the chronicle.
|
||||
|
||||
`game/storm-cards.ts` is a face catalog, not a new deck or source of a live draw.
|
||||
The inspector accepts one already-authorized value and no game, deck or seat.
|
||||
The rules gallery shows all possibilities without selecting a live card.
|
||||
The existing `stormForecast` projection remains the sole private authorization.
|
||||
|
||||
At the real `beginStormTurn` reveal, the existing public log entry receives an
|
||||
optional `{kind:'stormCard', distance}` component. Neither drawing the next card,
|
||||
allowing/canceling Fremen foresight, basic dials nor Weather Control creates that
|
||||
receipt. The existing saved log preserves it through JSON and the usual
|
||||
250-entry history limit. Earlier text-only entries remain valid; no face is
|
||||
guessed from old text or current movement. The inspector rejects unknown kinds,
|
||||
extra fields and values outside the six faces.
|
||||
|
||||
Weather Control replaces movement and Ecological Testing Station adjusts it;
|
||||
neither edits the original revealed receipt. The card inspector therefore keeps
|
||||
the printed value distinct from the effective distance. No rules calculations,
|
||||
AI strategies, save schema migration or network authority change.
|
||||
|
||||
Authority: the [classic GF9 rulebook](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf),
|
||||
component inventory p.5 and Fremen sheet p.16. Publisher-indexed text retrieved
|
||||
21 September confirms the six-card deck and the private forecast/public reveal,
|
||||
return and shuffle sequence. The six values already used by the production engine
|
||||
are represented as readable numeric faces with original explanatory prose.
|
||||
This does not certify every detail of the manufactured artwork or printed face;
|
||||
that remains part of final component verification. No source link enters the app.
|
||||
|
||||
Focused tests cover authorization, reveal metadata, legacy saves, continuation,
|
||||
modified movement and invalid metadata. Independent review, browser controls,
|
||||
required check/build/HTTP results, preservation and Git delivery belong to the
|
||||
source-bound private checkpoint. Screenshot limitations are recorded separately;
|
||||
DOM bounds do not establish complete visual acceptance.
|
||||
+7
-1
@@ -1,3 +1,4 @@
|
||||
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
|
||||
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
|
||||
import { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards';
|
||||
import { bureaucratPaymentModeSupported, bureaucratPaymentSignature, bureaucratUsed, quoteBureaucratPayment, type BureaucratPaymentSource, type BureaucratPaymentUse, type BureaucratPaymentView } from './bureaucrat-payment';
|
||||
@@ -1564,6 +1565,8 @@ export type Game = {
|
||||
seq: number;
|
||||
text: string;
|
||||
automatic?: { faction: FactionId; name: string };
|
||||
/** Public at the actual card reveal; never populated from a private forecast. */
|
||||
component?: StormCardComponent;
|
||||
}[];
|
||||
stormDials: Record<string, number>;
|
||||
stormPending: number | null;
|
||||
@@ -2570,11 +2573,13 @@ const log = (
|
||||
g: Game,
|
||||
text: string,
|
||||
automatic?: { faction: FactionId; name: string },
|
||||
component?: StormCardComponent,
|
||||
) => {
|
||||
g.log.push({
|
||||
seq: (g.log.at(-1)?.seq ?? 0) + 1,
|
||||
text,
|
||||
...(automatic ? { automatic } : {}),
|
||||
...(component ? { component } : {}),
|
||||
});
|
||||
if (g.log.length > 250) g.log.shift();
|
||||
};
|
||||
@@ -13026,7 +13031,8 @@ function beginStormTurn(g: Game) {
|
||||
g.stormMovementSource = nexusRule(() => createStormSource(g.turn,'card',g.stormPending!));
|
||||
g.stormCard = null;
|
||||
g.stormCardKnown = false;
|
||||
log(g, `Storm card revealed: ${g.stormPending} sectors.`);
|
||||
log(g, `Storm card revealed: ${g.stormPending} sectors.`, undefined,
|
||||
isStormCardDistance(g.stormPending) ? { kind: 'stormCard', distance: g.stormPending } : undefined);
|
||||
}
|
||||
for (const p of g.players) {
|
||||
for (const l of p.leaders) {
|
||||
|
||||
+27
-1
@@ -202,6 +202,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
related: [
|
||||
'setup',
|
||||
'interactive-introduction',
|
||||
'storm-cards',
|
||||
'truthtrance-spice',
|
||||
'ecaz-ambassadors',
|
||||
'richese-cards',
|
||||
@@ -2003,9 +2004,34 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
? ['ecaz-ambassadors', 'tleilaxu-revival']
|
||||
: phase === 1
|
||||
? ['card-harvester', 'faction-fremen']
|
||||
: undefined,
|
||||
: phase === 0
|
||||
? ['storm-cards', 'advanced-storm-spice', 'faction-fremen']
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
{
|
||||
id: 'storm-cards',
|
||||
title: 'Storm Cards: forecast and revealed faces',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Inspect the six possible faces, your permitted private forecast, and a card already revealed to the table.',
|
||||
steps: [
|
||||
'The gallery shows the six possible values, one through six. It does not identify a live draw.',
|
||||
'When the table grants Fremen its private forecast, Inspect Storm Card opens that exact face. Other players do not receive the private forecast.',
|
||||
'When the card is publicly revealed, its chronicle entry gains an inspector. The recorded face remains available while that entry remains in the chronicle, including after refresh.',
|
||||
'The card value and actual movement can differ. Weather Control and Ecological Testing Station do not rewrite the recorded card face. Read the current storm result for the distance actually moved.',
|
||||
'Earlier saved chronicle entries without a recorded face retain their text. Inspection is read-only and never draws, plays or reveals another card.',
|
||||
],
|
||||
related: ['storm', 'advanced-storm-spice', 'faction-fremen', 'privacy', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Six numeric faces and a public reveal record connect existing Storm Card state to inspection. Movement, draws and mode gates are unchanged; full component artwork and printed-face verification remain open.', evidence: ['game/storm-cards.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'Private forecasts, public revealed-card chronicle entries and the six-face reference gallery share a keyboard-accessible enlarged inspector.', evidence: ['components/storm-cards.tsx', 'components/game-table.tsx', 'components/rules-reference.tsx'] },
|
||||
{ area: 'AI', status: 'Implemented', detail: 'Inspection adds no game action or AI decision. Existing forecast authorization, legal choices and profiles remain unchanged.' },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Guidance distinguishes private forecasts, public history, reference examples and modified storm distance. This is not complete physical-component certification.', evidence: ['docs/STORM_CARD_INSPECTION.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused checks cover private forecast authorization, public reveal records, saved continuation, original versus modified distance and malformed component rejection. Broader rules and visual acceptance remain separate.', evidence: ['tests/storm-card-inspection.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'alliance-funding',
|
||||
title: 'Fund an ally’s bid or shipment',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** The six numeric Storm Card faces. This catalog does not hold a game's draw. */
|
||||
export const STORM_CARD_DISTANCES = [1, 2, 3, 4, 5, 6] as const;
|
||||
export type StormCardDistance = (typeof STORM_CARD_DISTANCES)[number];
|
||||
export type StormCardComponent = { kind: 'stormCard'; distance: StormCardDistance };
|
||||
|
||||
export function isStormCardDistance(value: unknown): value is StormCardDistance {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6;
|
||||
}
|
||||
|
||||
/** Only an explicitly revealed face may be read from a chronicle component. */
|
||||
export function stormCardDistance(component: unknown): StormCardDistance | null {
|
||||
if (!component || typeof component !== 'object' || Array.isArray(component) ||
|
||||
Object.keys(component).sort().join(',') !== 'distance,kind') return null;
|
||||
const value = component as { kind: unknown; distance: unknown };
|
||||
return value.kind === 'stormCard' && isStormCardDistance(value.distance) ? value.distance : null;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import {
|
||||
STORM_CARD_DISTANCES,
|
||||
isStormCardDistance,
|
||||
stormCardDistance,
|
||||
} from '../game/storm-cards';
|
||||
import {
|
||||
confirmDiscoveryStorm,
|
||||
discoveryStormFixture,
|
||||
} from './fixture-discovery-storm';
|
||||
import { discoveryFixture } from './fixture-discovery';
|
||||
import { placeFixtureHand } from './fixture-hand';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
|
||||
function advancedStormGame() {
|
||||
const game = discoveryFixture(true);
|
||||
game.players.forEach((_, index) => placeFixtureHand(game, index, []));
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.advanced, true);
|
||||
return game;
|
||||
}
|
||||
|
||||
function readyAll(state: Game) {
|
||||
let game = state;
|
||||
for (const player of game.players)
|
||||
game = applyAction(game, player.id, { type: 'ready' });
|
||||
return game;
|
||||
}
|
||||
|
||||
function allowResponses(state: Game) {
|
||||
let game = state;
|
||||
while (game.response) {
|
||||
const responder = game.players.find(
|
||||
(player) => !game.response!.passed.includes(player.id),
|
||||
);
|
||||
assert.ok(responder, 'a pending response has a responder');
|
||||
game = applyAction(game, responder.id, { type: 'passResponse' });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
function forecastOpportunity(withKarama = true) {
|
||||
let game = advancedStormGame();
|
||||
let karama: ReturnType<typeof baseDeck>[number] | undefined;
|
||||
if (withKarama) {
|
||||
karama = baseDeck().find((card) => card.effect === 'karama')!;
|
||||
placeFixtureHand(
|
||||
game,
|
||||
game.players.findIndex((player) => player.id === 'a'),
|
||||
[karama],
|
||||
);
|
||||
}
|
||||
for (const dialer of game.stormDialers)
|
||||
game = applyAction(game, dialer, { type: 'stormDial', amount: 0 });
|
||||
game = readyAll(game);
|
||||
assert.equal(game.response?.kind, 'stormPeek');
|
||||
assert.ok(isStormCardDistance(game.stormCard));
|
||||
return { game, face: game.stormCard, karama };
|
||||
}
|
||||
|
||||
function revealedStorm() {
|
||||
const opportunity = forecastOpportunity();
|
||||
let game = allowResponses(opportunity.game);
|
||||
assert.equal(viewGame(game, 'f').stormForecast, opportunity.face);
|
||||
game.phase = 8;
|
||||
game.ready = [];
|
||||
game = readyAll(game);
|
||||
assert.equal(game.turn, 2);
|
||||
assert.equal(game.phase, 0);
|
||||
assert.equal(game.stormPending, opportunity.face);
|
||||
return { game, face: opportunity.face };
|
||||
}
|
||||
|
||||
function receiptFaces(game: { log: readonly { component?: unknown }[] }) {
|
||||
return game.log.flatMap((entry) => {
|
||||
const distance = stormCardDistance(entry.component);
|
||||
return distance === null ? [] : [distance];
|
||||
});
|
||||
}
|
||||
|
||||
void test('the six-card catalog and structured receipt guard accept only exact canonical faces', () => {
|
||||
assert.deepEqual(STORM_CARD_DISTANCES, [1, 2, 3, 4, 5, 6]);
|
||||
for (const distance of STORM_CARD_DISTANCES) {
|
||||
assert.equal(isStormCardDistance(distance), true);
|
||||
assert.equal(stormCardDistance({ kind: 'stormCard', distance }), distance);
|
||||
}
|
||||
for (const invalid of [
|
||||
null,
|
||||
[],
|
||||
'stormCard',
|
||||
{ kind: 'stormCard' },
|
||||
{ distance: 1 },
|
||||
{ kind: 'storm-card', distance: 1 },
|
||||
{ kind: 'stormCard', distance: 0 },
|
||||
{ kind: 'stormCard', distance: 7 },
|
||||
{ kind: 'stormCard', distance: 1.5 },
|
||||
{ kind: 'stormCard', distance: 1, extra: true },
|
||||
])
|
||||
assert.equal(stormCardDistance(invalid), null);
|
||||
for (const invalid of [0, 7, 1.5, NaN, '1', null])
|
||||
assert.equal(isStormCardDistance(invalid), false);
|
||||
});
|
||||
|
||||
void test('a forecast remains private until allowed, cancellation hides it, and neither path publishes a component receipt', () => {
|
||||
const acceptedOpportunity = forecastOpportunity();
|
||||
assert.equal(viewGame(acceptedOpportunity.game, 'f').stormForecast, null);
|
||||
assert.equal(viewGame(acceptedOpportunity.game, 'a').stormForecast, null);
|
||||
assert.deepEqual(receiptFaces(acceptedOpportunity.game), []);
|
||||
const accepted = allowResponses(acceptedOpportunity.game);
|
||||
assert.equal(viewGame(accepted, 'f').stormForecast, acceptedOpportunity.face);
|
||||
assert.equal(viewGame(accepted, 'a').stormForecast, null);
|
||||
assert.deepEqual(receiptFaces(accepted), []);
|
||||
|
||||
const canceledOpportunity = forecastOpportunity(true);
|
||||
const canceled = applyAction(canceledOpportunity.game, 'a', {
|
||||
type: 'card',
|
||||
card: canceledOpportunity.karama!.id,
|
||||
mode: 'cancel',
|
||||
});
|
||||
assert.equal(viewGame(canceled, 'f').stormForecast, null);
|
||||
assert.equal(viewGame(canceled, 'a').stormForecast, null);
|
||||
assert.equal(canceled.stormCard, canceledOpportunity.face);
|
||||
assert.deepEqual(receiptFaces(canceled), []);
|
||||
});
|
||||
|
||||
void test('the actual next-turn reveal publishes one face to every view and survives JSON restoration without replay', () => {
|
||||
const revealed = revealedStorm();
|
||||
assert.deepEqual(receiptFaces(revealed.game), [revealed.face]);
|
||||
for (const player of revealed.game.players)
|
||||
assert.deepEqual(receiptFaces(viewGame(revealed.game, player.id)), [
|
||||
revealed.face,
|
||||
]);
|
||||
|
||||
const restored = reload(revealed.game);
|
||||
assert.deepEqual(receiptFaces(restored), [revealed.face]);
|
||||
viewGame(restored, 'f');
|
||||
viewGame(restored, 'a');
|
||||
assert.deepEqual(receiptFaces(restored), [revealed.face]);
|
||||
|
||||
const legacy = reload(restored);
|
||||
for (const entry of legacy.log) delete entry.component;
|
||||
assert.doesNotThrow(() => viewGame(legacy, 'f'));
|
||||
assert.deepEqual(receiptFaces(legacy), []);
|
||||
});
|
||||
|
||||
void test('Weather Control changes effective movement without rewriting the revealed Storm Card receipt', () => {
|
||||
const revealed = revealedStorm();
|
||||
let game = revealed.game;
|
||||
const weather = baseDeck().find((card) => card.effect === 'weather')!;
|
||||
placeFixtureHand(
|
||||
game,
|
||||
game.players.findIndex((player) => player.id === 'a'),
|
||||
[weather],
|
||||
);
|
||||
const replacement = revealed.face === 6 ? 5 : 6;
|
||||
game = applyAction(game, 'a', {
|
||||
type: 'card',
|
||||
card: weather.id,
|
||||
amount: replacement,
|
||||
});
|
||||
assert.equal(game.stormPending, replacement);
|
||||
assert.equal(game.stormMovementSource?.kind, 'weather');
|
||||
assert.deepEqual(receiptFaces(game), [revealed.face]);
|
||||
assert.deepEqual(receiptFaces(viewGame(game, 'f')), [revealed.face]);
|
||||
});
|
||||
|
||||
void test('Ecological Testing Station changes card movement while retaining the original public face', () => {
|
||||
let game = discoveryStormFixture(true);
|
||||
assert.equal(game.stormPending, 2);
|
||||
assert.deepEqual(receiptFaces(game), [2]);
|
||||
game = confirmDiscoveryStorm(game);
|
||||
assert.equal(game.decision?.kind, 'ecologicalStorm');
|
||||
game = applyAction(game, 'a', {
|
||||
type: 'decision',
|
||||
event: game.ecologicalStorm!.event,
|
||||
delta: 1,
|
||||
});
|
||||
assert.equal(game.storm, 9);
|
||||
assert.equal(game.phase, 1);
|
||||
assert.deepEqual(receiptFaces(game), [2]);
|
||||
for (const player of game.players)
|
||||
assert.deepEqual(receiptFaces(viewGame(game, player.id)), [2]);
|
||||
});
|
||||
Reference in new issue
Block a user