Connect expansion faction setup and saved Ixian auction decline

Add a local faction-only prototype profile for all seven nonempty expansion selections, genuine Basic/Advanced initialization and existing private setup, controls, AI and saved continuation. Ecaz allocates six forces across Imperial Basin sectors and retains fourteen reserves before later setup; preserve Fremen, Advanced BG and Moritani ordering with printed and inferred boundaries explicit. Keep optional Ecaz Treachery cards separate, ordinary deck counts 33/35/47, and Richese cache separate. Public start and publication gates remain closed.

Replace the Advanced Ixian/Richese pre-offer stop with an explicit per-lot Ixian decline. Bind the saved original offer to its round, turn, owner, independent event, exact card custody and terms before view/action/normalization. Decline resumes that unchanged lot once without using Technology; later normal lots still offer the exchange. Protect reserved cards across transfers, spending and random Harkonnen exchange, and preserve legitimate Gift/Truthtrance interruptions. The actual special-lot exchange still awaits replacement-custody interpretation.

Validation: focused setup, rules, UI/bot, private JSON/SQLite reload and concurrent CAS tests and independent custody/persistence review passed. Typecheck, lint, 4573 offline tests, production build and 43 HTTP tests passed. Browser Ecaz setup rejected an incomplete total, saved a 2/2/2 split, and refreshed to fourteen reserves and unchanged private cards. A separate genuine Advanced QA game restored the Ixian choice; explicit decline opened the unchanged cache lot with legal bidding and retained private hand, spice, pool count and unused Technology after refresh.

Five of six genuine four-profile samples finish: CHOAM Basic 1054 actions/28 JSON restores, CHOAM Advanced 225/6, Ecaz Basic 91/2, Ecaz Advanced 523/14, all-three Basic 476/12. Combined Advanced passes three former auction stops and reaches turn three/action614 with 16 restores, then hits an existing CHOAM movement/Ambassador arrival guard. Ecaz samples also include guarded arrival candidates with legal alternatives. Full combined play is not certified; arrival integration is the next gap. Retain initial failed logs, including a corrected private harness sold-card receipt alias.

After the outage, all 597 opening rooms and original seat/recovery records were preserved (632 after QA/HTTP). A stale development module cache required restart after a fresh 598-room backup; all 598 matched afterward and the QA seat restored. Final integrity and foreign keys passed. No database reset, recurring automation or publication.

Verified source tree: 5e2ff244a1167f2834bb46d609b8de914a34f7049ff7b06046243068c0935131
This commit is contained in:
Quintine committed 2026-09-13 22:51:19 +10:00
1 parent f067950f5e
commit cab2f1c42d
26 files changed
+3001 -58

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
'use client';
import { useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import {
ECAZ_START_FORCES,
ECAZ_START_LOCATIONS,
quoteEcazStartingForces,
} from '@/game/ecaz-setup';
import { splitLocation } from '@/game/board';
import { Button } from './ui/button';
import { Input } from './ui/input';
export function EcazSetup({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const [placements, setPlacements] = useState<Record<string, number>>({
[ECAZ_START_LOCATIONS[0]]: ECAZ_START_FORCES,
});
const me = game.players.find((player) => player.id === game.me);
if (
game.status !== 'setup' ||
game.setupStage !== 'forces' ||
me?.faction !== 'ecaz' ||
me.reserves !== 20 ||
!game.setupPending.includes(game.me)
)
return null;
let quoted: Record<string, number> | null = null;
try {
quoted = quoteEcazStartingForces(placements);
} catch {
/* Show the allocation until its total is legal. */
}
const total = Object.values(placements).reduce(
(sum, count) => sum + count,
0,
);
return (
<section aria-label="Ecaz starting forces" className="flex flex-col gap-3">
<p>
Place six forces in Imperial Basin. Choose how many go in each sector;
fourteen forces remain in reserves.
</p>
{ECAZ_START_LOCATIONS.map((location) => (
<label key={location} htmlFor={`ecaz-start-${location}`}>
Imperial Basin · Sector {splitLocation(location).sector}
<Input
id={`ecaz-start-${location}`}
type="number"
min={0}
max={ECAZ_START_FORCES}
step={1}
value={placements[location] ?? 0}
disabled={busy}
onChange={(event) =>
setPlacements({
...placements,
[location]: Number(event.target.value),
})
}
/>
</label>
))}
<p className="fine" aria-live="polite">
{total} / {ECAZ_START_FORCES} forces assigned
</p>
<Button
className="game-action min-h-11"
disabled={busy || !quoted}
onClick={() => {
if (!busy && quoted) act({ type: 'ecazSetup', placements: quoted });
}}
>
Place Ecaz starting forces
</Button>
</section>
);
}
+19 -5
View File
@@ -1,4 +1,6 @@
'use client';
import { EcazSetup } from './ecaz-setup';
import { IxRicheseTechnology } from './ix-richese-technology';
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
import {
matchesShipment,
@@ -206,6 +208,7 @@ export function GameTable({
const turnOwnerName = g.players.find((p) => p.id === turnOwner)?.name;
const setupStage = g.setupStage;
const setupFremen = g.players.find((p) => p.faction === 'fremen');
const setupEcaz = g.players.find((p) => p.faction === 'ecaz');
const setupSisterhood = g.players.find((p) => p.faction === 'beneGesserit');
const setupPendingNames = (g.setupPending ?? [])
.filter((id) => id !== me.id)
@@ -213,6 +216,7 @@ export function GameTable({
.filter(Boolean)
.join(', ');
const waitingForFremen = !!setupFremen && setupFremen.reserves !== 10;
const waitingForEcaz = setupStage === 'forces' && !!setupEcaz && setupEcaz.reserves === 20;
const setupStatus =
setupStage === 'leaderSkills'
? g.leaderSkills?.offer
@@ -235,6 +239,10 @@ export function GameTable({
? 'Place your ten starting forces. Bene Gesserit places its advisor afterward.'
: g.leaderSkills ? 'Place your ten starting forces. Starting cards were already dealt before skill assignment.' : 'Place your ten starting forces. Starting cards are dealt automatically after placement.'
: `Waiting for ${setupFremen.name} to place the Fremen starting forces.`
: waitingForEcaz
? setupEcaz.id === me.id
? 'Place your six starting forces among the sectors of Imperial Basin. Fourteen remain in reserves.'
: `Waiting for ${setupEcaz.name} to place the Ecaz starting forces.`
: g.advanced && setupSisterhood && !setupSisterhood.advisorSetup
? setupSisterhood.id === me.id
? 'Choose a printed board territory and sector for your starting advisor.'
@@ -1643,20 +1651,21 @@ export function GameTable({
: 'Setup order: prediction, traitors, starting forces, then the automatic treachery-card deal and Storm phase.'}
</p>
)}
<EcazSetup game={g} act={act} busy={busy} />
{(!setupStage || setupStage === 'forces') &&
g.advanced &&
me.faction === 'beneGesserit' &&
!me.advisorSetup && (
<>
<p className="muted">
After Fremen placement, choose a territory and sector on
After the other starting force choices, choose a territory and sector on
the map for your starting advisor. It becomes a fighter if
alone.
</p>
{destination}
{waitingForFremen && (
{(waitingForFremen || waitingForEcaz) && (
<p className="fine" id="advisor-setup-wait">
{setupFremen.name} must finish placing the Fremen forces
{waitingForFremen ? setupFremen!.name : setupEcaz!.name} must finish placing starting forces
before you place an advisor.
</p>
)}
@@ -1670,9 +1679,10 @@ export function GameTable({
'Place starting advisor',
{ type: 'advisorSetup', territory: selected, sector },
waitingForFremen ||
waitingForEcaz ||
me.reserves !== 20 ||
selected === MOBILE_STRONGHOLD,
waitingForFremen ? 'advisor-setup-wait' : undefined,
waitingForFremen || waitingForEcaz ? 'advisor-setup-wait' : undefined,
)}
</>
)}
@@ -2124,7 +2134,9 @@ export function GameTable({
<>
<span className="eyebrow">Player decision</span>
<h2>
{g.decision.kind === 'caladanReinforcement'
{g.decision.kind === 'ixRicheseTechnology'
? 'Ixian Technology · Richese lot'
: g.decision.kind === 'caladanReinforcement'
? 'Caladan victory reinforcement'
: g.decision.kind === 'grummanCollection'
? 'Grumman Collection'
@@ -2373,6 +2385,8 @@ export function GameTable({
act={act}
busy={busy}
/>
) : g.decision.kind === 'ixRicheseTechnology' ? (
<IxRicheseTechnology game={g} act={act} busy={busy} />
) : g.decision.kind === 'richeseBlackMarket' ||
g.decision.kind === 'richeseDeclaration' ||
g.decision.kind === 'richeseCache' ||
+51
View File
@@ -0,0 +1,51 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
export function IxRicheseTechnology({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const offer = game.ixRicheseTechnology;
if (
!offer ||
offer.player !== game.me ||
game.decision?.kind !== 'ixRicheseTechnology' ||
game.decision.player !== game.me ||
game.decision.event !== offer.event
)
return null;
return (
<section
className="flex flex-col gap-4"
aria-label="Ixian Technology on a Richese lot"
>
<p>
Richese is offering a{' '}
{offer.source === 'cache' ? 'cache' : 'Black Market'} card. Bidding will
begin after you decline Technology for this lot.
</p>
<p className="notice">{offer.exchangeBlocked}</p>
<p>
Declining leaves your once-per-round Technology use available for later
lots. Your hand and the offered card stay unchanged.
</p>
<Button
className="min-h-11 whitespace-normal"
disabled={busy}
onClick={() => {
if (!busy)
act({ type: 'decision', event: offer.event, decline: true });
}}
>
Decline Technology for this lot
</Button>
</section>
);
}
+13
View File
@@ -4,6 +4,19 @@ Updated 13 September 2026. This is the short navigation dashboard; detailed evid
## Evidence and source of truth
- [Expansion-faction development games](EXPANSION_FACTIONS_PROTOTYPE.md) connect
genuine setup for all seven expansion selections, using existing faction
controls, all four AI profiles and saved continuation. Ecaz now places its six
starting forces in Imperial Basin and keeps fourteen in reserve. The optional
three-card Ecaz variant remains separate and unfinished. Advanced Ixians have
an explicit saved per-lot decline for Richese offers; the actual exchange still
awaits its replacement-custody ruling. Complete-game samples
finish five of six games; combined Advanced reaches turn three, then stops
on the existing CHOAM movement/Ambassador arrival guard. That interaction is
the next integration gap. Setup evidence does not open public starts or
establish full faction compliance. Saved-game checks passed after a necessary
restart for a stale server module cache.
- [Diplomat defense copying](DIPLOMAT_DEFENSE.md) connects one committed Worthless
card to an opposing base Shield or Snooper, with optional controls, all four AI
profiles, native trained-leader self-protection and mandatory discard. Saved
+3
View File
@@ -11,6 +11,9 @@ The [three-milestone pilot](EFFICIENCY_PILOT.md) records measurement limits.
Use the [Ix prototype workflow](IX_PROTOTYPE.md) to connect a fresh local ready
lobby to genuine expansion setup with a private backup and preserved sessions.
It does not open normal mode gates or require restarting the server.
The [expansion-faction profile](EXPANSION_FACTIONS_PROTOTYPE.md) extends genuine
setup to selected faction sets without optional modules, including Ecaz starting
placement. Remaining combined-play guards still apply.
## Architecture map
+228
View File
@@ -0,0 +1,228 @@
# Expansion-faction development profile
13 September 2026. This checkpoint connects genuine setup for any nonempty
selection of the Ixians & Tleilaxu, CHOAM & Richese, and Ecaz & Moritani
expansions. It is a local development profile for faction play. Optional
variants, normal public expansion starts, and publication remain gated.
## Source and deck boundary
The source audit used the archived publisher-authored rulebooks at
`/home/quintine/.local/share/dune/sources/ecaz-moritani.pdf` and
`/home/quintine/.local/share/dune/sources/choam-richese-rulebook-en.pdf`, with
their extracted text. A targeted official web check reproduced the CHOAM &
Richese component text from GF9's index. The current Ecaz & Moritani PDF URL
returned HTTP 403, so the findings below use the archived publisher file and
retain its public URL rather than claiming a fresh download. No publisher or
designer clarification of Ecaz's starting-sector allocation was found in the
bounded follow-up search.
The Ecaz & Moritani rulebook calls Homeworlds, Nexus Cards, New Treachery
Cards, and Discovery Tokens variants that may be added together or separately,
regardless of the factions selected. Choosing Ecaz or Moritani therefore does
not add Recruits, Reinforcements, or Harass & Withdraw. Those three cards remain
an independent, unimplemented variant. [GF9 Ecaz & Moritani, printed
pp. 4, 9, 11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=9)
The CHOAM & Richese rulebook says its Poison Tooth and Artillery Strike replace
the versions from Ixians & Tleilaxu, or are added when that expansion is absent.
Its two updated Karama cards replace the two core cards. These are physical
replacements, so they do not duplicate identities or increase the combined Ix
deck. [GF9 CHOAM & Richese, printed
p. 4](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=4)
| Selected expansion sets | Ordinary Treachery Deck |
| --- | ---: |
| Ecaz & Moritani only | 33 |
| CHOAM & Richese only | 35 |
| Ixians & Tleilaxu only | 47 |
| Ixians & Tleilaxu plus either or both later sets | 47 |
| CHOAM & Richese plus Ecaz & Moritani, without Ix | 35 |
Richese's ten technology cards form a separate faction cache and do not count
toward the ordinary deck or the Richese hand at setup. When Richese is seated,
the setup creates that cache and its three concealed No-Field tokens with
values zero, three, and five. [GF9 CHOAM & Richese, printed
pp. 5–6](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=5)
The ordinary Spice Deck gains Sandtrout when Ixians & Tleilaxu is selected.
The seven Ecaz & Moritani Spice Cards belong to the separate Discovery Tokens
variant and are absent from this profile.
## Connected setup
`initializeFactionExpansionsGameForAudit` accepts an already constructed Basic
or Advanced lobby, two through six distinct ready players, and any roster whose
faction sets are among the selected nonempty expansion list. It uses the shared
prediction, traitor, force-placement, starting-card, and first-Storm pipeline.
It rejects an already dealt or modified lobby and does not add optional modules.
The ordinary browser lobby still creates Basic games only; Advanced profile
setup is currently an offline development/test path rather than a visible mode
selection.
Faction-specific setup uses the existing physical inventories and decisions:
- Ixians place three cyborgs and three suboids in the unplaced Hidden Mobile
Stronghold, then make their private starting-card choice. Tleilaxu receives
three Face Dancers after the other factions finish traitor selection.
- Advanced CHOAM receives the additional Auditor disc before traitors are
dealt. Richese receives its separate cache and concealed No-Field inventory.
- Ecaz receives its Ecaz Ambassador plus five random non-Ecaz Ambassadors and
allocates six starting forces among the three printed sectors of Imperial
Basin, leaving fourteen in reserve. The printed constraint specifies the
territory and total, without a one-stack restriction or a separate setup
sector procedure. The prototype therefore accepts any nonnegative allocation
totaling six within sectors 9, 10, and 11. This is the implementation's
literal territory-level reading, not a separate publisher ruling about
sector allocation; a targeted publisher/designer search found no more
specific clarification.
- Moritani receives six concealed Terror tokens and places six forces in an
unoccupied territory after every other faction completes setup, leaving
fourteen in reserve. Ecaz or Moritani also creates the one shared Duke Vidal
disc. Moritani-last is explicit on printed p. 5. The Ecaz setup on printed
p. 7 states its location and inventory but no relative priority. The
Fremen–Ecaz–Advanced-Bene-Gesserit order is therefore engine sequencing
needed to resolve those choices, not a claimed publisher priority among the
three factions.
These setup values come from the faction sections of the
[GF9 Ecaz & Moritani rulebook, printed pp. 5 and
7](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=5)
and the [GF9 CHOAM & Richese rulebook, printed pp. 5 and
7](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=5).
The earlier [Ix prototype](IX_PROTOTYPE.md) records its setup and 47-card
inventory evidence.
## Start a local faction prototype
Create a new local Basic room with the intended expansion sets and factions,
then mark every seat ready. Preserve the room cookie and current lobby version.
Start only that fresh lobby with:
```sh
node --import tsx tools/start-prototype.ts \
--profile factions \
--db PATH_TO_LOCAL_D1_SQLITE \
--room EIGHT_CHARACTER_CODE \
--version CURRENT_LOBBY_VERSION \
--out /private/new-prototype-checkpoint
```
The output directory must be new and outside the repository. The tool first
backs up all saved rooms, then uses an atomic state-and-version comparison to
write the selected lobby. It preserves sessions, recovery records, other rooms,
and the public start gate. Refresh the existing invitation to continue through
the ordinary private setup controls. Reusing the command on the started game is
rejected.
## Known incomplete play
This entry point makes the faction setup real and reviewable; it does not mark
the expansion rules complete. The three optional Ecaz Treachery Cards have
metadata and source guides but no runtime. Richese's cache and auction lifecycle
are present, but cache custody does not establish complete runtime or combined
interaction coverage for every technology card. Ecaz, Moritani, CHOAM, Richese,
Ixian, and Tleilaxu powers retain narrower supported and pending contracts in
the current in-game reference and their feature documents. In particular, see
the [Ecaz Treachery audit](ECAZ_TREACHERY_RULES.md),
[Ecaz Ambassadors](ECAZ_AMBASSADORS_RULES.md),
[Moritani Terror](MORITANI_TERROR_RULES.md),
[Richese implementation plan](RICHESE_IMPLEMENTATION_PLAN.md),
[CHOAM remaining rules](CHOAM_REMAINING_RULES.md), and
[Ix prototype](IX_PROTOTYPE.md). Those records and later feature-specific
runtime documents govern over older inventory snapshots. When an earlier
source/readiness audit says that a component or action was absent, the later
dated runtime document overrides that historical statement only for the slice
it actually connects.
Advanced Ixian Technology on a Richese cache or Black Market lot has a specific
combined-source gap: the rules do not assign the substituted card's seller,
sale income, or zero-bid destination. The development profile therefore does
not implement that exchange. A valid Richese cache or Black Market declaration
first creates a saved `ixRicheseTechnology` decision for Ixians, before the lot
opens, bidding begins, or Atreides receives an inspection. The only supported
answer is an explicit `decline: true` with the exact current event. It then
opens the originally declared lot without using Technology, moving either card,
or marking the once-per-turn power used. Technology remains available for a
later Richese or supported normal lot. The saved declaration binds the round,
turn, source, card custody, auction method and any direction or claim; public
views do not expose a concealed Black Market card through this pause.
This continuation prevents the known combined-game deadlock while preserving
the unresolved exchange. It is not a ruling that Technology cannot affect a
Richese lot, and it does not establish substitute ownership, sale income or
zero-bid custody. See the [Richese auction contract](RICHESE_AUCTION_RULES.md).
No Homeworld, Nexus, Leader Skill, Discovery, Tech Token, or Advanced
Stronghold Card state is admitted by this profile. Selecting expansion factions
does not imply any of those modules, and this checkpoint supplies no evidence
for their combinations.
`tests/expansion-factions-prototype.test.ts` covers every nonempty expansion
selection in Basic and Advanced at all four AI profiles, two-through-six-player
rosters, private views, deck and component custody, JSON continuation, stale
actions, and dirty lobby rejection. It includes a direct 2/2/2 Ecaz allocation,
the Fremen–Ecaz–Advanced-Bene-Gesserit sequence, Moritani's final placement,
and rejection of missing or forged placement state.
`tests/ecaz-setup-controls.test.tsx` checks the human allocation control and
bot path. `tests/expansion-factions-recovery.test.ts` exercises the mixed
six-faction setup through the migrated SQLite room API across reloads, plus
optimistic concurrency, unrelated room/session preservation, and repeated-start
rejection. The focused union reported 74 passing tests and typecheck passed;
the final checkpoint report records the required broad checks. Browser setup
and refresh restored the owned Ecaz allocation and private cards. This bounded setup evidence establishes a development path, not
complete games with every printed effect or public mode acceptance.
## Integrated sample games and browser evidence
Six deterministic samples used genuine faction setup, all four AI levels,
unchanged physical inventories and JSON continuations every 37 accepted actions.
No pieces were staged or phases skipped in these samples.
| Profile | Accepted actions | JSON continuations | Result notes |
| --- | ---: | ---: | --- |
| CHOAM & Richese, Basic | 1,054 | 28 | Finished at turn ten with no rejected candidates. |
| CHOAM & Richese, Advanced | 225 | 6 | Finished at turn two with no rejected candidates. |
| Ecaz & Moritani, Basic | 91 | 2 | Finished at turn one after four guarded Ambassador candidates were rejected and legal alternatives were used. |
| Ecaz & Moritani, Advanced | 523 | 14 | Finished at turn five after 32 guarded arrival candidates were rejected and legal alternatives were used. |
| All three expansion sets, Basic | 476 | 12 | Finished at turn two with no rejected candidates. |
| All three expansion sets, Advanced | 614 | 16 | Stopped at turn three on an existing CHOAM movement/Ambassador arrival guard. |
The first combined Advanced run stopped at action 70 on the old Ixian/Richese
special-lot guard. With the decline continuation, the same seed declined three
Richese lots and reached eleven ordinary Technology decisions before the separate
arrival stop. This is evidence of the connected auction path, not a completed
combined Advanced game. The current movement stop and rejected arrival candidates
remain integration work; public modes stay gated.
An initial CHOAM Basic custody audit mistakenly counted the already sold current
normal-auction card both in its winner's hand and in its sale receipt. Correcting
that private harness alias accounting allowed the full sample to run. The first
failure logs were retained; production card ownership was not changed to fit the
harness.
Browser room `WEBF27AU` used genuine six-expansion-faction setup. The human Ecaz
seat chose its private traitor, saw an incomplete four-force allocation rejected,
submitted two forces in each Imperial Basin sector, and refreshed to fourteen
reserves, twelve spice, its dealt Crysknife and unchanged traitor at opening Storm.
Ixian private dealing and Moritani's final placement completed in the same game.
A separate fresh browser lobby `BMHNSASJ` was configured for Advanced by the local
QA helper before genuine initialization. Thirty-seven accepted bot-selected
setup and phase actions reached the Ixian cache-offer decision; no pieces were
staged and no phases skipped. Refresh restored the human Ixian choice, clicking
its explicit decline opened the unchanged Ornithopter Once Around lot, and another
refresh retained the legal bid controls, private Harvester and traitor. The saved
hand, spice, offered card, ordinary pool count and unused Technology all matched
the pending-offer snapshot. This developer configuration does not add a public
Advanced lobby selector.
The outage recovery audit preserved all 597 opening rooms and their seat/recovery
records. A stale development-server module cache caused room requests to fail
although the home page responded. A fresh 598-room backup preceded a necessary
restart; all 598 rooms matched immediately afterward and the private QA seat
restored. No database reset or recurring maintenance automation was used. Final
room totals, required checks and source identity are recorded in the checkpoint
commit and private reports under
`/home/quintine/.local/share/dune/checkpoints/20260913-expansion-factions/`.
+36
View File
@@ -1,5 +1,41 @@
# Dune implementation status
## 13 September 2026 — Expansion faction setup and saved Ixian auction decline
The [faction development profile](EXPANSION_FACTIONS_PROTOTYPE.md) connects
all seven nonempty expansion selections to genuine Basic/Advanced setup and
existing faction controls, AI and saved continuation. Ecaz now allocates six
starting forces within Imperial Basin, keeping fourteen in reserve; Fremen,
Advanced Bene Gesserit and Moritani placement ordering is preserved with its
printed and inferred boundaries documented. The optional Ecaz Treachery variant
stays separate, ordinary decks contain 33/35/47 cards, and Richese keeps its
separate ten-card cache. Public starts remain gated.
Combined play exposed an existing Ixian Technology/Richese special-lot deadlock.
A saved explicit per-lot decline now opens the original cache or Black Market lot
without using Technology, changing its card custody or revealing a concealed
card. Exact event, round, declaration and custody validation protects view,
normalization and actions, including Gift/Truthtrance interruptions. Competing
uses of the reserved offered card are rejected before creating responses.
The actual exchange still needs its recorded replacement-custody interpretation.
Focused setup, controls, legal AI, private JSON/SQLite continuation and concurrent
write checks pass, with independent review of rules, custody and persistence.
Five of six genuine mixed-profile samples finish. Combined Advanced progresses
past three special-lot declines to turn three, then stops on an existing CHOAM
movement/Ambassador arrival guard. Guarded arrival candidates also occur in the
Ecaz samples. These are explicit remaining integration limits, not full expansion
acceptance. The detailed feature document retains exact sample metrics.
The browser verified genuine Ecaz traitor/force setup and refresh, then a separate
Advanced development game's saved Ixian decline, unchanged offered card, legal
bidding and private hand restoration. After the reported outage, all 597 opening
games and original seat/recovery records matched the backup. A stale server module
cache required a restart after a fresh 598-room backup; every room matched before
QA resumed. Required broad checks, final preservation totals and source fingerprint
are recorded in the checkpoint commit and private report. No database reset,
recurring automation or publication was performed.
## 13 September 2026 — Diplomat base-defense copying prototype
[Diplomat](DIPLOMAT_DEFENSE.md) now optionally counts one committed Worthless as
+32
View File
@@ -149,3 +149,35 @@ Root’s global run passed 1,106 rules and 81 recovery tests, production build,
Desktop QA confirmed all ten readable enlarged guides and working direct catalog topic links. Room `63NJ822S` completed first-position declaration, cache offer, Once Around self-purchase for 3 and automatic payment, leaving 17 spice, one card and two normal lots. A Silent bid of 4 remained private; fresh browser tab 23 restored two-of-three submitted status and the viewer’s own bid of 4. A final synthetic zero submission produced the storm-order tie winner Harkonnen, paid 4 and awarded its bonus, with Richese at 24 spice. Root records the exact screenshots and synthetic/manual distinction in `docs/VISUAL_PLAYTEST.md`.
Remaining boundaries are unchanged: cache-cancellation count, empty-cache arithmetic, seller-positive Black Market bidding, advanced Ixian Technology special-lot custody, remaining card effects and actual Juice of Sapho play. Full starts remain gated. The hand’s disabled-effect fix and pre-offer guard explanation are implemented. The final browser recheck in QA room63NJ822S version9 confirmed the printed category, disabled unsupported Play card control, visible reason and available inspector. This is bounded desktop/persistence evidence, not mobile QA, a full Richese game, every advanced interaction or durable hosted-worker certification.
## 13 September: saved per-lot Technology decline
Genuine combined Advanced faction play exposed the previous pre-offer guard as
a deadlock when Ixians retained their optional Technology use. A validated Richese
cache or Black Market offer now creates a saved decision for Ixians before opening
that lot. The development continuation supports an explicit **decline for this lot**;
it does not implement a card exchange or adjudicate the pending seller, income or
unsold-card questions described above.
The exact offered card stays in its original hand or cache. The pending receipt
binds the current round, turn, offer terms, owner, independent event and physical
custody. The public decision contains no card identity; the owner-facing control
explains the unfinished exchange and uses the matching event. Declining opens the
same lot once, preserves the normal pool count, and leaves `ixTechnologyTurn`
unchanged. Ixians can still use Technology on a later normal lot or separately
decline another Richese lot. All four AI profiles have this legal continuation.
This opt-out is the ordinary exercise of an optional ability, not a publisher
prohibition on exchanging special lots. The pending offer is kept private until
the normal lot-opening point; this saved staging order is an implementation
boundary rather than a separate official timing clarification. Public starts and
full combined compliance remain gated.
The focused engine and UI tests are
`tests/ix-richese-technology.test.ts`,
`tests/ix-richese-technology-controls.test.tsx`, and
`tests/ix-richese-technology-recovery.test.ts`. Final checkpoint verification is
recorded in the implementation log and source-bound private report. Historical
absence claims about ordinary Richese technology cards above are superseded by
their later runtime contracts, including [Juice of Sapho](JUICE_OF_SAPHO_RUNTIME.md).
+2
View File
@@ -6,6 +6,8 @@ Updated 13 September 2026. This is the authoritative navigation index for existi
| Topic | Decision to preserve | Detailed authority and evidence |
| --- | --- | --- |
| Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) |
| Ixian Technology on Richese lots | Replacement-card ownership, income and unsold disposition remain unresolved. A saved explicit per-lot decline permits an unchanged offer to proceed and leaves Technology available later. This development continuation does not implement the exchange or settle its custody. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline) |
| Spice Banker battle spending | Secret own-spice commitment is separate from support; only the surviving selected disc gains strength. Core Battle Plan payment rules spend it on death/loss and waive it for a sole traitor-call winner. This payment composition is explicit; upper deferred-income custody remains pending. | [Source contract and prototype](SPICE_BANKER_RUNTIME.md) |
| Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) |
| Sandmaster movement | One optional board spice per distinct entered territory on an explicit legal route; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing; multiple-pile collection remains guarded. | [Source and connected prototype](SANDMASTER_MOVEMENT.md) |
+6
View File
@@ -9,6 +9,7 @@ import { greatMakerBotActions } from './great-maker-options';
import { nexusChoamTradeBotActions } from './nexus-choam-trade-options';
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildHajrAction, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from './nexus-guild-cunning-options';
import { nexusMoritaniBotActions } from './nexus-moritani-options';
import { ECAZ_START_FORCES, ECAZ_START_LOCATIONS, quoteEcazStartingForces } from './ecaz-setup';
import { choamPowerAction, choamPowerBotPlay } from './choam-power-options';
import { nexusCardBotActions } from './nexus-card-options';
import { nexusTraitorBotActions } from './nexus-traitor-options';
@@ -1190,12 +1191,15 @@ function policyActions(g: GameView): Action[] {
},
},
];
if (placingForces && me.faction === 'ecaz' && me.reserves === 20 && g.setupStage === 'forces' && g.setupPending.includes(me.id))
return [{ type: 'ecazSetup', placements: quoteEcazStartingForces({ [ECAZ_START_LOCATIONS[0]]: ECAZ_START_FORCES }) }];
if (
placingForces &&
g.advanced &&
me.faction === 'beneGesserit' &&
!me.advisorSetup &&
me.reserves === 20 &&
(!g.setupStage || g.setupPending.includes(me.id)) &&
g.players.every((p) => p.faction !== 'fremen' || p.reserves === 10)
)
return destinations(g, true)
@@ -1658,6 +1662,8 @@ function policyActions(g: GameView): Action[] {
const card = cards.find((c) => c.kind === 'worthless') ?? cards[0];
return card ? [{ type: 'decision', card: card.id }] : [];
}
if (d.kind === 'ixRicheseTechnology')
return [{ type: 'decision', event: d.event, decline: true }];
if (d.kind === 'richeseBlackMarket')
return [
{ type: 'decision', event: g.richeseBidding!.event, decline: true },
+3 -1
View File
@@ -62,8 +62,10 @@ export function choamDeck(): Card[] {
}
/** Deck assembly does not open the separate faction/module release gates. */
export function treacheryDeck(expansions: readonly string[] = []): Card[] {
if (expansions.some((id) => id !== 'ix' && id !== 'choam'))
if (expansions.some((id) => !['ix', 'choam', 'ecaz'].includes(id)))
throw new Error('This expansion treachery deck is not implemented yet.');
// Ecaz/Moritani factions are independent of the optional three-card variant.
// Selecting those factions does not activate its still-unimplemented effects.
return [...baseDeck(), ...(expansions.includes('ix') ? ixDeck() : expansions.includes('choam') ? choamDeck() : [])];
}
export type Leader = {
+44
View File
@@ -0,0 +1,44 @@
import { location, territory } from './board';
export const ECAZ_START_FORCES = 6;
export const ECAZ_START_LOCATIONS = Object.freeze(
territory('imperial_basin').sectors.map((sector) =>
location('imperial_basin', sector),
),
);
/** The printed territory and total constrain the allocation; return detached positive groups. */
export function quoteEcazStartingForces(
placements: unknown,
): Record<string, number> {
if (
!placements ||
typeof placements !== 'object' ||
Array.isArray(placements)
)
throw new Error(
'Choose the Imperial Basin sectors for six Ecaz starting forces.',
);
const entries = Object.entries(placements);
if (
!entries.every(
([key, amount]) =>
ECAZ_START_LOCATIONS.includes(key) &&
typeof amount === 'number' &&
Number.isSafeInteger(amount) &&
amount >= 0 &&
amount <= ECAZ_START_FORCES,
)
)
throw new Error(
'Ecaz starting forces require whole nonnegative amounts in explicit Imperial Basin sectors.',
);
if (
entries.reduce((total, [, amount]) => total + amount, 0) !==
ECAZ_START_FORCES
)
throw new Error(
'Place exactly six Ecaz starting forces in Imperial Basin.',
);
return Object.fromEntries(entries.filter(([, amount]) => amount > 0));
}
+169 -10
View File
@@ -15,6 +15,7 @@ import { quoteSmugglerShipment, type SmugglerShipment } from './smuggler-shipmen
import { quoteSandmasterMovement, validateSandmasterMovement, type SandmasterMovement, type SandmasterOrder } from './sandmaster-movement';
import { spiceBankerModeSupported, validateSpiceBankerSpend } from './spice-banker';
import { quoteDiplomatDefense, diplomatDefenseModeSupported, type DiplomatDefenseQuote } from './diplomat-defense';
import { ECAZ_START_FORCES, quoteEcazStartingForces } from './ecaz-setup';
import { discoveryChoices, discoveryStashSignature, type DiscoveryStash } from './discovery-actions';
import { createStormSource, validateStormSource, discoveryStormOffer, createDiscoveryStorm, chooseDiscoveryStorm, validateDiscoveryStorm, finishDiscoveryStorm, type StormMovementSource, type DiscoveryStorm } from './discovery-storm';
import { completeDiscoveryFlight, discoveryFlightOffer, quoteDiscoveryFlight, validateDiscoveryFlight, type DiscoveryFlightReceipt } from './discovery-flight';
@@ -710,6 +711,7 @@ export type Decision =
| { kind: 'ixSetup'; player: string }
| { kind: 'ixAuction'; player: string }
| { kind: 'ixTechnology'; player: string }
| { kind: 'ixRicheseTechnology'; player: string; event: string; source: 'cache' | 'blackMarket' }
| { kind: 'ixAllyCard'; player: string }
| { kind: 'mobileStronghold'; player: string; placement: boolean }
| {
@@ -1124,6 +1126,22 @@ export type Game = {
richesePeekKnown?: boolean;
richeseOfferedCard?: Card | null;
richeseClaim?: string | null;
/** Private pre-lot offer. The public Ixian choice contains no Black Market card identity. */
pendingIxRicheseTechnology?: {
event: string;
player: string;
owner: string;
round: string;
turn: number;
source: 'cache' | 'blackMarket';
card: Card;
method: RicheseAuction['method'];
direction: 'clockwise' | 'counterclockwise' | null;
claim: string | null;
frame: string;
signature: string;
};
ixRicheseTechnologyEvent?: string;
currentAuctionSale?: {
winner: string;
amount: number;
@@ -2870,6 +2888,8 @@ function savedTransferResponses(g: Game) {
];
}
function transferCardBlock(g: Game, owner: Player, card: Card) {
if (g.pendingIxRicheseTechnology?.owner === owner.id && g.pendingIxRicheseTechnology.card.id === card.id)
return 'This card is reserved for the pending Richese lot and Ixian choice.';
if (isPortableSnooper(card) && homeworldSavedDecisions(g).some((d) => d.kind === 'homeworldDefense' && d.player === owner.id))
return 'Resolve the Homeworld late-defense choice before transferring its Portable Snooper.';
@@ -3322,6 +3342,8 @@ function draw(g: Game) {
return g.deck.shift();
}
function discard(g: Game, p: Player, id: string, role?: Omit<EcazPoisonDiscard, 'card'>) {
requireRule(g.pendingIxRicheseTechnology?.card.id !== id,
'The card reserved for the pending Richese lot cannot be discarded.');
requireRule(
!g.pendingTreacheryDiscard,
'Finish the committed discard continuation before another discard.',
@@ -5290,6 +5312,8 @@ function setupPending(g: Game): string[] {
.map((p) => p.id);
const fremen = byFaction(g, 'fremen');
if (fremen?.reserves === 20) return [fremen.id];
const ecaz = byFaction(g, 'ecaz');
if (ecaz?.reserves === 20) return [ecaz.id];
const bg = byFaction(g, 'beneGesserit');
return g.advanced && bg && !bg.advisorSetup ? [bg.id] : [];
}
@@ -5371,6 +5395,33 @@ export function initializeIxGameForAudit(state: Game): Game {
'The Ix prototype requires exactly the Ixians & Tleilaxu expansion.');
return initializeSetupGameForAudit(state, false, false, true);
}
/** Genuine faction-only development setup. Optional variants and public starts remain gated. */
export function initializeFactionExpansionsGameForAudit(state: Game): Game {
requireRule(typeof state.advanced === 'boolean', 'Choose Basic or Advanced rules for the faction prototype.');
requireRule(Array.isArray(state.expansions) && state.expansions.length > 0 &&
state.expansions.every(id => ['ix', 'choam', 'ecaz'].includes(id)) &&
new Set(state.expansions).size === state.expansions.length,
'The faction prototype needs a nonempty selection of distinct known expansions.');
requireRule(!state.homeworlds && !state.nexusCards && !state.leaderSkills &&
!state.discoveryEnabled && !state.discoveries && !state.discoveryStash && !state.greatMaker &&
!state.techTokens && !state.strongholdCards,
'The faction prototype excludes optional modules, including Leader Skills and Discoveries.');
requireRule(state.richeseCache === undefined && state.richeseRemoved === undefined &&
state.ecazAmbassadors === undefined && state.moritaniTerror === undefined && state.dukeVidal === undefined &&
state.mobileStronghold === undefined && state.ixSetupCards === undefined &&
state.players.every(p => {
const original = leaders(p.faction);
return p.noField === undefined && p.noFieldEvent === undefined && p.faceDancers === undefined &&
p.revived === 0 && p.freeForcesRevived === 0 && !p.leaderRevived && p.revivalCycle === 0 &&
p.battleLosses === 0 && p.ally === null && p.bribes === 0 &&
p.leaders.length === original.length && original.every(expected => {
const actual = p.leaders.find(l => l.id === expected.id);
return actual && Object.keys(actual).length === Object.keys(expected).length &&
Object.entries(expected).every(([key, value]) => actual[key as keyof Leader] === value);
});
}), 'The faction initializer cannot overwrite existing inventories, leader custody or revival history.');
return initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
}
/** Prototype-only Discovery setup. Public starts stay gated while remaining effects are connected. */
export function initializeDiscoveryGameForAudit(state: Game): Game {
requireRule(state.discoveryEnabled === true && !state.discoveries && !state.discoveryStash && !state.greatMaker,
@@ -5384,7 +5435,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
g.leaderSkills = createLeaderSkills(random);
return initializeSetupGameForAudit(g, false, false, false, false, true, g.expansions.length === 1 && g.expansions[0] === 'choam');
}
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false): Game {
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false): Game {
nexusCardsIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -5406,8 +5457,9 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
'The audit initializer requires two through six distinct ready players and an existing host.',
);
requireRule(
(homeworlds || nexus || ix || choam || g.expansions.length === 0) &&
(choam || !g.expansions.includes('choam')) &&
(homeworlds || nexus || ix || choam || factions || g.expansions.length === 0) &&
(choam || factions || !g.expansions.includes('choam')) &&
(factions || !g.expansions.includes('ecaz')) &&
(leaderSkills || !g.leaderSkills) &&
(discovery || !g.discoveryEnabled) &&
(nexus || !g.nexusCards) &&
@@ -5416,10 +5468,12 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
(homeworlds || !g.homeworlds) &&
g.players.every((p) =>
FACTIONS.some(
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam')),
(f) => f.id === p.faction && (homeworlds || nexus || f.expansion === 'base' || (ix && f.expansion === 'ix') || (choam && f.expansion === 'choam') || (factions && g.expansions.includes(f.expansion))),
),
),
choam
factions
? 'The faction prototype supports base factions and the selected expansion factions without optional modules.'
: choam
? 'The Leader Skills prototype supports base, CHOAM and Richese factions without other expansions or optional modules.'
: ix
? 'The Ix prototype supports base, Ixian and Tleilaxu factions without optional modules.'
@@ -5451,7 +5505,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
!p.prediction &&
!p.advisorSetup &&
(!p.elites ||
((homeworlds || nexus || ix) &&
((homeworlds || nexus || ix || factions) &&
p.faction === 'ixians' &&
p.elites.reserves === 7 &&
p.elites.tanks === 0 &&
@@ -5470,6 +5524,10 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
initializeSetup(g);
return normalizeAutomaticGame(g);
}
function ecazStartingForcesComplete(p: Player): boolean {
if (p.reserves !== 20 - ECAZ_START_FORCES) return false;
try { quoteEcazStartingForces(p.forces); return true; } catch { return false; }
}
function otherSetupComplete(g: Game) {
return (
!g.setupStage &&
@@ -5478,6 +5536,7 @@ function otherSetupComplete(g: Game) {
(p) =>
(p.faction === 'tleilaxu' || p.traitors.length > 0) &&
(p.faction !== 'fremen' || p.reserves === 10) &&
(p.faction !== 'ecaz' || ecazStartingForcesComplete(p)) &&
(p.faction !== 'beneGesserit' ||
(p.prediction && (!g.advanced || p.advisorSetup))),
)
@@ -6528,10 +6587,47 @@ function richeseOfferBlock(g: Game): string | null {
? 'Ixian Technology on Richese lots is awaiting a ruling on replacement-card custody.'
: null;
}
function ixRicheseTechnologyFrame(g: Game): string {
return JSON.stringify({ round: g.richeseBidding, order: g.order, positions: normalizedPlayerPositions(g) });
}
function ixRicheseTechnologySignature(pending: NonNullable<Game['pendingIxRicheseTechnology']>): string {
return JSON.stringify({ ...pending, signature: undefined });
}
function ixRicheseTechnologyIntegrity(g: Game) {
const pending = g.pendingIxRicheseTechnology;
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'ixRicheseTechnology');
if (pending === undefined && g.ixRicheseTechnologyEvent === undefined && !decisions.length) return;
const round = g.richeseBidding, ixians = byFaction(g, 'ixians'), owner = byFaction(g, 'richese');
requireRule(pending && g.status === 'playing' && g.phase === 3 && g.advanced &&
ixians && g.ixTechnologyTurn !== g.turn && owner && round &&
pending.player === ixians.id && pending.owner === owner.id && pending.round === round.event &&
pending.turn === g.turn && round.turn === g.turn && round.owner === owner.id &&
typeof pending.event === 'string' && pending.event.length > 0 && pending.event === g.ixRicheseTechnologyEvent &&
['cache', 'blackMarket'].includes(pending.source) &&
round.stage === (pending.source === 'cache' ? 'cacheOffer' : 'blackMarketOffer') &&
!g.auction && !g.richeseAuction && !g.ixAuction && !g.currentAuctionSale && !g.richeseOfferedCard &&
pending.frame === ixRicheseTechnologyFrame(g) && pending.signature === ixRicheseTechnologySignature(pending),
'The saved Ixian Richese-lot choice lost or changed its original offer, round or event.');
requireRule(['normal', 'onceAround', 'silent'].includes(pending.method) &&
(pending.source !== 'cache' || pending.method !== 'normal') &&
(pending.direction === null || ['clockwise', 'counterclockwise'].includes(pending.direction)) &&
(pending.method !== 'onceAround' || pending.direction !== null) &&
(pending.source === 'cache' ? pending.claim === null : pending.claim === null ||
typeof pending.claim === 'string' && pending.claim === pending.claim.trim() && pending.claim.length <= 300),
'The saved Ixian Richese-lot choice has invalid auction terms.');
const held = (pending.source === 'cache' ? g.richeseCache : owner.hand)?.filter(c => c.id === pending.card?.id);
requireRule(held?.length === 1 && JSON.stringify(held[0]) === JSON.stringify(pending.card) &&
physicalTreacheryCards(g).filter(c => c.id === pending.card.id).length === 1,
'The pending Richese offer must retain its exact unique card in the original custody.');
requireRule(decisions.length === 1 && decisions[0].player === pending.player && decisions[0].event === pending.event &&
decisions[0].source === pending.source && Object.keys(decisions[0]).every(key => ['kind', 'player', 'event', 'source'].includes(key)),
'The saved Ixian Richese-lot choice lost or duplicated its original decision.');
}
function beginRicheseLot(
g: Game,
source: 'cache' | 'blackMarket',
action: Action,
technologyDeclined = false,
) {
const round = g.richeseBidding!,
owner = getPlayer(g, round.owner);
@@ -6558,8 +6654,25 @@ function beginRicheseLot(
action.method !== 'onceAround' || action.direction !== undefined,
'Choose a physical bidding direction.',
);
const blocked = richeseOfferBlock(g);
requireRule(!blocked, blocked ?? 'This Richese lot is unavailable.');
if (richeseOfferBlock(g) && !technologyDeclined) {
requireRule(!g.pendingIxRicheseTechnology && g.ixRicheseTechnologyEvent === undefined,
'Finish the existing Ixian choice before declaring another Richese offer.');
const event = crypto.randomUUID(), player = byFaction(g, 'ixians')!.id;
const pending: NonNullable<Game['pendingIxRicheseTechnology']> = {
event, player, owner: owner.id, round: round.event, turn: g.turn, source, card: structuredClone(card),
method: action.method as RicheseAuction['method'],
direction: (action.direction as 'clockwise' | 'counterclockwise' | undefined) ?? null,
claim: source === 'blackMarket' && typeof action.claim === 'string' ? action.claim.trim().slice(0, 300) : null,
frame: ixRicheseTechnologyFrame(g), signature: '',
};
pending.signature = ixRicheseTechnologySignature(pending);
g.pendingIxRicheseTechnology = pending;
g.ixRicheseTechnologyEvent = event;
g.decision = { kind: 'ixRicheseTechnology', player, event, source };
g.active = player;
log(g, `${getPlayer(g, player).name} must choose whether to continue without exchanging this Richese ${source === 'cache' ? 'cache' : 'Black Market'} lot. The exchange is not yet implemented; Technology remains available for later lots.`);
return;
}
const positions = normalizedPlayerPositions(g);
let order = [...g.order];
if (action.method === 'onceAround') {
@@ -7172,6 +7285,8 @@ function nextAuction(g: Game) {
}
/** Physical custody only: promise feasibility remains conservatively checked at commit. */
function karamaSpendingBlock(g: Game, p: Player, card: Card): string | null {
if (g.pendingIxRicheseTechnology?.card.id === card.id)
return 'This card is reserved for the pending Richese lot and Ixian choice.';
if (g.battle?.lateDefense?.[p.id] === card.id)
return 'This Portable Snooper is already played and reserved for battle cleanup.';
if (giftReserved(g, p.id, card.id))
@@ -18533,6 +18648,8 @@ export function prepareSpecialKaramaIntent(
'Resolve the previous purchase income first.',
);
const acquire = stringField(action.acquire);
requireRule(g.pendingIxRicheseTechnology?.card.id !== acquire,
'The card reserved for the pending Richese lot cannot be acquired by another effect.');
const chosen = g.richeseCache?.find((c) => c.id === acquire);
requireRule(
chosen && richeseCardDefinition(chosen),
@@ -18724,6 +18841,11 @@ export function prepareSpecialKaramaIntent(
g.pendingRicheseGift?.intent.owner !== target.id,
'Resolve the reserved Richese gift before randomly exchanging that hand.',
);
requireRule(
g.pendingIxRicheseTechnology?.source !== 'blackMarket' ||
g.pendingIxRicheseTechnology.owner !== target.id,
'Resolve the pending Black Market Ixian choice before randomly exchanging that hand.',
);
const count = integer(
action.amount,
1,
@@ -19117,6 +19239,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
@@ -19283,6 +19406,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
homeworldSubstitutionIntegrity(g);
homeworldDefenseIntegrity(g);
homeworldShipmentIntegrity(g);
ixRicheseTechnologyIntegrity(g);
return g;
}
function finishActionContinuations(g: Game) {
@@ -19377,6 +19501,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
discoveryIntegrity(state);
greatMakerIntegrity(state);
@@ -19417,6 +19542,7 @@ export function normalizeAutomaticGame(state: Game): Game {
homeworldSubstitutionIntegrity(g);
homeworldDefenseIntegrity(g);
homeworldShipmentIntegrity(g);
ixRicheseTechnologyIntegrity(g);
return g;
}
@@ -19805,6 +19931,19 @@ function applyActionInner(
);
g.decision = null;
requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.');
if (decision.kind === 'ixRicheseTechnology') {
const pending = g.pendingIxRicheseTechnology!;
requireRule(action.event === pending.event && action.decline === true &&
Object.keys(action).every(key => ['type', 'event', 'decline'].includes(key)),
'Ixian exchange custody for Richese lots is not implemented. Explicitly continue without this exchange.');
delete g.pendingIxRicheseTechnology;
delete g.ixRicheseTechnologyEvent;
log(g, `${p.name} continued without exchanging this Richese lot; Technology remains available for later lots.`);
beginRicheseLot(g, pending.source, { type: 'decision', card: pending.card.id, method: pending.method,
...(pending.direction !== null ? { direction: pending.direction } : {}),
...(pending.claim !== null ? { claim: pending.claim } : {}) }, true);
return g;
}
if (decision.kind === 'homeworldRevivalDeployment') {
decideHomeworldRevivalReturn(g, p, action);
return g;
@@ -21080,7 +21219,7 @@ function applyActionInner(
? ['traitor']
: g.setupStage === 'leaderSkills'
? ['leaderSkill']
: ['fremenSetup', 'advisorSetup'];
: ['fremenSetup', 'ecazSetup', 'advisorSetup'];
requireRule(
expected.includes(t) && setupPending(g).includes(id),
`Wait for the current setup step: ${g.setupStage}.`,
@@ -21130,6 +21269,18 @@ function applyActionInner(
);
p.traitors = [String(action.leader)];
p.traitorChoices = [];
} else if (t === 'ecazSetup') {
requireRule(g.setupStage === 'forces' && p.faction === 'ecaz' && p.reserves === 20 &&
Object.keys(p.forces).length === 0 && Object.keys(action).every(key => ['type', 'placements'].includes(key)),
'Place Ecaz starting forces once during the current force setup.');
const placements = nexusRule(() => quoteEcazStartingForces(action.placements));
for (const [key, amount] of Object.entries(placements)) {
const loc = splitLocation(key);
place(p, loc.territory, loc.sector, amount);
}
p.reserves -= ECAZ_START_FORCES;
log(g, `${p.name} placed six starting forces in Imperial Basin and retained fourteen in reserves.`,
{ faction: p.faction, name: 'Starting forces' });
} else if (t === 'fremenSetup') {
requireRule(
p.faction === 'fremen' && p.reserves === 20,
@@ -22823,6 +22974,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
discoveryFlightIntegrity(state);
discoveryIntegrity(state);
@@ -23636,6 +23788,13 @@ export function viewGame(state: Game, id: string) {
}
: null,
richeseSpecialKarama: richeseSpecialKaramaView(g, me),
ixRicheseTechnology: g.pendingIxRicheseTechnology ? {
event: g.pendingIxRicheseTechnology.event,
player: g.pendingIxRicheseTechnology.player,
owner: g.pendingIxRicheseTechnology.owner,
source: g.pendingIxRicheseTechnology.source,
exchangeBlocked: 'Ixian exchange custody for Richese lots is not implemented. You can explicitly continue without this exchange; Technology remains available for later lots.',
} : null,
richeseBidding:
g.richeseBidding?.turn === g.turn
? {
@@ -23644,7 +23803,7 @@ export function viewGame(state: Game, id: string) {
stage: g.richeseBidding.stage,
position: g.richeseBidding.position,
normalCount: g.richeseBidding.normalCount,
offerBlocked: richeseOfferBlock(g),
offerBlocked: null,
cache:
g.richeseBidding.owner === id ? (g.richeseCache ?? []) : null,
}
+24
View File
@@ -3591,6 +3591,30 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
],
},
{
id: 'expansion-faction-games',
title: 'Expansion faction development games',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Genuine setup connects selected expansion factions to development tables. Complete faction rules and combined-game acceptance remain unfinished.',
steps: [
'Development setup supports two through six distinct seats from the base factions and selected expansion sets. Expansion selection and optional modules are separate; public expansion starts remain disabled.',
'Ecaz places six starting forces in Imperial Basin, allocated among sectors 9, 10 and 11, and keeps fourteen in reserve. Finish Fremen placement first; Bene Gesserit then places its Advanced starting advisor. Moritani places its six forces in an unoccupied territory after all other setup is complete.',
'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.',
'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: Recruits, Reinforcements and Harass & Withdraw form a separate unfinished optional variant.',
'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.',
'Remaining abilities and combined timing can still encounter explicit development guards. Setup and saved-continuation evidence does not certify complete expansion play.',
],
related: ['setup', 'ix-modules', 'choam-modules', 'ecaz-modules', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Genuine setup for seven expansion selections reuses existing faction effects and corrects Ecaz starting placement. Saved per-lot Ixian decline resumes Richese bidding without changing card custody or consuming Technology. Optional variants and unfinished interactions remain separate.', evidence: ['game/engine.ts', 'game/cards.ts', 'game/ecaz-setup.ts', 'tools/prototype-room.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Existing faction choices and private hands are reused. Ecaz has an owned sector-allocation control and setup waiting messages; Ixians have an explicit per-lot decline control. Public starts and full acceptance remain gated.', evidence: ['components/ecaz-setup.tsx', 'components/ix-richese-technology.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use genuine setup choices and the shared Ecaz allocation quote, and can decline this Richese lot without using Technology. Combined gameplay strategy, guarded interactions and full-game reliability remain unfinished.', evidence: ['game/bots.ts', 'game/ecaz-setup.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The faction profile records ordinary deck composition, independent variants, Ecaz territory allocation and deterministic setup-order interpretations, with unfinished play explicit.', evidence: ['docs/EXPANSION_FACTIONS_PROTOTYPE.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Setup, inventory, private views, JSON/SQLite continuation, concurrent start and dirty-lobby rejection have focused checks. Ixian per-lot decline has ownership, stale/corrupt decision, private card custody and concurrent recovery checks. Sample gameplay and browser evidence are recorded separately, with remaining guards retained.', evidence: ['tests/expansion-factions-prototype.test.ts', 'tests/expansion-factions-recovery.test.ts', 'tests/ecaz-setup-controls.test.tsx', 'tests/ecaz-cards.test.ts', 'tests/ix-richese-technology.test.ts', 'tests/ix-richese-technology-controls.test.tsx', 'tests/ix-richese-technology-recovery.test.ts'] },
],
},
...[
[
'ix-modules',
+1
View File
@@ -38,6 +38,7 @@ const DECISIONS = {
ixSetup: true,
ixAuction: true,
ixTechnology: true,
ixRicheseTechnology: true,
ixAllyCard: true,
mobileStronghold: true,
ixSubstitution: true,
+22 -1
View File
@@ -37,13 +37,34 @@ void test('the verified Ecaz variant has one physical special of each identity a
assert.equal(new Set(combined.map((card) => card.id)).size, 50);
assert.equal(treacheryDeck().length, 33);
assert.equal(treacheryDeck(['ix']).length, 47);
for (const unsupported of ['ecaz', ECAZ_TREACHERY_VARIANT.id])
assert.equal(treacheryDeck(['ecaz']).length, 33);
for (const unsupported of [ECAZ_TREACHERY_VARIANT.id, 'unknown'])
assert.throws(() => treacheryDeck([unsupported]), /not implemented/);
assert.equal(ECAZ_TREACHERY_VARIANT.independentOfFactions, true);
assert.equal(ECAZ_TREACHERY_VARIANT.independentOfOtherVariants, true);
assert.equal(ECAZ_TREACHERY_VARIANT.activation, 'not-implemented');
});
void test('Ecaz faction selection preserves each ordinary deck and never includes its separate unfinished variant', () => {
for (const other of [[], ['ix'], ['choam'], ['ix', 'choam']]) {
const cards = treacheryDeck([...other, 'ecaz']);
assert.deepEqual(cards, treacheryDeck(other));
assert.equal(new Set(cards.map((card) => card.id)).size, cards.length);
assert.equal(
cards.some((card) => card.id.startsWith('ecaz-')),
false,
);
assert.equal(
cards.length,
other.includes('ix') ? 47 : other.includes('choam') ? 35 : 33,
);
}
assert.throws(
() => treacheryDeck(['ecaz', ECAZ_TREACHERY_VARIANT.id]),
/not implemented/,
);
});
void test('factory instances and their mutable cards are independent of canonical definitions', () => {
const first = ecazTreacheryCards();
first[0].name = 'Changed';
+104
View File
@@ -0,0 +1,104 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { EcazSetup } from '../components/ecaz-setup';
import {
applyAction,
createGame,
initializeFactionExpansionsGameForAudit,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { ECAZ_START_LOCATIONS } from '../game/ecaz-setup';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function pending(advanced = false) {
let game = createGame('ECAZSETU', newPlayer('e', 'Ecaz', 'ecaz'), advanced, [
'ecaz',
]);
joinGame(game, newPlayer('g', 'Guild', 'guild'));
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
game = initializeFactionExpansionsGameForAudit(game);
while (game.setupStage === 'traitors') {
const player = game.players.find((p) => p.traitorChoices.length)!;
game = applyAction(game, player.id, {
type: 'traitor',
leader: player.traitorChoices[0],
});
}
assert.equal(game.setupStage, 'forces');
assert.deepEqual(
game.players.map((p) => p.hand.length),
[0, 0],
);
assert.deepEqual(game.setupStage && viewGame(game, 'e').setupPending, ['e']);
return game;
}
function markup(game: Game, viewer = 'e', busy = false) {
return renderToStaticMarkup(
createElement(EcazSetup, { game: viewGame(game, viewer), act() {}, busy }),
);
}
void test('Ecaz setup presents three sector allocations only to its pending owner, before cards are dealt', () => {
for (const advanced of [false, true]) {
const game = pending(advanced);
const html = markup(game);
assert.match(html, /Ecaz starting forces/);
assert.match(html, /fourteen forces remain in reserves/);
for (const location of ECAZ_START_LOCATIONS)
assert.ok(html.includes(`ecaz-start-${location}`));
assert.match(html, /6 \/ 6 forces assigned/);
assert.match(html, /Place Ecaz starting forces/);
assert.equal(markup(game, 'g'), '');
assert.equal(
(
markup(game, 'e', true).match(
/<(?:input|button)\b[^>]*\sdisabled=""/g,
) ?? []
).length,
4,
);
const placed = applyAction(JSON.parse(JSON.stringify(game)), 'e', {
type: 'ecazSetup',
placements: Object.fromEntries(
ECAZ_START_LOCATIONS.map((location) => [location, 2]),
),
});
assert.equal(markup(placed), '');
assert.equal(placed.players[0].reserves, 14);
assert.deepEqual(
placed.players.map((p) => p.hand.length),
[1, 1],
);
}
});
void test('every AI profile supplies a legal Ecaz allocation and other seats wait for placement', () => {
for (const difficulty of DIFFICULTIES) {
const game = pending(true);
const view = viewGame(game, 'e');
view.players[0].bot = difficulty;
const actions = botActions(view);
assert.equal(actions.length, 1);
assert.equal(actions[0].type, 'ecazSetup');
const result = applyAction(game, 'e', actions[0]);
assert.equal(result.players[0].reserves, 14);
assert.equal(
Object.values(result.players[0].forces).reduce(
(sum, count) => sum + count,
0,
),
6,
);
const other = viewGame(game, 'g');
other.players[1].bot = difficulty;
assert.deepEqual(botActions(other), []);
}
});
+594
View File
@@ -0,0 +1,594 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction,
createGame,
initializeFactionExpansionsGameForAudit,
initializeHomeworldGameForAudit,
initializeNexusGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
import { FACTIONS, faction, type FactionId } from '../game/catalog';
import { CHOAM_AUDITOR_ID, spiceDeck, treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { traitorDeck } from '../game/traitors';
import { MOBILE_LOCATION } from '../game/board';
import {
ECAZ_START_FORCES,
ECAZ_START_LOCATIONS,
quoteEcazStartingForces,
} from '../game/ecaz-setup';
const combinations = [
['ix'],
['choam'],
['ecaz'],
['ix', 'choam'],
['ix', 'ecaz'],
['choam', 'ecaz'],
['ix', 'choam', 'ecaz'],
];
const sum = (forces: Record<string, number>) =>
Object.values(forces).reduce((a, b) => a + b, 0);
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function rosterFor(expansions: string[]): FactionId[] {
const selected = FACTIONS.filter((f) => expansions.includes(f.expansion)).map(
(f) => f.id,
);
return [
...selected,
...(
['fremen', 'beneGesserit', 'atreides', 'harkonnen'] as FactionId[]
).slice(0, 6 - selected.length),
];
}
function lobby(
expansions = ['choam'],
advanced = false,
roster = rosterFor(expansions),
): Game {
let g = createGame(
'FACTIONS',
newPlayer(roster[0], roster[0], roster[0]),
advanced,
expansions,
);
for (const id of roster.slice(1)) joinGame(g, newPlayer(id, id, id));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function privateViews(g: Game) {
const saved = reload(g);
for (const p of g.players) {
const view = viewGame(g, p.id);
assert.deepEqual(viewGame(saved, p.id), view);
assert.equal('deck' in view, false);
assert.equal('traitorReserve' in view, false);
for (const other of view.players.filter((other) => other.id !== p.id)) {
assert.equal(other.hand, undefined);
assert.equal(other.traitors, undefined);
assert.equal(other.traitorChoices, undefined);
assert.equal(other.faceDancers, undefined);
assert.equal(other.spice, undefined);
}
if (g.ixSetupCards && p.faction !== 'ixians')
assert.equal(view.ixTechnology, null);
if (g.players.some((other) => other.noField)) {
assert.equal(!!view.richeseNoField?.private, p.faction === 'richese');
if (p.faction !== 'richese')
for (const token of g.players.find(
(other) => other.faction === 'richese',
)!.noField!.tokens)
assert.equal(JSON.stringify(view).includes(token.id), false);
}
if (g.moritaniTerror) {
assert.equal(
view.moritaniTerror!.tokens.length,
p.faction === 'moritani' ? 6 : 0,
);
if (p.faction === 'moritani')
assert.ok(
view.moritaniTerror!.tokens.every((token) => 'kind' in token),
);
}
}
return saved;
}
function inventory(g: Game) {
const expected = [
...treacheryDeck(g.expansions),
...(g.players.some((p) => p.faction === 'richese') ? richeseCards() : []),
];
const cards = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
...(g.ixSetupCards ?? []),
...(g.richeseCache ?? []),
];
assert.deepEqual(
cards.map((c) => c.id).sort(),
expected.map((c) => c.id).sort(),
);
assert.equal(new Set(cards.map((c) => c.id)).size, cards.length);
assert.ok(cards.every((c) => !c.id.startsWith('ecaz-')));
assert.equal(
g.spiceDeck.length + g.spiceDiscard.flat().length,
spiceDeck(g.expansions.includes('ix')).length,
);
for (const p of g.players)
assert.equal(p.reserves + p.tanks + sum(p.forces), 20, p.id);
if (g.setupStage !== 'prediction') {
const traitors = [
...(g.traitorReserve ?? []),
...g.players.flatMap((p) => [
...p.traitors,
...p.traitorChoices,
...(p.faceDancers ?? []).map((c) => c.leader),
]),
];
assert.deepEqual(
traitors.slice().sort(),
traitorDeck(g.players, g.expansions.includes('ix')).sort(),
);
assert.equal(new Set(traitors).size, traitors.length);
}
}
function setup(state: Game, difficulty: Difficulty) {
let g = state;
const history: string[] = [];
for (let step = 0; g.status === 'setup' && step < 40; step++) {
inventory(g);
g = privateViews(g);
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((player) => player.id === p.id)!.bot = difficulty;
const before = structuredClone(view);
const actions = botActions(view);
assert.deepEqual(view, before);
for (const action of actions) {
const candidate = applyAction(g, p.id, action);
next ??= candidate;
}
if (next) {
const action = actions[0];
history.push(
action.type === 'decision' ? g.decision!.kind : action.type,
);
if (g.decision?.kind === 'moritaniSetup') {
assert.equal(g.setupStage, undefined);
assert.ok(
g.players.every(
(player) =>
player.hand.length === (player.faction === 'harkonnen' ? 2 : 1),
),
);
assert.ok(
g.players.every(
(player) => player.faction !== 'ecaz' || player.reserves === 14,
),
);
}
break;
}
}
assert.ok(
next,
`${g.expansions.join(',')}/${difficulty}: stalled ${g.setupStage}/${g.decision?.kind}/${viewGame(g, g.host).setupPending.join(',')}`,
);
g = next;
}
assert.equal(g.status, 'playing');
assert.equal(g.turn, 1);
assert.equal(g.phase, 0);
inventory(g);
privateViews(g);
return { game: g, history };
}
function reject(g: Game, operation = initializeFactionExpansionsGameForAudit) {
const before = structuredClone(g);
assert.throws(() => operation(g));
assert.deepEqual(g, before);
}
function rejectAction(g: Game, player: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, player, action));
assert.deepEqual(g, before);
}
for (const expansions of combinations)
void test(`genuine ${expansions.join('+')} faction setup supports Basic and Advanced with every AI profile and private JSON continuation`, () => {
for (const advanced of [false, true])
for (const difficulty of DIFFICULTIES) {
const initial = lobby(expansions, advanced),
before = structuredClone(initial);
const started = initializeFactionExpansionsGameForAudit(initial);
assert.deepEqual(initial, before);
assert.equal(started.leaderSkills, undefined);
assert.equal(
started.deck.length,
expansions.includes('ix')
? 47
: expansions.includes('choam')
? 35
: 33,
);
const { game: g, history } = setup(started, difficulty);
assert.deepEqual(g.expansions, expansions);
for (const p of g.players) {
assert.equal(p.spice, faction(p.faction).spice);
assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
}
const ecaz = g.players.find((p) => p.faction === 'ecaz');
if (ecaz) {
assert.equal(ecaz.reserves, 14);
assert.equal(sum(ecaz.forces), 6);
assert.ok(
Object.keys(ecaz.forces).every((key) =>
ECAZ_START_LOCATIONS.includes(key),
),
);
assert.equal(g.ecazAmbassadors!.tokens.length, 11);
assert.equal(
g.ecazAmbassadors!.tokens.filter((t) => t.zone === 'supply').length,
6,
);
assert.equal(g.ecazAmbassadors!.cohort.length, 5);
if (g.players.some((p) => p.faction === 'fremen'))
assert.ok(
history.indexOf('fremenSetup') < history.indexOf('ecazSetup'),
);
if (advanced && g.players.some((p) => p.faction === 'beneGesserit'))
assert.ok(
history.indexOf('ecazSetup') < history.indexOf('advisorSetup'),
);
}
const moritani = g.players.find((p) => p.faction === 'moritani');
if (moritani) {
assert.equal(moritani.reserves, 14);
assert.equal(sum(moritani.forces), 6);
assert.equal(g.moritaniTerror!.tokens.length, 6);
assert.equal(history.at(-1), 'moritaniSetup');
assert.ok(g.dukeVidal);
assert.ok(
g.players.every(
(p) => !p.leaders.some((l) => l.id === g.dukeVidal!.leader.id),
),
);
}
const richese = g.players.find((p) => p.faction === 'richese');
if (richese) {
assert.equal(g.richeseCache!.length, 10);
assert.deepEqual(
richese.noField!.tokens.map((t) => t.value).sort((a, b) => a - b),
[0, 3, 5],
);
assert.equal(richese.reserves, 20);
}
const choam = g.players.find((p) => p.faction === 'choam');
if (choam)
assert.equal(
choam.leaders.some((l) => l.id === CHOAM_AUDITOR_ID),
advanced,
);
const ix = g.players.find((p) => p.faction === 'ixians');
if (ix) {
assert.deepEqual(ix.forces, { [MOBILE_LOCATION]: 6 });
assert.deepEqual(ix.elites!.forces, { [MOBILE_LOCATION]: 3 });
assert.equal(ix.elites!.reserves, 4);
assert.equal(g.mobileStronghold!.location, null);
}
const tleilaxu = g.players.find((p) => p.faction === 'tleilaxu');
if (tleilaxu) assert.equal(tleilaxu.faceDancers!.length, 3);
reject(g);
}
});
void test('faction prototype handles two through six seats and preserves the public start gates', () => {
const roster: FactionId[] = [
'choam',
'richese',
'atreides',
'harkonnen',
'guild',
'emperor',
];
for (const count of [2, 3, 4, 5, 6])
for (const advanced of [false, true]) {
const g = lobby(['choam'], advanced, roster.slice(0, count));
rejectAction(g, g.host, { type: 'start' });
assert.equal(
setup(initializeFactionExpansionsGameForAudit(g), 'Medium').game.players
.length,
count,
);
}
});
void test('Ecaz starting-force quote accepts exact six-force Basin allocations and rejects invalid or ambiguous input purely', () => {
assert.equal(ECAZ_START_FORCES, 6);
assert.deepEqual(ECAZ_START_LOCATIONS, [
'imperial_basin:9',
'imperial_basin:10',
'imperial_basin:11',
]);
const input = {
'imperial_basin:9': 2,
'imperial_basin:10': 4,
'imperial_basin:11': 0,
};
assert.deepEqual(quoteEcazStartingForces(input), {
'imperial_basin:9': 2,
'imperial_basin:10': 4,
});
for (const bad of [
null,
[],
{},
{ 'imperial_basin:9': 5 },
{ 'imperial_basin:9': 7 },
{ 'imperial_basin:9': 5.5, 'imperial_basin:10': 0.5 },
{ 'imperial_basin:9': -1, 'imperial_basin:10': 7 },
{ 'imperial_basin:09': 6 },
{ imperial_basin: 6 },
{ 'arrakeen:10': 6 },
{ 'imperial_basin:9': '6' },
]) {
const before = structuredClone(bad);
assert.throws(() => quoteEcazStartingForces(bad));
assert.deepEqual(bad, before);
}
});
void test('Ecaz splits six forces once between Fremen and Advanced BG, then excludes Imperial Basin from Moritani final placement', () => {
let g = initializeFactionExpansionsGameForAudit(
lobby(['ecaz'], true, ['ecaz', 'moritani', 'fremen', 'beneGesserit']),
);
const actionsFor = (id: string) => {
const view = viewGame(g, id);
view.players.find((p) => p.id === id)!.bot = 'Medium';
return botActions(view);
};
for (
let step = 0;
!(
g.setupStage === 'forces' &&
viewGame(g, g.host).setupPending[0] === 'ecaz'
) && step < 20;
step++
) {
const actor = g.players.find((p) => actionsFor(p.id).length > 0)!;
assert.ok(actor);
g = applyAction(reload(g), actor.id, actionsFor(actor.id)[0]);
}
assert.deepEqual(viewGame(g, g.host).setupPending, ['ecaz']);
assert.equal(g.players.find((p) => p.faction === 'fremen')!.reserves, 10);
assert.ok(g.players.every((p) => p.hand.length === 0));
const split = {
type: 'ecazSetup',
placements: {
'imperial_basin:9': 2,
'imperial_basin:10': 2,
'imperial_basin:11': 2,
},
};
rejectAction(g, 'beneGesserit', {
type: 'advisorSetup',
territory: 'polar_sink',
sector: 0,
});
rejectAction(g, 'fremen', split);
rejectAction(g, 'ecaz', {
type: 'ecazSetup',
placements: { 'imperial_basin:9': 5 },
});
rejectAction(g, 'ecaz', { ...split, elitePlacements: {} });
g = applyAction(privateViews(g), 'ecaz', split);
assert.deepEqual(g.players[0].forces, split.placements);
assert.equal(g.players[0].reserves, 14);
assert.deepEqual(viewGame(g, g.host).setupPending, ['beneGesserit']);
rejectAction(g, 'ecaz', split);
g = applyAction(privateViews(g), 'beneGesserit', {
type: 'advisorSetup',
territory: 'polar_sink',
sector: 0,
});
assert.deepEqual(g.decision, { kind: 'moritaniSetup', player: 'moritani' });
assert.ok(g.players.every((p) => p.hand.length === 1));
rejectAction(g, 'moritani', {
type: 'decision',
territory: 'imperial_basin',
sector: 9,
});
rejectAction(g, 'moritani', {
type: 'decision',
territory: 'polar_sink',
sector: 0,
});
for (const [reserves, forces] of [
[20, {}],
[14, { 'arrakeen:10': 6 }],
] as const) {
const incomplete = reload(g);
incomplete.players[0].reserves = reserves;
incomplete.players[0].forces = forces;
rejectAction(incomplete, 'moritani', {
type: 'decision',
territory: 'carthag',
sector: 11,
});
incomplete.decision = null;
const before = structuredClone(incomplete),
normalized = normalizeAutomaticGame(incomplete);
assert.deepEqual(incomplete, before);
assert.equal(normalized.status, 'setup');
assert.equal(normalized.decision, null);
}
g = applyAction(privateViews(g), 'moritani', {
type: 'decision',
territory: 'arrakeen',
sector: 10,
});
assert.equal(g.status, 'playing');
assert.equal(g.players[1].reserves, 14);
assert.deepEqual(g.players[1].forces, { 'arrakeen:10': 6 });
rejectAction(g, 'ecaz', split);
inventory(g);
});
void test('a stale or partially played lobby cannot redeal faction inventories, leaders, or optional modules', () => {
const mutations: Array<(g: Game) => void> = [
(g) => {
g.expansions = [];
},
(g) => {
g.expansions = ['ecaz-treachery'];
},
(g) => {
g.expansions.push('choam');
},
(g) => {
g.players[0].ready = false;
},
(g) => {
g.host = 'missing';
},
(g) => {
g.turn = 2;
},
(g) => {
g.advanced = 'advanced' as unknown as boolean;
},
(g) => {
g.status = 'setup';
},
(g) => {
g.players[0].spice = 1;
},
(g) => {
g.players[0].reserves = 19;
},
(g) => {
g.richeseCache = [];
},
(g) => {
g.richeseRemoved = [];
},
(g) => {
g.ecazAmbassadors = { tokens: [], cohort: [], placement: null };
},
(g) => {
g.moritaniTerror = { tokens: [] };
},
(g) => {
g.dukeVidal = {} as NonNullable<Game['dukeVidal']>;
},
(g) => {
g.mobileStronghold = { location: null };
},
(g) => {
g.ixSetupCards = [];
},
(g) => {
g.players[1].noField = {} as NonNullable<
Game['players'][number]['noField']
>;
},
(g) => {
g.players[1].noFieldEvent = 'old';
},
(g) => {
g.players[0].faceDancers = [];
},
(g) => {
g.players[0].leaders[0].capturedBy = g.players[1].id;
},
(g) => {
g.players[0].leaders[0].gholaBy = g.players[1].id;
},
(g) => {
g.players[0].leaders[0].dead = true;
},
(g) => {
g.players[0].leaders[0].deaths = 1;
},
(g) => {
g.players[0].leaders[0].usedAt = 'arrakeen';
},
(g) => {
g.players[0].leaders[0].strength++;
},
(g) => {
g.players[0].revived = 1;
},
(g) => {
g.players[0].freeForcesRevived = 1;
},
(g) => {
g.players[0].leaderRevived = true;
},
(g) => {
g.players[0].revivalCycle = 1;
},
(g) => {
g.homeworlds = { custody: null };
},
(g) => {
g.nexusCards = { cards: null, phase: null };
},
(g) => {
g.leaderSkills = {} as NonNullable<Game['leaderSkills']>;
},
(g) => {
g.discoveryEnabled = true;
},
(g) => {
g.discoveries = {} as NonNullable<Game['discoveries']>;
},
(g) => {
g.techTokens = {} as NonNullable<Game['techTokens']>;
},
(g) => {
g.strongholdCards = {} as NonNullable<Game['strongholdCards']>;
},
];
for (const mutate of mutations) {
const g = lobby();
mutate(g);
reject(g);
}
const absentFaction = lobby(['choam']);
absentFaction.players[0] = {
...newPlayer('ecaz', 'Ecaz', 'ecaz'),
ready: true,
};
absentFaction.host = 'ecaz';
reject(absentFaction);
const tooMany = lobby(['ix']);
tooMany.players.push({
...newPlayer('guild', 'Guild', 'guild'),
ready: true,
});
reject(tooMany);
const alone = lobby(['choam'], false, ['choam']);
reject(alone);
});
void test('accepting the Ecaz deck selector does not open existing Homeworld or Nexus expansion gates', () => {
for (const expansions of [['choam'], ['ecaz'], ['ix', 'ecaz']]) {
const home = lobby(expansions, true);
home.homeworlds = { custody: null };
reject(home, initializeHomeworldGameForAudit);
const nexus = lobby(expansions, true);
nexus.nexusCards = { cards: null, phase: null };
reject(nexus, initializeNexusGameForAudit);
}
});
+415
View File
@@ -0,0 +1,415 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import { botActions } from '../game/bots';
import { treacheryDeck } from '../game/cards';
import type { FactionId } from '../game/catalog';
import type { Action, Game, GameView } from '../game/engine';
import type * as Rooms from '../db/rooms';
import { startPrototypeRoom } from '../tools/prototype-room';
import { unitStore } from './fixture-nexus-room-store';
const clock: Rooms.RoomsClock = {
now: () => 12_345,
sleep: async () => {},
};
const MIXED_ROSTER: readonly FactionId[] = [
'ixians',
'tleilaxu',
'choam',
'richese',
'ecaz',
'moritani',
];
type Store = ReturnType<typeof unitStore>;
type RoomRow = {
code: string;
state: string;
version: number;
updated_at: number;
};
let receiptSequence = 0;
function entry() {
receiptSequence++;
return {
operationId: `00000000-0000-4000-8000-${receiptSequence
.toString(16)
.padStart(12, '0')}`,
sessionToken: receiptSequence.toString(16).padStart(64, '0'),
};
}
function roomRow(db: DatabaseSync, code: string): RoomRow {
const row = db
.prepare('SELECT code,state,version,updated_at FROM rooms WHERE code = ?')
.get(code) as RoomRow | undefined;
assert.ok(row);
return row;
}
function storageSnapshot(db: DatabaseSync) {
return {
rooms: db.prepare('SELECT * FROM rooms ORDER BY code').all(),
seats: db.prepare('SELECT * FROM seats ORDER BY room_code,player_id').all(),
receipts: db
.prepare('SELECT * FROM room_entry_receipts ORDER BY room_code,player_id')
.all(),
};
}
async function readyRoom(
store: Store,
expansions: string[],
factions: readonly FactionId[],
advanced = false,
) {
const made = await store.rooms.createRoom(
`${factions[0]} host`,
factions[0],
advanced,
expansions,
entry(),
);
const code = made.view.code;
const tokens = [made.token];
for (const faction of factions.slice(1)) {
const joined = await store.rooms.joinRoom(
code,
`${faction} seat`,
faction,
entry(),
);
assert.ok(joined.token);
tokens.push(joined.token);
}
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
for (const auth of auths) {
const current = await store.rooms.readRoom(code);
await store.rooms.act(
code,
auth,
current.version,
{ type: 'ready' },
clock,
);
}
const source = await store.rooms.readRoom(code);
const raw = roomRow(store.sqlite, code);
assert.equal(source.version, raw.version);
assert.equal((JSON.parse(raw.state) as Game).version, raw.version);
assert.equal(source.status, 'lobby');
assert.ok(source.players.every((player) => player.ready));
return { code, tokens, auths, source };
}
function ownPlayer(view: GameView) {
return view.players.find((player) => player.id === view.me)!;
}
async function assertPrivateSetupViews(
rooms: Store['rooms'],
code: string,
auths: Rooms.SeatAuth[],
game: Game,
) {
const views = await Promise.all(
auths.map((auth) => rooms.readSeatView(code, auth)),
);
for (const [index, view] of views.entries()) {
const owner = game.players[index];
assert.equal(view.me, owner.id);
assert.deepEqual(ownPlayer(view).hand, owner.hand);
assert.deepEqual(ownPlayer(view).traitors, owner.traitors);
assert.deepEqual(ownPlayer(view).traitorChoices, owner.traitorChoices);
assert.deepEqual(ownPlayer(view).faceDancers, owner.faceDancers);
assert.equal(Object.hasOwn(view, 'deck'), false);
for (const other of view.players.filter(
(player) => player.id !== view.me,
)) {
assert.equal(other.hand, undefined);
assert.equal(other.traitors, undefined);
assert.equal(other.traitorChoices, undefined);
assert.equal(other.faceDancers, undefined);
}
}
const richeseIndex = game.players.findIndex(
(player) => player.faction === 'richese',
);
const richese = game.players[richeseIndex];
if (richese.noField) {
assert.deepEqual(
views[richeseIndex].richeseNoField?.private,
richese.noField,
);
const secretIds = richese.noField.tokens.map((token) => token.id);
for (const [index, view] of views.entries()) {
if (index === richeseIndex) continue;
assert.equal(view.richeseNoField?.private, null);
const projected = JSON.stringify(view);
for (const id of secretIds) assert.equal(projected.includes(id), false);
}
}
const moritaniIndex = game.players.findIndex(
(player) => player.faction === 'moritani',
);
if (game.moritaniTerror && moritaniIndex >= 0) {
assert.deepEqual(views[moritaniIndex].moritaniTerror, game.moritaniTerror);
for (const [index, view] of views.entries()) {
if (index === moritaniIndex) continue;
assert.deepEqual(view.moritaniTerror?.tokens, []);
}
}
if (game.ixSetupCards) {
const ixianIndex = game.players.findIndex(
(player) => player.faction === 'ixians',
);
assert.deepEqual(views[ixianIndex].ixTechnology?.setup, game.ixSetupCards);
for (const [index, view] of views.entries())
if (index !== ixianIndex) assert.equal(view.ixTechnology, null);
}
}
function setupAction(view: GameView): Action | null {
const copy = structuredClone(view);
ownPlayer(copy).bot = 'Medium';
return botActions(copy)[0] ?? null;
}
async function finishPersistedSetup(
store: Store,
code: string,
auths: Rooms.SeatAuth[],
) {
let sawPrivateIxChoice = false;
for (let step = 0; step < 64; step++) {
const game = await store.restart().readRoom(code);
const raw = roomRow(store.sqlite, code);
assert.equal(game.version, raw.version);
assert.equal((JSON.parse(raw.state) as Game).version, raw.version);
await assertPrivateSetupViews(store.restart(), code, auths, game);
if (game.ixSetupCards) sawPrivateIxChoice = true;
if (game.status === 'playing') return { game, sawPrivateIxChoice };
let progressed = false;
for (const auth of auths) {
const rooms = store.restart();
const view = await rooms.readSeatView(code, auth);
const action = setupAction(view);
if (!action) continue;
await rooms.act(code, auth, view.version, action, clock);
progressed = true;
break;
}
assert.ok(
progressed,
`Persisted faction setup stalled at ${game.setupStage}/${game.decision?.kind ?? 'no decision'}.`,
);
}
assert.fail('Persisted faction setup exceeded its bounded action count.');
}
void test('faction prototype preserves room entry state and completes a private mixed setup after reloads', async (t) => {
const store = unitStore();
t.after(() => store.sqlite.close());
const main = await readyRoom(
store,
['ix', 'choam', 'ecaz'],
MIXED_ROSTER,
true,
);
const neighbor = await store.rooms.createRoom(
'Unchanged neighboring room',
'atreides',
false,
[],
entry(),
);
const neighborRow = roomRow(store.sqlite, neighbor.view.code);
const seatsBefore = storageSnapshot(store.sqlite).seats;
const receiptsBefore = storageSnapshot(store.sqlite).receipts;
const started = startPrototypeRoom(
store.sqlite,
main.code,
main.source.version,
'factions',
);
assert.equal(started.version, main.source.version + 1);
assert.equal(started.status, 'setup');
assert.equal(started.players, 6);
const startedRow = roomRow(store.sqlite, main.code);
assert.equal(startedRow.version, started.version);
assert.equal((JSON.parse(startedRow.state) as Game).version, started.version);
assert.deepEqual(storageSnapshot(store.sqlite).seats, seatsBefore);
assert.deepEqual(storageSnapshot(store.sqlite).receipts, receiptsBefore);
assert.deepEqual(roomRow(store.sqlite, neighbor.view.code), neighborRow);
const { game, sawPrivateIxChoice } = await finishPersistedSetup(
store,
main.code,
main.auths,
);
assert.equal(sawPrivateIxChoice, true);
assert.equal(game.status, 'playing');
assert.deepEqual(game.expansions, ['ix', 'choam', 'ecaz']);
assert.equal(game.setupStage, undefined);
assert.equal(game.ixSetupCards, null);
assert.equal(game.richeseCache?.length, 10);
assert.equal(game.richeseRemoved?.length, 0);
assert.equal(game.ecazAmbassadors?.tokens.length, 11);
assert.equal(game.ecazAmbassadors?.cohort.length, 5);
assert.equal(game.moritaniTerror?.tokens.length, 6);
assert.equal(game.dukeVidal?.leader.id, 'duke-vidal');
assert.equal(
game.players.find((player) => player.faction === 'tleilaxu')?.faceDancers
?.length,
3,
);
const ecaz = game.players.find((player) => player.faction === 'ecaz')!;
assert.equal(ecaz.reserves, 14);
assert.equal(
Object.entries(ecaz.forces)
.filter(([key]) => key.startsWith('imperial_basin:'))
.reduce((sum, [, amount]) => sum + amount, 0),
6,
);
const expectedDeck = treacheryDeck(['ix', 'choam', 'ecaz']);
const heldDeck = [
...game.deck,
...game.players.flatMap((player) => player.hand),
];
assert.equal(expectedDeck.length, 47);
assert.equal(heldDeck.length, 47);
assert.deepEqual(
heldDeck.map((card) => card.id).sort(),
expectedDeck.map((card) => card.id).sort(),
);
assert.deepEqual(storageSnapshot(store.sqlite).seats, seatsBefore);
assert.deepEqual(storageSnapshot(store.sqlite).receipts, receiptsBefore);
assert.deepEqual(roomRow(store.sqlite, neighbor.view.code), neighborRow);
});
void test('faction prototype rejects stale, wrong-profile, optional-module and redeal starts without writes', async (t) => {
const store = unitStore();
t.after(() => store.sqlite.close());
const main = await readyRoom(store, ['choam', 'ecaz'], ['choam', 'ecaz']);
let before = storageSnapshot(store.sqlite);
assert.throws(
() =>
startPrototypeRoom(
store.sqlite,
main.code,
main.source.version - 1,
'factions',
),
/missing or changed/,
);
assert.deepEqual(storageSnapshot(store.sqlite), before);
assert.throws(
() =>
startPrototypeRoom(store.sqlite, main.code, main.source.version, 'ix'),
/requires exactly/,
);
assert.deepEqual(storageSnapshot(store.sqlite), before);
const unsupported = await readyRoom(store, ['ecaz'], ['ecaz', 'atreides']);
const unsupportedRow = roomRow(store.sqlite, unsupported.code);
const unsupportedState = JSON.parse(unsupportedRow.state) as Game;
unsupportedState.discoveryEnabled = true;
store.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(JSON.stringify(unsupportedState), unsupported.code);
before = storageSnapshot(store.sqlite);
assert.throws(
() =>
startPrototypeRoom(
store.sqlite,
unsupported.code,
unsupported.source.version,
'factions',
),
/excludes optional modules/,
);
assert.deepEqual(storageSnapshot(store.sqlite), before);
const result = startPrototypeRoom(
store.sqlite,
main.code,
main.source.version,
'factions',
);
before = storageSnapshot(store.sqlite);
assert.throws(
() =>
startPrototypeRoom(store.sqlite, main.code, result.version, 'factions'),
/fresh lobby|overwrite|redeal/,
);
assert.deepEqual(storageSnapshot(store.sqlite), before);
});
void test('faction prototype start CAS preserves a concurrent fresh-lobby winner', async (t) => {
const store = unitStore();
t.after(() => store.sqlite.close());
const main = await readyRoom(store, ['ecaz'], ['ecaz', 'atreides']);
const seatsBefore = storageSnapshot(store.sqlite).seats;
const receiptsBefore = storageSnapshot(store.sqlite).receipts;
let raced = false;
const racing = {
prepare(sql: string) {
const statement = store.sqlite.prepare(sql);
if (!sql.startsWith('UPDATE rooms SET state = ?, version = ?'))
return statement;
return {
run: (...values: (string | number | null)[]) => {
if (!raced) {
raced = true;
const current = roomRow(store.sqlite, main.code);
const winner = JSON.parse(current.state) as Game;
winner.version = current.version + 1;
winner.log.push({
seq: (winner.log.at(-1)?.seq ?? 0) + 1,
text: 'Concurrent fresh-lobby edit won the version fence.',
});
store.sqlite
.prepare(
'UPDATE rooms SET state = ?, version = ?, updated_at = ? WHERE code = ?',
)
.run(
JSON.stringify(winner),
winner.version,
current.updated_at + 1,
main.code,
);
}
return statement.run(...values);
},
};
},
} as unknown as DatabaseSync;
assert.throws(
() =>
startPrototypeRoom(racing, main.code, main.source.version, 'factions'),
/changed during setup/,
);
assert.equal(raced, true);
const saved = roomRow(store.sqlite, main.code);
const winner = JSON.parse(saved.state) as Game;
assert.equal(saved.version, main.source.version + 1);
assert.equal(winner.version, saved.version);
assert.equal(winner.status, 'lobby');
assert.equal(winner.setupStage, undefined);
assert.match(winner.log.at(-1)!.text, /Concurrent fresh-lobby edit/);
assert.deepEqual(storageSnapshot(store.sqlite).seats, seatsBefore);
assert.deepEqual(storageSnapshot(store.sqlite).receipts, receiptsBefore);
});
+1 -1
View File
@@ -109,7 +109,7 @@ void test('the Ix expansion has fourteen distinct cards and combines into a fort
baseDeck().some((c) => c.name === 'Basilia Weapon'),
false,
);
for (const expansion of ['ecaz', 'unknown'])
for (const expansion of ['ecaz-treachery', 'unknown'])
assert.throws(() => treacheryDeck([expansion]), /not implemented/);
});
void test('CHOAM & Richese adds or replaces the same two battle cards without duplicating physical custody', () => {
@@ -0,0 +1,142 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { IxRicheseTechnology } from '../components/ix-richese-technology';
import { RicheseAuctionDecision } from '../components/richese-auctions';
import {
applyAction,
createGame,
initializeFactionExpansionsGameForAudit,
joinGame,
newPlayer,
viewGame,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
function until(state: Game, kind: string) {
let game = state;
for (let step = 0; step < 100; step++) {
if (game.decision?.kind === kind) return game;
let next: Game | undefined;
for (const player of game.players) {
const action = botActions(viewGame(game, player.id))[0];
if (action) {
next = applyAction(game, player.id, action);
break;
}
}
assert.ok(next, `No action before ${kind}`);
game = next;
}
throw new Error(`Did not reach ${kind}`);
}
function offer(source: 'cache' | 'blackMarket') {
let game = createGame(
'IXRICHUI',
newPlayer('r', 'Richese', 'richese'),
true,
['ix', 'choam'],
);
joinGame(game, newPlayer('i', 'Ixians', 'ixians'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
for (const player of game.players) {
player.ready = true;
player.bot = 'Medium';
}
game = until(
initializeFactionExpansionsGameForAudit(game),
'richeseBlackMarket',
);
if (source === 'cache') {
game = applyAction(game, 'r', {
type: 'decision',
event: game.richeseBidding!.event,
decline: true,
});
game = applyAction(game, 'r', {
type: 'decision',
event: game.richeseBidding!.event,
position: 'first',
});
game = until(game, 'richeseCache');
}
const markup = renderToStaticMarkup(
createElement(RicheseAuctionDecision, {
game: viewGame(game, 'r'),
act() {},
busy: false,
}),
);
assert.match(markup, /Offer (?:cache|Black Market) card/);
assert.doesNotMatch(markup, /<button[^>]*\sdisabled=""[^>]*>Offer /);
const card =
source === 'cache' ? game.richeseCache![0] : game.players[0].hand[0];
game = applyAction(game, 'r', {
type: 'decision',
event: game.richeseBidding!.event,
card: card.id,
method: 'silent',
...(source === 'blackMarket' ? { claim: 'Private card claim' } : {}),
});
assert.equal(game.decision?.kind, 'ixRicheseTechnology');
return { game, card };
}
function render(game: Game, viewer = 'i', busy = false) {
return renderToStaticMarkup(
createElement(IxRicheseTechnology, {
game: viewGame(game, viewer),
act() {},
busy,
}),
);
}
void test('Ixian special-lot decline is owned, available after reload and disabled during submission', () => {
for (const source of ['cache', 'blackMarket'] as const) {
const { game, card } = offer(source);
const restored = JSON.parse(JSON.stringify(game)) as Game;
const html = render(restored);
assert.match(html, /Decline Technology for this lot/);
assert.match(html, /once-per-round Technology use available/);
assert.match(html, /custody/);
assert.doesNotMatch(html, new RegExp(card.id));
if (source === 'blackMarket') assert.ok(!html.includes(card.name));
assert.equal(render(restored, 'r'), '');
assert.equal(render(restored, 'a'), '');
assert.match(render(restored, 'i', true), /<button[^>]*\sdisabled=""/);
const completed = applyAction(restored, 'i', {
type: 'decision',
event: viewGame(restored, 'i').ixRicheseTechnology!.event,
decline: true,
});
assert.equal(render(completed), '');
assert.equal(completed.ixTechnologyTurn, game.ixTechnologyTurn);
assert.equal(completed.richeseAuction?.cardId, card.id);
}
});
void test('all four AI levels decline only this lot using the private owner view', () => {
for (const source of ['cache', 'blackMarket'] as const) {
const { game, card } = offer(source);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'i');
view.players.find((player) => player.id === 'i')!.bot = difficulty;
const actions = botActions(view);
assert.deepEqual(actions, [
{
type: 'decision',
event: view.ixRicheseTechnology!.event,
decline: true,
},
]);
const result = applyAction(game, 'i', actions[0]);
assert.equal(result.richeseAuction?.cardId, card.id);
assert.equal(result.ixTechnologyTurn, game.ixTechnologyTurn);
}
}
});
@@ -0,0 +1,428 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { botActions } from '../game/bots';
import { treacheryDeck, type Card } from '../game/cards';
import {
normalizeAutomaticGame,
viewGame,
type Action,
type Decision,
type Game,
} from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import type * as Rooms from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
const clock: Rooms.RoomsClock = {
now: () => 22_000,
sleep: async () => {},
};
type Store = ReturnType<typeof unitStore>;
type Source = 'cache' | 'blackMarket';
function player(g: Game, id: string) {
return g.players.find((candidate) => candidate.id === id)!;
}
function take(g: Game, predicate: (card: Card) => boolean) {
const index = g.deck.findIndex(predicate);
assert.ok(index >= 0);
return g.deck.splice(index, 1)[0];
}
function physicalCards(g: Game) {
return [
...g.deck,
...g.discard,
...(g.richeseCache ?? []),
...(g.richeseRemoved ?? []),
...g.players.flatMap((seat) => seat.hand),
...(g.auction?.cards.slice(g.auction.index) ?? []),
...(g.ixAuction?.cards ?? []),
]
.map((card) => card.id)
.sort();
}
function storageRow(store: Store, code: string) {
const row = store.sqlite
.prepare('SELECT state,version FROM rooms WHERE code = ?')
.get(code) as { state: string; version: number } | undefined;
assert.ok(row);
return row;
}
function snapshot(store: Store, code: string) {
const row = storageRow(store, code);
return { state: row.state, version: row.version };
}
function barrier() {
let release!: () => void;
const wait = new Promise<void>((resolve) => {
release = resolve;
});
let arrivals = 0;
return async () => {
if (++arrivals === 2) release();
await wait;
};
}
async function fixture(t: test.TestContext) {
const store = unitStore();
t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Richese seller', 'richese', true, [
'ix',
'choam',
]);
const code = made.view.code;
const ixJoined = await store.rooms.joinRoom(code, 'Ixian choice', 'ixians');
const observerJoined = await store.rooms.joinRoom(
code,
'Outside observer',
'emperor',
);
assert.ok(ixJoined.token);
assert.ok(observerJoined.token);
const auths = await Promise.all(
[made.token, ixJoined.token, observerJoined.token].map((token) =>
store.rooms.authenticate(code, token),
),
);
const [richeseAuth, ixAuth, observerAuth] = auths;
const initial = await store.rooms.readRoom(code);
Object.assign(initial, {
status: 'playing',
advanced: true,
turn: 2,
phase: 2,
order: auths.map((auth) => auth.playerId),
active: null,
ready: [],
response: null,
decision: null,
phaseOpening: null,
deck: treacheryDeck(['ix', 'choam']),
discard: [],
richeseCache: richeseCards(),
richeseRemoved: [],
richeseBidding: null,
richeseAuction: null,
richeseFunding: {},
});
for (const seat of initial.players) {
seat.hand = [];
seat.spice = 20;
seat.traitors = [seat.leaders[0].id];
seat.traitorChoices = [];
}
const blackMarketCard = take(
initial,
(card) => card.effect !== 'karama' && card.kind !== 'worthless',
);
player(initial, richeseAuth.playerId).hand.push(blackMarketCard);
player(initial, ixAuth.playerId).hand.push(
take(initial, (card) => card.effect !== 'karama'),
);
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(initial), initial.version, code);
async function act(
auth: Rooms.SeatAuth,
action: Action,
rooms = store.rooms,
version?: number,
) {
const currentVersion = version ?? (await rooms.readRoom(code)).version;
return rooms.act(code, auth, currentVersion, action, clock);
}
for (const auth of auths) await act(auth, { type: 'ready' });
let opened = await store.rooms.readRoom(code);
for (const auth of auths) {
if (!opened.phaseOpening) break;
await act(auth, { type: 'ready' });
opened = await store.rooms.readRoom(code);
}
store.writes.length = 0;
opened = await store.restart().readRoom(code);
assert.equal(opened.phase, 3);
assert.equal(opened.decision?.kind, 'richeseBlackMarket');
return {
...store,
code,
auths,
richeseAuth,
ixAuth,
observerAuth,
blackMarketCard,
act,
};
}
async function openIxChoice(
f: Awaited<ReturnType<typeof fixture>>,
source: Source,
) {
let game = await f.rooms.readRoom(f.code);
if (source === 'cache') {
await f.act(f.richeseAuth, {
type: 'decision',
event: game.richeseBidding!.event,
decline: true,
});
game = await f.rooms.readRoom(f.code);
assert.equal(game.decision?.kind, 'richeseDeclaration');
await f.act(f.richeseAuth, {
type: 'decision',
event: game.richeseBidding!.event,
position: 'first',
});
game = await f.rooms.readRoom(f.code);
while (game.response) {
const auth = f.auths.find(
(candidate) => !game.response!.passed.includes(candidate.playerId),
);
assert.ok(auth);
await f.act(auth, { type: 'passResponse' });
game = await f.rooms.readRoom(f.code);
}
assert.equal(game.decision?.kind, 'richeseCache');
}
const offered =
source === 'cache' ? game.richeseCache![3] : f.blackMarketCard;
const roundEvent = game.richeseBidding!.event;
await f.act(f.richeseAuth, {
type: 'decision',
event: roundEvent,
card: offered.id,
method: 'onceAround',
direction: source === 'cache' ? 'clockwise' : 'counterclockwise',
...(source === 'blackMarket' ? { claim: ' concealed claim ' } : {}),
});
game = await f.restart().readRoom(f.code);
assert.deepEqual(game.decision, {
kind: 'ixRicheseTechnology',
player: f.ixAuth.playerId,
event: game.pendingIxRicheseTechnology!.event,
source,
});
assert.equal(game.pendingIxRicheseTechnology?.round, roundEvent);
assert.equal(game.pendingIxRicheseTechnology?.card.id, offered.id);
assert.equal(
game.pendingIxRicheseTechnology?.direction,
source === 'cache' ? 'clockwise' : 'counterclockwise',
);
assert.equal(
game.pendingIxRicheseTechnology?.claim,
source === 'blackMarket' ? 'concealed claim' : null,
);
return { game, offered };
}
function decline(game: Game): Action {
return {
type: 'decision',
event: game.pendingIxRicheseTechnology!.event,
decline: true,
};
}
void test('restored Ixian declines resume the exact cache and Black Market lots once without spending Technology or exposing cards', async (t) => {
for (const source of ['cache', 'blackMarket'] as const) {
await t.test(source, async (t) => {
const f = await fixture(t);
const { game: pending, offered } = await openIxChoice(f, source);
const pendingVersion = pending.version;
const inventory = physicalCards(pending);
const counts = {
deck: pending.deck.length,
cache: pending.richeseCache?.length,
richeseHand: player(pending, f.richeseAuth.playerId).hand.length,
ixHand: player(pending, f.ixAuth.playerId).hand.length,
auctionPool: pending.auction?.cards.length ?? 0,
ixPool: pending.ixAuction?.cards.length ?? 0,
};
assert.equal(pending.ixTechnologyTurn, undefined);
for (const auth of f.auths) {
const view = await f.restart().readSeatView(f.code, auth);
assert.deepEqual(view.ixRicheseTechnology, {
event: pending.pendingIxRicheseTechnology!.event,
player: f.ixAuth.playerId,
owner: f.richeseAuth.playerId,
source,
exchangeBlocked:
'Ixian exchange custody for Richese lots is not implemented. You can explicitly continue without this exchange; Technology remains available for later lots.',
});
assert.equal(Object.hasOwn(view, 'pendingIxRicheseTechnology'), false);
assert.equal(Object.hasOwn(view, 'ixRicheseTechnologyEvent'), false);
if (auth.playerId !== f.richeseAuth.playerId)
assert.equal(JSON.stringify(view).includes(offered.id), false);
}
const ixView = await f.rooms.readSeatView(f.code, f.ixAuth);
const botView = structuredClone(ixView);
botView.players.find((seat) => seat.id === f.ixAuth.playerId)!.bot =
'Medium';
assert.deepEqual(botActions(botView), [decline(pending)]);
if (source === 'blackMarket') {
const beforeRejected = snapshot(f, f.code);
await assert.rejects(
f.rooms.act(
f.code,
f.observerAuth,
pendingVersion,
decline(pending),
clock,
),
/pending decision/,
);
for (const action of [
{
type: 'decision',
event: pending.pendingIxRicheseTechnology!.event + '-stale',
decline: true,
},
{
type: 'decision',
event: pending.pendingIxRicheseTechnology!.event,
decline: false,
},
{
...decline(pending),
card: pending.pendingIxRicheseTechnology!.card.id,
},
])
await assert.rejects(
f.rooms.act(f.code, f.ixAuth, pendingVersion, action, clock),
/Explicitly continue/,
);
assert.deepEqual(snapshot(f, f.code), beforeRejected);
}
await f.act(f.ixAuth, decline(pending), f.restart(), pendingVersion);
const resumed = await f.restart().readRoom(f.code);
assert.equal(resumed.version, pendingVersion + 1);
assert.equal(resumed.pendingIxRicheseTechnology, undefined);
assert.equal(resumed.ixRicheseTechnologyEvent, undefined);
assert.equal(resumed.ixTechnologyTurn, undefined);
assert.equal(resumed.richeseAuction?.source, source);
assert.equal(resumed.richeseAuction?.cardId, offered.id);
assert.equal(resumed.richeseAuction?.method, 'onceAround');
assert.deepEqual(physicalCards(resumed), inventory);
assert.deepEqual(
{
deck: resumed.deck.length,
cache: resumed.richeseCache?.length,
richeseHand: player(resumed, f.richeseAuth.playerId).hand.length,
ixHand: player(resumed, f.ixAuth.playerId).hand.length,
auctionPool: resumed.auction?.cards.length ?? 0,
ixPool: resumed.ixAuction?.cards.length ?? 0,
},
counts,
);
const outside = await f.restart().readSeatView(f.code, f.observerAuth);
if (source === 'blackMarket') {
assert.equal(outside.richeseAuction?.card, null);
assert.equal(outside.richeseAuction?.cardId, null);
assert.equal(JSON.stringify(outside).includes(offered.id), false);
}
const saved = snapshot(f, f.code);
await assert.rejects(
f.rooms.act(f.code, f.ixAuth, pendingVersion, decline(pending), clock),
/table changed/,
);
await assert.rejects(
f.rooms.act(f.code, f.ixAuth, resumed.version, decline(pending), clock),
);
assert.deepEqual(snapshot(f, f.code), saved);
});
}
});
void test('forged or missing saved Ixian lot decisions fail before projection, action and normalization without writes', async (t) => {
const f = await fixture(t);
const { game: pending } = await openIxChoice(f, 'blackMarket');
const genuine = structuredClone(pending);
const corruptions: Array<(game: Game) => void> = [
(game) => {
game.decision = null;
},
(game) => {
delete game.pendingIxRicheseTechnology;
},
(game) => {
if (game.decision?.kind === 'ixRicheseTechnology')
game.decision.event += '-forged';
},
(game) => {
game.ixRicheseTechnologyEvent += '-forged';
},
(game) => {
const duplicate = structuredClone(game.decision) as Decision;
game.pendingExchange = { response: null, decision: duplicate };
},
];
for (const corrupt of corruptions) {
const game = structuredClone(genuine);
corrupt(game);
f.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, f.code);
const before = snapshot(f, f.code);
const original = structuredClone(game);
assert.throws(() => viewGame(game, f.ixAuth.playerId), /saved Ixian/);
assert.deepEqual(game, original);
assert.throws(() => normalizeAutomaticGame(game), /saved Ixian/);
assert.deepEqual(game, original);
await assert.rejects(f.rooms.readSeatView(f.code, f.ixAuth), /saved Ixian/);
await assert.rejects(
f.rooms.act(f.code, f.ixAuth, game.version, decline(genuine), clock),
/saved Ixian/,
);
assert.deepEqual(snapshot(f, f.code), before);
}
});
void test('concurrent authenticated declines persist one Richese lot continuation behind the room CAS', async (t) => {
const f = await fixture(t);
const { game: pending, offered } = await openIxChoice(f, 'blackMarket');
const inventory = physicalCards(pending);
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const attempts = await Promise.allSettled([
f.rooms.act(f.code, f.ixAuth, pending.version, decline(pending), clock),
f.restart().act(f.code, f.ixAuth, pending.version, decline(pending), clock),
]);
delete f.hooks.beforeWrite;
assert.equal(
attempts.filter((attempt) => attempt.status === 'fulfilled').length,
1,
);
assert.equal(
attempts.filter((attempt) => attempt.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const resumed = await f.restart().readRoom(f.code);
assert.equal(resumed.version, pending.version + 1);
assert.equal(resumed.richeseAuction?.cardId, offered.id);
assert.equal(resumed.richeseAuction?.source, 'blackMarket');
assert.equal(resumed.pendingIxRicheseTechnology, undefined);
assert.equal(resumed.ixTechnologyTurn, undefined);
assert.deepEqual(physicalCards(resumed), inventory);
assert.equal(
resumed.log.filter((entry) =>
entry.text.includes('continued without exchanging this Richese lot'),
).length,
1,
);
});
+522
View File
@@ -0,0 +1,522 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction,
createGame,
initializeFactionExpansionsGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
prepareSpecialKaramaIntent,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function fixture(advanced = true, harkonnen = false) {
let g = createGame('IXRICH', newPlayer('r', 'Richese', 'richese'), advanced, [
'ix',
'choam',
]);
joinGame(g, newPlayer('i', 'Ixians', 'ixians'));
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
if (harkonnen) joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
g = initializeFactionExpansionsGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 20; step++) {
let next: Game | undefined;
for (const p of g.players) {
const view = viewGame(g, p.id);
view.players.find((p) => p.id === view.me)!.bot = 'Medium';
const action = botActions(view)[0];
if (action) {
next = applyAction(g, p.id, action);
break;
}
}
assert.ok(next);
g = next;
}
assert.equal(g.status, 'playing');
// Focus the genuine initialized inventory on the Charity-to-Bidding transition.
Object.assign(g, {
phase: 2,
ready: [],
phaseOpening: null,
stormPending: null,
response: null,
decision: null,
});
for (const p of g.players) {
g.deck.push(...p.hand);
p.hand = [];
p.spice = 20;
}
for (const [index, kind] of ['shield', 'snooper', 'projectile'].entries()) {
const card = g.deck.findIndex((c) => c.kind === kind);
assert.ok(card >= 0);
g.players[index].hand.push(...g.deck.splice(card, 1));
}
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.phase, 3);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function richese(g: Game, fields: Omit<Action, 'type'>) {
return applyAction(g, 'r', {
type: 'decision',
event: g.richeseBidding!.event,
...fields,
});
}
function offer(source: 'cache' | 'blackMarket', method = 'silent') {
let g = fixture();
if (source === 'cache') {
g = richese(g, { decline: true });
g = richese(g, { position: 'first' });
}
const card = source === 'cache' ? g.richeseCache![0] : g.players[0].hand[0];
const before = structuredClone(g);
g = richese(g, {
card: card.id,
method,
direction: 'counterclockwise',
claim: ' a private draft claim ',
});
return { g, card, before };
}
function decline(g: Game) {
return applyAction(reload(g), 'i', {
type: 'decision',
event: g.pendingIxRicheseTechnology!.event,
decline: true,
});
}
function stock(g: Game) {
return {
deck: g.deck,
discard: g.discard,
hands: g.players.map((p) => p.hand),
spice: g.players.map((p) => p.spice),
cache: g.richeseCache,
removed: g.richeseRemoved,
normalCount: g.richeseBidding!.normalCount,
blackMarketSold: g.richeseBidding!.blackMarketSold,
ixTechnologyTurn: g.ixTechnologyTurn,
};
}
function reject(g: Game, player: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, player, action));
assert.deepEqual(g, before);
}
for (const source of ['cache', 'blackMarket'] as const)
void test(`${source} offer pauses before bidding or inspection and saved explicit Ixian decline preserves all custody, funds and Technology`, () => {
for (const method of source === 'cache'
? ['silent', 'onceAround']
: ['silent', 'onceAround', 'normal']) {
const { g, card, before } = offer(source, method);
assert.equal(g.decision?.kind, 'ixRicheseTechnology');
assert.equal(g.decision?.player, 'i');
assert.equal(g.response, null);
assert.equal(g.richeseAuction, null);
assert.equal(g.auction, null);
assert.deepEqual(stock(g), stock(before));
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
const event = g.pendingIxRicheseTechnology!.event;
for (const p of g.players)
assert.deepEqual(viewGame(reload(g), p.id), viewGame(g, p.id));
const resumed = decline(g);
assert.equal(resumed.pendingIxRicheseTechnology, undefined);
assert.equal(resumed.ixRicheseTechnologyEvent, undefined);
assert.deepEqual(stock(resumed), stock(g));
assert.equal(resumed.richeseAuction!.cardId, card.id);
assert.equal(resumed.richeseAuction!.source, source);
assert.equal(resumed.richeseAuction!.method, method);
assert.equal(
resumed.richeseClaim,
source === 'blackMarket' ? 'a private draft claim' : null,
);
assert.equal(viewGame(resumed, 'a').richeseAuction!.card?.id, card.id);
reject(resumed, 'i', { type: 'decision', event, decline: true });
}
});
void test('Black Market pending projections conceal card identity, terms and receipt from Ixians and Atreides before inspection', () => {
const { g, card } = offer('blackMarket');
for (const id of ['i', 'a']) {
const view = viewGame(g, id),
json = JSON.stringify(view);
assert.equal(json.includes(card.id), false);
assert.equal(json.includes('a private draft claim'), false);
assert.equal('pendingIxRicheseTechnology' in view, false);
assert.equal('ixRicheseTechnologyEvent' in view, false);
assert.deepEqual(view.ixRicheseTechnology, {
event: g.pendingIxRicheseTechnology!.event,
player: 'i',
owner: 'r',
source: 'blackMarket',
exchangeBlocked: view.ixRicheseTechnology!.exchangeBlocked,
});
assert.equal(view.richeseBidding!.offerBlocked, null);
}
assert.equal(JSON.stringify(g.log).includes(card.id), false);
assert.equal(JSON.stringify(g.log).includes('a private draft claim'), false);
});
void test('decline requires the current Ixian owner and exact explicit action; exchange and stale offers remain guarded atomically', () => {
const { g } = offer('cache'),
event = g.pendingIxRicheseTechnology!.event;
for (const action of [
{ type: 'decision', event },
{ type: 'decision', event, decline: false },
{ type: 'decision', event, card: g.players[1].hand[0].id },
{ type: 'decision', event, decline: true, card: 'extra' },
{ type: 'decision', event: 'expired', decline: true },
{ type: 'ready' },
])
reject(g, 'i', action);
reject(g, 'r', { type: 'decision', event, decline: true });
reject(g, 'a', { type: 'decision', event, decline: true });
reject(g, 'r', {
type: 'decision',
event: g.richeseBidding!.event,
card: g.richeseCache![1].id,
method: 'silent',
});
reject(g, 'a', { type: 'richeseBid', event, amount: 1 });
});
void test('saved receipt, independent marker, unique decision and exact physical offer cannot be forged or lost before view, action or normalization', () => {
const { g } = offer('blackMarket');
const mutations: Array<(g: Game) => void> = [
(g) => {
delete g.pendingIxRicheseTechnology;
},
(g) => {
delete g.ixRicheseTechnologyEvent;
},
(g) => {
g.decision = null;
},
(g) => {
g.decision!.player = 'r';
},
(g) => {
(
g.decision as Extract<
NonNullable<Game['decision']>,
{ kind: 'ixRicheseTechnology' }
>
).event = 'stale';
},
(g) => {
g.pendingIxRicheseTechnology!.event = 'stale';
},
(g) => {
g.pendingIxRicheseTechnology!.source = 'cache';
},
(g) => {
g.pendingIxRicheseTechnology!.method = 'normal';
},
(g) => {
g.pendingIxRicheseTechnology!.claim = 'changed';
},
(g) => {
g.pendingIxRicheseTechnology!.card.name = 'changed';
},
(g) => {
g.pendingIxRicheseTechnology!.round = 'stale';
},
(g) => {
g.turn++;
},
(g) => {
g.richeseBidding!.normalCount = 4;
},
(g) => {
g.richeseBidding!.event = 'replaced';
},
(g) => {
g.ixTechnologyTurn = g.turn;
},
(g) => {
g.deck.push(g.players[0].hand[0]);
},
(g) => {
g.deck.push(...g.players[0].hand.splice(0, 1));
},
(g) => {
g.players[0].hand[0].name = 'changed physical card';
},
(g) => {
g.pendingExchange = {
response: null,
decision: structuredClone(g.decision),
};
},
];
for (const mutate of mutations) {
const bad = reload(g);
mutate(bad);
const before = structuredClone(bad);
assert.throws(() => viewGame(bad, 'i'));
assert.throws(() => normalizeAutomaticGame(bad));
assert.throws(() =>
applyAction(bad, 'i', {
type: 'decision',
event: g.pendingIxRicheseTechnology!.event,
decline: true,
}),
);
assert.deepEqual(bad, before);
}
});
void test('Black Market decline does not use Technology; a later cache has a fresh choice and normal lots still offer their original exchange', () => {
let { g } = offer('blackMarket');
const first = g.pendingIxRicheseTechnology!.event;
g = decline(g);
for (const p of g.players)
g = applyAction(g, p.id, {
type: 'richeseBid',
event: g.richeseAuction!.event,
amount: 0,
});
assert.equal(g.decision?.kind, 'richeseDeclaration');
g = richese(g, { position: 'first' });
g = richese(g, { card: g.richeseCache![0].id, method: 'silent' });
assert.equal(g.decision?.kind, 'ixRicheseTechnology');
assert.notEqual(g.pendingIxRicheseTechnology!.event, first);
reject(g, 'i', { type: 'decision', event: first, decline: true });
g = decline(g);
for (const p of g.players)
g = applyAction(g, p.id, {
type: 'richeseBid',
event: g.richeseAuction!.event,
amount: 0,
});
assert.equal(g.decision?.kind, 'richeseUnbid');
g = richese(g, { keep: false });
assert.equal(g.decision?.kind, 'ixAuction');
const cards = g.ixAuction!.cards!;
g = applyAction(g, 'i', {
type: 'decision',
card: cards[0].id,
position: 'bottom',
});
assert.equal(g.decision?.kind, 'ixTechnology');
const replacement = g.players[1].hand[0],
taken = g.auction!.cards[g.auction!.index];
g = applyAction(g, 'i', { type: 'decision', card: replacement.id });
assert.equal(g.ixTechnologyTurn, g.turn);
assert.ok(g.players[1].hand.some((c) => c.id === taken.id));
assert.equal(g.auction!.cards[g.auction!.index].id, replacement.id);
});
void test('Basic offers and Advanced already-used Technology keep the original direct special-lot path', () => {
for (const advanced of [false, true]) {
let g = fixture(advanced);
if (advanced) {
g.ixTechnologyTurn = g.turn;
g = richese(g, { decline: true });
}
g = richese(g, { position: 'first' });
g = richese(g, { card: g.richeseCache![0].id, method: 'silent' });
assert.ok(g.richeseAuction);
assert.equal(g.pendingIxRicheseTechnology, undefined);
assert.equal(viewGame(g, 'i').ixRicheseTechnology, null);
}
});
void test('an independent Richese gift can suspend the Ixian lot choice and restore the same private commitment after a saved response', () => {
let g = fixture();
g.players[0].ally = 'i';
g.players[1].ally = 'r';
const gift = g.richeseCache!.splice(0, 1)[0];
g.players[0].hand.push(gift);
const karamaIndex = g.deck.findIndex((c) => c.effect === 'karama');
assert.ok(karamaIndex >= 0);
g.players[2].hand.push(...g.deck.splice(karamaIndex, 1));
g = richese(g, { card: g.players[0].hand[0].id, method: 'silent' });
const commitment = structuredClone(g.pendingIxRicheseTechnology);
g = applyAction(g, 'r', { type: 'richeseGift', card: gift.id });
assert.equal(g.decision, null);
assert.equal(g.response?.kind, 'richeseGift');
assert.equal(
g.pendingRicheseGift!.resume.decision?.kind,
'ixRicheseTechnology',
);
assert.deepEqual(g.pendingIxRicheseTechnology, commitment);
for (const id of ['r', 'i', 'a'])
assert.deepEqual(viewGame(reload(g), id), viewGame(g, id));
g = applyAction(reload(g), 'a', { type: 'passResponse' });
assert.equal(g.decision?.kind, 'ixRicheseTechnology');
assert.deepEqual(g.pendingIxRicheseTechnology, commitment);
assert.ok(g.players[1].hand.some((c) => c.id === gift.id));
g = decline(g);
assert.equal(g.richeseAuction!.cardId, commitment!.card.id);
assert.equal(g.ixTechnologyTurn, undefined);
});
void test('the pending physical card cannot begin a competing gift, paid Box search, or cache acquisition response', () => {
for (const source of ['cache', 'blackMarket'] as const) {
let g = fixture();
g.players[0].ally = 'i';
g.players[1].ally = 'r';
const karamaIndex = g.deck.findIndex((c) => c.effect === 'karama');
const karama = g.deck.splice(karamaIndex, 1)[0];
g.players[0].hand.push(karama);
if (source === 'cache') {
g = richese(g, { decline: true });
g = richese(g, { position: 'first' });
for (const p of g.players)
if (g.response && !g.response.passed.includes(p.id))
g = applyAction(g, p.id, { type: 'passResponse' });
g = richese(g, { card: g.richeseCache![0].id, method: 'silent' });
reject(g, 'r', {
type: 'card',
mode: 'special',
card: karama.id,
acquire: g.pendingIxRicheseTechnology!.card.id,
});
} else {
const index = g.richeseCache!.findIndex(
(c) => c.effect === 'nullentropyBox',
);
const card = g.richeseCache!.splice(index, 1)[0];
g.players[0].hand.push(card);
g.discard.push(...g.deck.splice(0, 1));
g = richese(g, { card: card.id, method: 'silent' });
reject(g, 'r', { type: 'richeseGift', card: card.id });
reject(g, 'r', { type: 'card', card: card.id });
assert.equal(
viewGame(g, 'r').richeseGift?.cards.some((c) => c.id === card.id),
false,
);
}
assert.equal(g.response, null);
assert.equal(g.pendingNullentropy, undefined);
assert.equal(g.pendingRicheseGift, undefined);
assert.equal(g.decision?.kind, 'ixRicheseTechnology');
}
});
void test('spending the last Ixian hand card in an independent Truthtrance leaves the saved per-lot decline available', () => {
let g = fixture();
g.deck.push(...g.players[1].hand.splice(0));
const index = g.deck.findIndex((c) => c.effect === 'truthtrance');
const truth = g.deck.splice(index, 1)[0];
g.players[1].hand.push(truth);
g = richese(g, { card: g.players[0].hand[0].id, method: 'silent' });
const receipt = structuredClone(g.pendingIxRicheseTechnology);
g = applyAction(g, 'i', { type: 'card', card: truth.id });
while (g.truthtrance?.stage === 'priority') {
const player = g.players.find(
(p) => !g.truthtrance!.passed.includes(p.id),
)!;
g = applyAction(g, player.id, { type: 'truthPass' });
}
g = applyAction(g, 'i', {
type: 'truthAsk',
question: {
kind: 'fact',
target: 'a',
fact: { kind: 'hand', name: g.players[2].hand[0].name },
},
});
g = applyAction(reload(g), 'a', { type: 'truthAnswer', answer: 'yes' });
assert.equal(g.players[1].hand.length, 0);
assert.deepEqual(g.pendingIxRicheseTechnology, receipt);
assert.equal(g.decision?.kind, 'ixRicheseTechnology');
g = decline(g);
assert.equal(g.richeseAuction!.cardId, receipt!.card.id);
assert.equal(g.ixTechnologyTurn, undefined);
});
void test('Harkonnen random exchange rejects the reserved Black Market hand before RNG in live and suspended Ixian choices', (t) => {
const g = fixture(true, true);
const karama = g.deck.splice(
g.deck.findIndex((c) => c.effect === 'karama'),
1,
)[0];
g.players[3].hand.push(karama);
g.players[0].hand.push(
g.deck.splice(
g.deck.findIndex((c) => c.kind === 'worthless'),
1,
)[0],
);
const box = g.richeseCache!.splice(
g.richeseCache!.findIndex((c) => c.effect === 'nullentropyBox'),
1,
)[0];
g.players[1].hand.push(box);
g.discard.push(
g.deck.splice(
g.deck.findIndex((c) => c.kind === 'worthless'),
1,
)[0],
);
g.discard.push(
g.deck.splice(
g.deck.findIndex((c) => c.kind === 'shield'),
1,
)[0],
);
const original = reload(g);
const games: Game[] = [];
for (const card of original.players[0].hand) {
const live = richese(original, { card: card.id, method: 'silent' });
const suspended = applyAction(live, 'i', { type: 'card', card: box.id });
assert.equal(
suspended.pendingNullentropy!.resume.decision?.kind,
'ixRicheseTechnology',
);
assert.ok(viewGame(suspended, 'i').ixRicheseTechnology);
games.push(live, suspended);
}
let cache = richese(original, { decline: true });
cache = richese(cache, { position: 'first' });
cache = applyAction(cache, 'h', { type: 'passResponse' });
cache = richese(cache, { card: cache.richeseCache![0].id, method: 'silent' });
const random = t.mock.method(crypto, 'getRandomValues', () => {
throw new Error('Unexpected random selection');
});
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw new Error('Unexpected event allocation');
});
for (const state of games)
for (const amount of [1, 2]) {
const action = {
type: 'card',
mode: 'special',
card: karama.id,
target: 'r',
amount,
};
const before = structuredClone(state);
assert.throws(
() => prepareSpecialKaramaIntent(state, 'h', action),
/pending Black Market Ixian choice/,
);
assert.throws(() => applyAction(state, 'h', action));
assert.deepEqual(state, before);
}
// A cache lot reserves the separate cache card, not Richese's unrelated hand.
assert.equal(
prepareSpecialKaramaIntent(cache, 'h', {
type: 'card',
mode: 'special',
card: karama.id,
target: 'r',
amount: 1,
}).kind,
'harkonnen',
);
assert.equal(random.mock.callCount(), 0);
assert.equal(uuid.mock.callCount(), 0);
});
+31 -23
View File
@@ -320,34 +320,42 @@ void test('normal paid auctions after a cache sale route income to Emperor witho
assert.equal(g.auction!.index, 1);
});
void test('advanced unused Ixian Technology explicitly fences special lots when Ixians hold a replacement', () => {
void test('advanced unused Ixian Technology pauses special lots for an explicit per-lot decline', () => {
let g = fixture(true);
g.players[2].faction = 'ixians';
g.players[2].hand = [g.deck.shift()!];
reject(
g,
'r',
{
type: 'decision',
event: g.richeseBidding!.event,
card: g.players[0].hand[0].id,
method: 'silent',
},
/Ixian Technology/,
);
const blackMarket = applyAction(g, 'r', {
type: 'decision',
event: g.richeseBidding!.event,
card: g.players[0].hand[0].id,
method: 'silent',
});
assert.equal(blackMarket.decision?.kind, 'ixRicheseTechnology');
assert.equal(blackMarket.richeseAuction, null);
const resumed = applyAction(blackMarket, g.players[2].id, {
type: 'decision',
event: blackMarket.pendingIxRicheseTechnology!.event,
decline: true,
});
assert.equal(resumed.richeseAuction?.source, 'blackMarket');
assert.equal(resumed.ixTechnologyTurn, g.ixTechnologyTurn);
g = decision(g, { decline: true });
g = decision(g, { position: 'first' });
reject(
g,
'r',
{
type: 'decision',
event: g.richeseBidding!.event,
card: g.richeseCache![0].id,
method: 'silent',
},
/Ixian Technology/,
);
const cache = applyAction(g, 'r', {
type: 'decision',
event: g.richeseBidding!.event,
card: g.richeseCache![0].id,
method: 'silent',
});
assert.equal(cache.decision?.kind, 'ixRicheseTechnology');
assert.equal(cache.richeseAuction, null);
const resumedCache = applyAction(cache, g.players[2].id, {
type: 'decision',
event: cache.pendingIxRicheseTechnology!.event,
decline: true,
});
assert.equal(resumedCache.richeseAuction?.source, 'cache');
assert.equal(resumedCache.ixTechnologyTurn, g.ixTechnologyTurn);
g.ixTechnologyTurn = g.turn;
g = decision(g, { card: g.richeseCache![0].id, method: 'silent' });
assert.equal(g.richeseAuction!.source, 'cache');
+22 -8
View File
@@ -3,10 +3,22 @@ import {
initializeIxGameForAudit,
initializeDiscoveryGameForAudit,
initializeLeaderSkillsGameForAudit,
initializeFactionExpansionsGameForAudit,
viewGame,
type Game,
} from '../game/engine';
export const PROTOTYPE_PROFILES = [
'ix',
'discovery',
'leader-skills',
'factions',
] as const;
export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number];
export function isPrototypeProfile(value: string): value is PrototypeProfile {
return PROTOTYPE_PROFILES.some((profile) => profile === value);
}
/** Local development only. This cannot create a seat, change its owner, or
* redeal a started game. The caller backs up the database before invoking it. */
export function startIxPrototypeRoom(
@@ -20,7 +32,7 @@ export function startPrototypeRoom(
db: DatabaseSync,
code: string,
expectedVersion: number,
profile: 'ix' | 'discovery' | 'leader-skills',
profile: PrototypeProfile,
) {
if (
!/^[A-Z0-9]{8}$/.test(code) ||
@@ -38,17 +50,19 @@ export function startPrototypeRoom(
const initial = JSON.parse(row.state) as Game;
if (initial.code !== code)
throw new Error('The room identity is inconsistent.');
if (!['ix', 'discovery', 'leader-skills'].includes(profile))
if (!isPrototypeProfile(profile))
throw new Error('Unknown prototype profile.');
const game =
profile === 'ix'
? initializeIxGameForAudit(initial)
: profile === 'leader-skills'
? initializeLeaderSkillsGameForAudit(initial)
: initializeDiscoveryGameForAudit({
...initial,
discoveryEnabled: true,
});
: profile === 'factions'
? initializeFactionExpansionsGameForAudit(initial)
: profile === 'leader-skills'
? initializeLeaderSkillsGameForAudit(initial)
: initializeDiscoveryGameForAudit({
...initial,
discoveryEnabled: true,
});
// Exercise the same player projection before accepting the new saved state.
for (const player of game.players) viewGame(game, player.id);
const version = expectedVersion + 1;
+4 -9
View File
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
import { captureSavedGames } from './saved-game-verification';
import { startPrototypeRoom } from './prototype-room';
import { isPrototypeProfile, startPrototypeRoom } from './prototype-room';
import { sourceSnapshot } from './verification';
async function main() {
@@ -20,12 +20,12 @@ async function main() {
});
if (values.help) {
console.log(
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|factions --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Factions supports selected expansion factions without optional modules; the separate Ecaz Treachery variant stays disabled. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
);
return;
}
if (
!['ix', 'discovery', 'leader-skills'].includes(values.profile) ||
!isPrototypeProfile(values.profile) ||
!values.db ||
!values.room ||
!values.out ||
@@ -46,12 +46,7 @@ async function main() {
throw new Error('The requested lobby version is absent from the backup.');
const db = new DatabaseSync(values.db);
try {
const result = startPrototypeRoom(
db,
values.room,
version,
values.profile as 'ix' | 'discovery' | 'leader-skills',
);
const result = startPrototypeRoom(db, values.room, version, values.profile);
writeFileSync(
resolve(values.out, 'prototype.json'),
JSON.stringify(