diff --git a/AGENTS.md b/AGENTS.md
index e44d3b49..47fbacd6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,7 +41,9 @@ for the in-memory SQLite tests. `tsx` is a pinned development dependency.
checkpoints under standing authorization. No user-imposed usage cutoff or
routine account-usage check remains.
- Keep material rulings explicit and prototype independent work while pending.
- Prototype evidence does not open mode or publication gates.
+ The user-authorized Advanced preview permits unfinished base-faction starts
+ with a visible warning; see `docs/ADVANCED_PREVIEW.md`. Prototype evidence does
+ not certify complete modes or open expansion/publication gates.
## Implement and verify
diff --git a/README.md b/README.md
index f4519f4b..81bc34b0 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Dune — Arrakis Table
-An unofficial multiplayer adaptation of Gale Force Nine’s classic 2019 Dune, built with React, TypeScript, Vinext, and Cloudflare Workers/D1. **Development is ongoing; this is not yet a complete rules implementation.** Advanced and unfinished expansion starts remain disabled. Start with [current status](docs/CURRENT_STATUS.md) for implemented systems, remaining requirements and release gates; detailed historical evidence remains in the [implementation log](docs/IMPLEMENTATION_STATUS.md).
+An unofficial multiplayer adaptation of Gale Force Nine’s classic 2019 Dune, built with React, TypeScript, Vinext, and Cloudflare Workers/D1. **Development is ongoing; this is not yet a complete rules implementation.** The six classic factions can use a clearly labeled **Advanced preview**; unfinished expansion starts remain disabled. Start with [current status](docs/CURRENT_STATUS.md) for implemented systems, remaining requirements and release gates; detailed historical evidence remains in the [implementation log](docs/IMPLEMENTATION_STATUS.md).
## Run locally
@@ -14,6 +14,11 @@ npm run dev -- --host 0.0.0.0
Open `http://localhost:3000`. Create a room, share its eight-character invitation, and add human or AI seats. The host can configure Easy, Medium, Hard and Brutal AI opponents. Current calibration demonstrates a substantially weaker Easy policy; it does not yet demonstrate a consistent ordering among the upper three levels.
+For Advanced play, choose **Rules → Advanced preview** before creating a room,
+or use the same selector as host in an existing base-game lobby. Everyone must
+ready again after changing rules, then the host chooses **Begin Advanced preview**.
+This mode is unfinished; see [preview scope and limits](docs/ADVANCED_PREVIEW.md).
+
Before starting, open **Configure** on an AI seat to change its difficulty,
faction or player circle without replacing it. Changes save immediately and
clear human readiness. See [lobby AI configuration](docs/LOBBY_AI_CONFIGURATION.md).
diff --git a/app/globals.css b/app/globals.css
index 157ed743..0a88451b 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -473,6 +473,10 @@ input[data-slot='input'] {
line-height: 1.8;
color: #a8af9c;
}
+.development-note.advanced-preview-note {
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
.development-note {
font-size: 11px;
line-height: 1.6;
diff --git a/app/page.tsx b/app/page.tsx
index ed502013..5a6ac215 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -3,6 +3,7 @@ import Link from 'next/link';
import { useState, useEffect, useCallback, useRef } from 'react';
import { TableTalk } from '@/components/table-talk';
import { GameTable } from '@/components/game-table';
+import { RulesetControls } from '@/components/ruleset-controls';
import {
SeatHandoverSetup,
SeatHandoverClaim,
@@ -68,6 +69,7 @@ export default function Home() {
const [busy, setBusy] = useState(false);
const [connection, setConnection] = useState('');
const [selected, setSelected] = useState('atreides');
+ const [advanced, setAdvanced] = useState(false);
const [expansions, setExpansions] = useState([]);
const [name, setName] = useState('');
const [code, setCode] = useState('');
@@ -423,7 +425,7 @@ export default function Home() {
let attempt: RoomEntryAttempt;
try {
attempt = createRoomEntry(
- { name, faction: selected, expansions, advanced: false },
+ { name, faction: selected, expansions, advanced },
join ? code : undefined,
);
} catch {
@@ -1014,10 +1016,17 @@ export default function Home() {
placeholder="Enter your name"
maxLength={32}
/>
-
- Playable core under development. Advanced rules and expansion
- games are not yet available.
+ Playable core under development. Advanced is available as an
+ unfinished preview. Expansion games are not yet available.
+ Advanced preview · Rules and card interactions are still
+ being completed.{' '}
+ {compact
+ ? 'Some play may need fixes.'
+ : 'Use the six classic factions; expansion games are not available. Tech Tokens and Stronghold Cards can be enabled in the lobby.'}
+
+ {host
+ ? 'Changing rules clears player readiness. Switching to Basic removes Stronghold Cards.'
+ : 'The host chooses the rules. A change clears player readiness.'}
+
+ )}
+
+ );
+}
diff --git a/docs/ADVANCED_PREVIEW.md b/docs/ADVANCED_PREVIEW.md
new file mode 100644
index 00000000..f7e513bd
--- /dev/null
+++ b/docs/ADVANCED_PREVIEW.md
@@ -0,0 +1,69 @@
+# Advanced preview
+
+The user explicitly authorized a clearly labeled unfinished Advanced mode on
+21 September 2026. This changes access to the existing development game, not
+the rules-completion or publication criteria.
+
+## Select and start
+
+1. On the home page, choose **Rules → Advanced preview**, then **Create a room**.
+2. Invite players or add AI seats. The preview supports two through six of the
+ six classic factions: Atreides, Harkonnen, Fremen, Emperor, Guild and Bene Gesserit.
+3. Optionally enable **Tech Tokens** with at least three players, or
+ **Stronghold Cards** in the lobby.
+4. Each human chooses **Ready for Advanced preview**. The host chooses
+ **Begin Advanced preview**.
+
+The host can also change **Rules** in an existing base-game lobby. Changing modes
+clears human readiness; native AI seats remain ready. Switching to Basic removes
+unused Stronghold Cards and keeps Tech Tokens. Rules cannot change after setup
+starts. Selecting Advanced on the home page clears expansion selections, which
+are outside this preview. Joining a room uses that room's existing rules.
+
+The warning remains visible throughout Advanced play, including older Advanced
+development saves. Games use the existing room save, reconnect and private seat
+system. Basic remains the default.
+
+## Admission and saved behavior
+
+Normal room creation already stores `advanced: boolean`; the same request format
+and exact tab retry are retained. The host-only lobby action
+`{type: 'rules', advanced: boolean}` changes the mode. The ordinary start action
+must contain `advancedPreview: true` for Advanced; a bare start remains rejected.
+Only a successful explicit start writes `Game.advancedPreview: true`.
+`GameView.advancedPreview` projects the marker as a boolean.
+
+Start calls the genuine shared setup pipeline, including prediction, Traitors,
+starting forces, advisors, elites and the starting Treachery deal at their normal
+stages. The live API never dispatches a test/audit initializer. Fresh-lobby and
+unused-module checks prevent accidental redealing. Restored preview markers must
+still match their supported mode and roster. Mode changes, starts and ordinary
+continuation retain the existing version/CAS fence and private views.
+
+Expansion factions/decks, Homeworlds, Nexus, Discoveries, Leader Skills, the Ecaz
+Treachery variant and separate audit profiles remain rejected by normal starts.
+Enabling Advanced cannot bypass those gates. For compatibility with exact older
+create retries, the create API still accepts the pre-existing expansion lobby
+format; those unsupported lobbies cannot start and have no expansion-removal
+control yet. The preview creation UI prevents that combination. No pending ruling is resolved by
+this access change; see the [decision index](RULE_DECISIONS.md).
+
+## Evidence and limits
+
+- `advanced-preview.test.ts`: normal explicit starts, strict authority/admission,
+ readiness, Basic fallback, genuine setup for all 57 base rosters with each of
+ four existing legal AI profiles, card/force custody, JSON and private views.
+- `advanced-preview-controls.test.ts`: visible mode/warning/host controls and
+ exact Advanced create-request restoration.
+- `advanced-preview-recovery.test.ts`: production room SQL, fresh module reload,
+ both private seats, unchanged rejected requests and start/rules CAS races.
+- `advanced-preview-http.test.ts`: normal authenticated endpoints, explicit
+ opt-in, origin/authority checks, readiness, duplicate start and refresh.
+
+The private checkpoint directory records final check/build/HTTP results, ordinary
+preview-start complete-game samples, browser checks and saved-game preservation.
+These are development samples, not AI calibration or complete interaction coverage.
+Advanced base powers and timing audits remain unfinished, including the recorded
+Guild/special-Karama boundaries and advisor interactions. Stronghold Cards and
+Tech Tokens retain their existing implementation limits. Full visual acceptance,
+complete rules compliance and publication remain open.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 1b626462..196d3cda 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -1,26 +1,26 @@
# Current development status
Updated 21 September 2026. **The full twelve-faction goal is unfinished. Basic
-is playable; public Advanced and unfinished expansion/module starts remain
-gated.** Prototypes do not open completion or publication gates.
+is playable and the six classic factions have a user-authorized Advanced preview.
+Expansion starts and full rules/publication acceptance remain gated.**
## Current checkpoint and work
-Latest delivered checkpoint: `a3bc0ac`, [completed-action feedback](ACTION_FEEDBACK.md).
-Types, lint, **5,137 offline tests** and the production build passed. Two complete
-Basic/Advanced samples had no rejected actions across 821 actions and 21 JSON
-restores. Browser burst notices, local interaction, muted updates and exact refresh
-were checked. All 1,585 prior rooms and 3,353 seats remained intact; one QA
-room/seat brought the baseline to 1,586 rooms and 3,354 human seats. Full visual
-acceptance remains open.
+Latest delivered checkpoint: `0f79f28`, [forced Harkonnen exchange return](HARKONNEN_EXCHANGE.md).
+Types, lint, **5,150 offline tests**, **49 HTTP tests** and the production build
+passed. One complete Advanced sample had no rejected actions across 158 actions
+and four JSON restores. Browser inspection and exact refresh were checked;
+full visual acceptance remains open. All 1,586 prior rooms and 3,354 human seats
+were preserved; new QA/HTTP rooms brought the baseline to 1,626 rooms and 3,442 seats.
-Current work connects the [forced Harkonnen special-Karama return](HARKONNEN_EXCHANGE.md)
-when every held card must go back. Automatic settlement preserves a private saved
-record of the inspected cards and the original interruption. Larger hands keep
-their real return choice; legacy records identify held cards without inventing
-which were originally drawn. The server also schedules older saved exchanges for
-automatic recovery while preserving real choices. Independent review is complete;
-final source-bound checks and browser evidence accompany the Git checkpoint.
+Current work exposes [Advanced preview](ADVANCED_PREVIEW.md) through normal room
+creation and host lobby controls. It uses genuine setup, explicit unfinished-mode
+start, renewed human readiness after mode changes, private seat restoration and
+saved continuation. Six classic factions, existing Tech Tokens (3+ players) and
+Stronghold Cards are admitted; expansion/module gates and pending rulings remain.
+Focused engine, controls, retry and concurrent recovery checks are connected;
+final independent review, browser, sample-game and required checks accompany the
+Git checkpoint. Preview access does not certify complete rules compliance.
The [Sandmaster/HMS source comparison](SANDMASTER_MOVEMENT.md#21-september-native-hms-relocation-eligibility)
found no official clarification that passengers remaining in the interior
@@ -94,7 +94,7 @@ These links define bounded working behavior, not complete module certification.
| Area | Current connected work and evidence |
| --- | --- |
-| Base/Advanced setup | [Genuine setup](ADVANCED_SETUP_TEST_SEAM.md), [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md), [Fremen cancellation](FREMEN_MOVEMENT_KARAMA_RULES.md), [Atreides full-plan audit](ATREIDES_FULL_PLAN_TIMING.md). Earlier Basic-then-flip fixtures do not establish current setup coverage. |
+| Base/Advanced setup | [Normal Advanced preview](ADVANCED_PREVIEW.md), [genuine setup](ADVANCED_SETUP_TEST_SEAM.md), [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md), [Fremen cancellation](FREMEN_MOVEMENT_KARAMA_RULES.md), [Atreides full-plan audit](ATREIDES_FULL_PLAN_TIMING.md). Earlier Basic-then-flip fixtures do not establish current setup coverage. |
| Expansion factions | [Ixians/Tleilaxu](IX_PROTOTYPE.md) and [all seven faction selections](EXPANSION_FACTIONS_PROTOTYPE.md): genuine setup, Ecaz six-force placement and [Advanced Loyalty](ECAZ_LOYALTY.md), separate optional card variants, private controls and saved per-lot Ixian/Richese decline. Actual special-lot exchange remains pending. |
| Leader Skills | [Common lifecycle](LEADER_SKILLS_RUNTIME.md), [known skilled capture](LEADER_SKILLS_CAPTURE.md), [Mentat question preview](MENTAT_QUESTION.md), [Bureaucrat payments](BUREAUCRAT_PAYMENTS.md), five battle disciplines, [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md), [Rihani and other battle effects](LEADER_BATTLE_EFFECTS.md), [Smuggler shipment](SMUGGLER_SHIPMENT.md), [No-Field](SMUGGLER_NO_FIELD.md) and [battle collection](SMUGGLER_BATTLE.md), [Sandmaster routes](SANDMASTER_MOVEMENT.md) and [worm rides](SANDMASTER_WORM.md), [Banker spending](SPICE_BANKER_RUNTIME.md), [Diplomat defense](DIPLOMAT_DEFENSE.md). Remaining bands and combinations are explicit in those contracts. |
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. |
@@ -108,7 +108,7 @@ These links define bounded working behavior, not complete module certification.
| Area | Remaining gate |
| --- | --- |
| Basic core | Ordinary Guild repricing/transport settlement, broader truthful commitments, unresolved special timing and integrated rules/human/AI acceptance. |
-| Six-faction Advanced | Base gaps, provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. Setup matrices and complete samples do not open public starts. |
+| Six-faction Advanced | Base gaps, provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. The user-authorized unfinished preview is available; setup matrices and complete samples do not certify full compliance. |
| Twelve factions and all optional modules | Every missing effect, remaining skill bands, Ecaz card variant, Kull Wahad, unfinished Richese cards, Terror effects, occupation entitlement, Nexus interactions, contested Discoveries and complete games across valid combinations. Detailed boundaries remain in the checklist/decision index. |
| AI | Maintain minimal legal participation and critical correctness fixes while game features are unfinished. Full implementation, strategy refinement and calibration wait until all non-AI features are complete; then target approximately 75% higher-tier wins for Medium/Easy, Hard/Medium and Brutal/Hard. See the [AI development plan](AI_DEVELOPMENT_PLAN.md). These targets remain unverified. |
| Multiplayer | Broader disconnected/abandoned-seat recovery, unattended continuation and network-failure acceptance, while preserving authoritative versioning, custody, privacy and saves. |
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 3820dfcb..df6bdc2a 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,17 @@
# Dune implementation status
+## 21 September 2026 — Selectable Advanced preview
+
+The user-authorized [Advanced preview](ADVANCED_PREVIEW.md) now has room-creation
+and host lobby selectors, renewed readiness after mode changes, an explicit
+preview start and a persistent unfinished-rules notice. It uses genuine setup
+for two through six classic factions, with the existing Tech Token and Stronghold
+Card options. Saved mode integrity, fresh-piece checks, authenticated recovery
+and concurrent-start tests retain authority, custody and privacy. All 57 base
+rosters complete setup with each of four existing legal AI profiles. Full rules
+compliance, expansion starts and publication remain gated; final verification
+evidence stays in the private checkpoint report and Git message.
+
## 21 September 2026 — Harkonnen forced exchange return
Special-Karama exchanges now return automatically when the entire combined hand
diff --git a/game/engine.ts b/game/engine.ts
index 9e795af8..9a808787 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1554,6 +1554,8 @@ export type Game = {
mentatHistory?: MentatObservation[];
mentatHistoryEvents?: string[];
advanced: boolean;
+ /** Explicit normal-lobby opt-in; unfinished rules remain labeled as a preview. */
+ advancedPreview?: true;
expansions: string[];
players: Player[];
/** Fixed printed player circles; absent only in rooms created before this rule fix. */
@@ -5082,26 +5084,93 @@ function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
);
return lostCyborgs;
}
-function start(g: Game) {
+function baseStartProfileBlock(g: Game): string | null {
+ if (g.homeworlds) return 'Homeworld gameplay is still being implemented.';
+ if (g.nexusCards) return 'Nexus card effects are still being implemented.';
+ if (g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker)
+ return 'Discoveries are still being implemented.';
+ if (g.leaderSkills) return 'Leader Skills are still being implemented.';
+ if (g.ecazTreachery) return 'Ecaz Treachery Cards are still being implemented.';
+ if (g.mentatQuestionPreview || g.moritaniAssassinatePreview || g.moritaniAssassinate)
+ return 'Separate development profiles cannot be enabled by a normal start.';
+ if (!g.players.every(p => FACTIONS.some(entry => entry.id === p.faction && entry.expansion === 'base')))
+ return 'Expansion factions are still being implemented.';
+ if (g.expansions.length !== 0) return 'Expansion rules are still being implemented.';
+ return null;
+}
+function advancedPreviewIntegrity(g: Game) {
+ if (g.advancedPreview === undefined) return;
+ requireRule(g.advancedPreview === true && g.advanced === true && g.status !== 'lobby' && !baseStartProfileBlock(g),
+ 'The saved Advanced preview no longer matches its supported rules profile.');
+}
+function requireFreshSetup(g: Game, allowIxElites = false) {
+ requireRule(g.status === 'lobby' && !g.setupStage && g.turn === 1 && g.phase === 0 && g.advancedPreview === undefined,
+ 'Start only from a fresh lobby.');
+ requireRule(g.players.length >= 2 && g.players.length <= 6 &&
+ new Set(g.players.map(p => p.id)).size === g.players.length &&
+ new Set(g.players.map(p => p.faction)).size === g.players.length &&
+ g.players.some(p => p.id === g.host) && g.players.every(p => p.id && p.ready),
+ 'Start requires two through six distinct ready players and an existing host.');
+ requireRule(!g.deck.length && !g.discard.length && !g.spiceDeck.length &&
+ !g.spiceDiscard.some(pile => pile.length) && !g.traitorReserve?.length &&
+ g.ecazLoyalty === undefined && !g.decision && !g.response && !g.battle && !g.winner.length &&
+ g.players.every(p => !p.hand.length && !p.traitors.length && !p.traitorChoices.length &&
+ Object.keys(p.forces).length === 0 && p.reserves === 20 && p.tanks === 0 && p.spice === 0 &&
+ p.moved === 0 && !p.shipped && !p.prediction && !p.advisorSetup &&
+ (!p.elites || (allowIxElites && p.faction === 'ixians' && p.elites.reserves === 7 &&
+ p.elites.tanks === 0 && p.elites.revived === 0 && Object.keys(p.elites.forces).length === 0))),
+ 'Starting setup cannot redeal or replace existing game pieces.');
+ requireRule(g.players.every(p => {
+ const native = leaders(p.faction);
+ return p.leaders.length === native.length && native.every(expected => {
+ const actual = p.leaders.find(leader => leader.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);
+ }) && p.ally === null && p.allySinceTurn === undefined && p.bribes === 0 && p.revived === 0 &&
+ (p.freeForcesRevived ?? 0) === 0 && p.leaderRevived === false && p.revivalCycle === 0 && p.battleLosses === 0 &&
+ !p.specialKaramaUsed && p.kwisatz === undefined && p.charityTurn === undefined &&
+ p.noField === undefined && p.noFieldEvent === undefined && p.noFieldBlockedTurn === undefined &&
+ p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined &&
+ p.fremenMovementBlocked === undefined && p.faceDancers === undefined &&
+ p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined;
+ }), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.');
+ requireRule(Object.keys(g.playerPositions ?? {}).every(id => g.players.some(p => p.id === id)),
+ 'Player circles must belong to seated players.');
+ normalizedPlayerPositions(g);
+}
+function requireFreshBaseRuntime(g: Game) {
+ requireRule((g.botsPending === undefined || g.botsPending === false) && g.botNextActionAt === undefined,
+ 'Start with no pending bot work or prior action deadline.');
+ const fresh = createGame(g.code, getPlayer(g, g.host), g.advanced);
+ // These are the live lobby settings and storage metadata. Module admission and
+ // unowned optional components are checked separately; gameplay fields stay fresh.
+ const configuration = new Set(['code', 'version', 'host', 'players', 'playerPositions',
+ 'advanced', 'expansions', 'log', 'botsPending', 'botNextActionAt', 'techTokens', 'strongholdCards',
+ 'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery']);
+ const keys = [...new Set([...Object.keys(g), ...Object.keys(fresh)])] as (keyof Game)[];
+ requireRule(keys.every(key => configuration.has(key) ||
+ JSON.stringify(g[key]) === JSON.stringify(fresh[key])),
+ 'Starting a base table cannot retain existing game resources, histories or continuations.');
+}
+function start(g: Game, advancedPreview = false) {
requireRule(g.players.length >= 2, 'At least two players are needed.');
requireRule(
g.players.every((p) => p.ready),
'Every player must be ready.',
);
- requireRule(!g.advanced, 'Advanced rules are still being implemented.');
- requireRule(!g.homeworlds, 'Homeworld gameplay is still being implemented.');
- requireRule(!g.nexusCards, 'Nexus card effects are still being implemented.');
- requireRule(!g.discoveryEnabled, 'Discoveries are still being implemented.');
- requireRule(!g.leaderSkills, 'Leader Skills are still being implemented.');
- requireRule(
- g.players.every((p) => faction(p.faction).expansion === 'base'),
- 'Expansion factions are still being implemented.',
- );
- requireRule(
- g.expansions.length === 0,
- 'Expansion rules are still being implemented.',
- );
+ requireRule(typeof g.advanced === 'boolean' && (!g.advanced || advancedPreview),
+ 'Advanced rules are unfinished. Explicitly choose Start Advanced preview to continue.');
+ requireRule(!advancedPreview || g.advanced, 'Choose Advanced preview rules before starting that preview.');
+ const blocked = baseStartProfileBlock(g);
+ requireRule(!blocked, blocked ?? 'This rules profile is unavailable.');
+ requireFreshSetup(g);
+ requireRule(!g.techTokens || JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens()),
+ 'Start with unused Tech Tokens before assigning their initial owners.');
+ requireRule(!g.strongholdCards || JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards()),
+ 'Start with unused Stronghold Cards before assigning ownership.');
+ requireFreshBaseRuntime(g);
initializeSetup(g);
+ if (advancedPreview) g.advancedPreview = true;
}
function skillRule(operation: () => T): T {
try {
@@ -5935,19 +6004,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
homeworldDefenseIntegrity(state);
homeworldShipmentIntegrity(state);
const g = structuredClone(state);
- requireRule(
- g.status === 'lobby' && !g.setupStage && g.turn === 1 && g.phase === 0,
- 'The audit initializer requires a fresh lobby.',
- );
- requireRule(
- g.players.length >= 2 &&
- g.players.length <= 6 &&
- new Set(g.players.map((p) => p.id)).size === g.players.length &&
- new Set(g.players.map((p) => p.faction)).size === g.players.length &&
- g.players.some((p) => p.id === g.host) &&
- g.players.every((p) => p.id && p.ready),
- 'The audit initializer requires two through six distinct ready players and an existing host.',
- );
+ requireFreshSetup(g, homeworlds || nexus || ix || factions);
requireRule(
(homeworlds || nexus || ix || choam || factions || g.expansions.length === 0) &&
(choam || factions || !g.expansions.includes('choam')) &&
@@ -5974,47 +6031,6 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
? 'The Homeworld setup audit supports implemented deck sets without Tech Tokens or Stronghold Cards.'
: 'The audit initializer supports base factions without expansions or optional modules.',
);
- requireRule(
- !g.deck.length &&
- !g.discard.length &&
- !g.spiceDeck.length &&
- !g.spiceDiscard.some((pile) => pile.length) &&
- !g.traitorReserve?.length &&
- g.ecazLoyalty === undefined &&
- !g.decision &&
- !g.response &&
- !g.battle &&
- !g.winner.length &&
- g.players.every(
- (p) =>
- !p.hand.length &&
- !p.traitors.length &&
- !p.traitorChoices.length &&
- Object.keys(p.forces).length === 0 &&
- p.reserves === 20 &&
- p.tanks === 0 &&
- p.spice === 0 &&
- p.moved === 0 &&
- !p.shipped &&
- !p.prediction &&
- !p.advisorSetup &&
- (!p.elites ||
- ((homeworlds || nexus || ix || factions) &&
- p.faction === 'ixians' &&
- p.elites.reserves === 7 &&
- p.elites.tanks === 0 &&
- p.elites.revived === 0 &&
- Object.keys(p.elites.forces).length === 0)),
- ),
- 'The audit initializer cannot redeal or replace existing game pieces.',
- );
- requireRule(
- Object.keys(g.playerPositions ?? {}).every((id) =>
- g.players.some((p) => p.id === id),
- ),
- 'Player circles must belong to seated players.',
- );
- normalizedPlayerPositions(g);
initializeSetup(g);
return normalizeAutomaticGame(g);
}
@@ -20722,6 +20738,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
+ advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
validateEcazLoyalty(state);
ecazTreacheryIntegrity(state);
@@ -20990,6 +21007,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
+ advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
validateEcazLoyalty(state);
ecazTreacheryIntegrity(state);
@@ -22647,6 +22665,27 @@ function applyActionInner(
return g;
}
if (g.status === 'lobby') {
+ if (t === 'rules') {
+ requireRule(id === g.host, 'Only the host can change the rules mode.');
+ requireRule(typeof action.advanced === 'boolean' &&
+ Object.keys(action).every(key => key === 'type' || key === 'advanced'),
+ 'Choose Basic or Advanced preview rules.');
+ if (g.advanced === action.advanced) return g;
+ if (action.advanced) {
+ const blocked = baseStartProfileBlock(g);
+ requireRule(!blocked, blocked ?? 'This configuration cannot use Advanced preview.');
+ }
+ const removedStrongholds = !action.advanced && !!g.strongholdCards;
+ if (removedStrongholds) {
+ requireRule(JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards()),
+ 'Only unused lobby Stronghold Cards can be cleared when choosing Basic rules.');
+ g.strongholdCards = null;
+ }
+ g.advanced = action.advanced;
+ g.players.forEach(player => (player.ready = !!player.bot));
+ log(g, `${p.name} selected ${g.advanced ? 'Advanced preview' : 'Basic'} rules.${removedStrongholds ? ' Stronghold Cards were disabled because they require Advanced rules.' : ''} Human readiness was cleared; AI seats remain ready.`);
+ return g;
+ }
if (t === 'configureBot') {
const positions = normalizedPlayerPositions(g);
const quote = quoteLobbyBotConfiguration(
@@ -22785,7 +22824,10 @@ function applyActionInner(
}
if (t === 'start') {
requireRule(id === g.host, 'Only the host can start the game.');
- start(g);
+ requireRule(Object.keys(action).every(key => key === 'type' || key === 'advancedPreview') &&
+ (action.advancedPreview === undefined || action.advancedPreview === true),
+ 'Start this table, or explicitly choose Advanced preview.');
+ start(g, action.advancedPreview === true);
return g;
}
if (t === 'faction') {
@@ -24629,6 +24671,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
+ advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
validateEcazLoyalty(state);
ecazTreacheryIntegrity(state);
@@ -24739,6 +24782,7 @@ export function viewGame(state: Game, id: string) {
mentat: projectedMentat(g, id),
rihani: projectedRihani(g, id),
advanced: g.advanced,
+ advancedPreview: g.advancedPreview === true,
dukeVidal: g.dukeVidal
? { ...g.dukeVidal, leader: projectLeader(g, g.dukeVidal.leader, id) }
: null,
diff --git a/game/reference.ts b/game/reference.ts
index 451e21d6..bb38990c 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -198,7 +198,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Verification entries identify focused regression suites. Passing those checks does not certify every interaction, browser journey or complete expansion game.',
'Development stages distinguish Missing functions, working Prototypes, Integrated functions, Verified behavior and Polished presentation. A prototype stage does not certify complete rules coverage; check the stated limits and evidence.',
'This checklist is being extended across the rules. Features without a checklist still have a topic-level coverage label and are not implicitly complete.',
- 'Advanced and full expansion faction starts remain disabled. Development fixture coverage is not permission to start an unsupported mode.',
+ 'Choose Rules → Advanced preview when creating a room, or as host in the lobby. After everyone is ready, choose Begin Advanced preview. This unfinished mode supports the six classic factions, with optional Tech Tokens (3+ players) and Stronghold Cards. Expansion starts remain disabled; preview access does not certify complete rules compliance.',
],
related: [
'setup',
@@ -1240,7 +1240,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Before any initial Traitor Cards are drawn, randomly select one of the five ordinary Ecaz Traitor Cards and set it aside face up. Everyone may inspect it. It remains outside the deck for the rest of the game, including later Traitor and Face Dancer draws.',
'Loyalty is automatic and cannot be stopped with Karama. It creates no player choice, payment or acknowledgment. The card names a leader, but does not remove, kill, transfer or otherwise change that leader disc. Duke Vidal has no Traitor Card and is never a candidate.',
'In new Advanced development games with Ecaz, the table displays the selected card before the Traitor choices. Inspect traitor enlarges its identity; refreshing or resuming the saved game keeps the same card. Basic games and games without native Ecaz do not use this power.',
- 'Older saved games that have already dealt their Traitor Cards retain that inventory; they do not gain a retroactive Loyalty draw. Full Advanced and expansion starts remain gated, including unfinished combinations with Leader Skills and Moritani assassination.',
+ 'Older saved games that have already dealt their Traitor Cards retain that inventory; they do not gain a retroactive Loyalty draw. Ecaz Advanced and expansion starts remain gated, including unfinished combinations with Leader Skills and Moritani assassination.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'One persisted native Ecaz card is selected before genuine initial dealing, excluded from setup and Nexus deck construction, and validated against all known circulating or retired Traitor locations. Older saves stay unchanged. Broader combinations remain gated.', evidence: ['game/ecaz-loyalty.ts', 'game/engine.ts'] },
@@ -1955,7 +1955,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Each player receives four private traitor choices and keeps one. Harkonnen keeps all four automatically. Every remaining selection must finish before starting spice and forces are placed. Your starting Treachery hand is still empty.',
'Starting spice and fixed forces are then placed. Fremen distributes ten forces among Sietch Tabr, False Wall South and False Wall West, choosing their printed sectors. Basic Bene Gesserit starts with one fighter in Polar Sink. In Advanced setup, Bene Gesserit instead chooses a printed territory and sector after Fremen placement; its advisor becomes a fighter when alone.',
'After all starting force choices, each player receives one private Treachery Card and Harkonnen receives two. Dealing and entry into the first Storm phase are automatic. The table names the player whose setup choice is still required; there is no separate confirmation for dealing.',
- 'Older saved tables that already received their cards keep those cards and their existing unfinished choices. Returning to a table does not restart setup or deal another hand. Advanced and expansion starts remain unavailable while their remaining rules and interactions are completed.',
+ 'Older saved tables that already received their cards keep those cards and their existing unfinished choices. Returning to a table does not restart setup or deal another hand. The six classic factions can start Advanced preview from the lobby while remaining rules and interactions are completed. Expansion starts remain unavailable.',
'The browser keeps a private seat cookie. Returning to the same room in that browser restores your seat. Separate players should use separate browsers or profiles.',
],
related: ['privacy', 'ai-players', 'storm', 'implementation-checklist'],
@@ -2762,7 +2762,7 @@ export const RULE_TOPICS: RuleTopic[] = [
? 'Partial'
: 'Planned',
summary:
- 'Advanced faction rules. Some powers have tested engine support; advanced table starts remain disabled until the full rules are ready.',
+ 'Advanced faction rules. The six classic factions can use the unfinished Advanced preview; complete rules compliance remains under verification.',
steps: rules.advanced,
related: [`faction-${id}`, 'advanced-combat', ...(id === 'ecaz' ? ['ecaz-loyalty'] : [])],
}),
@@ -2944,7 +2944,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'An already-dead named leader remains a legal reveal, awarding no death or spice. During Mentat Pause, set the revealed card aside face up as a marker and draw a new Traitor Card. You can use this advantage only once against each faction in the game.',
'The printed rule says a normal traitor reveal loses this advantage, but its duration remains unresolved. The preview guards further assassination after a normal call. Karama has no effect against Assassinate Leaders.',
'If Harkonnen has captured your own leader and you hold its Traitor Card, you may call that traitor normally; you may not use Assassinate Leaders on that basis.',
- 'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Advanced starts and Moritani starts remain unavailable.',
+ 'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Moritani starts remain unavailable in Basic and Advanced.',
],
checklist: [
{area:'Implementation',status:'Partial',detail:'Explicit Advanced Moritani/base-opponent preview before any normal traitor call; native disc deaths, printed bounty, dead-target zero bounty and exact Mentat replacement.',evidence:['game/moritani-assassinate.ts','game/engine.ts']},
@@ -2968,7 +2968,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary:
'Spice-supported combat, elite units, casualty selection and Kwisatz Haderach.',
steps: [
- 'The combat engine now distinguishes ordinary and elite tokens, accepts half-strength dials and seals spice support with the battle plan. Advanced table starts remain disabled while other advanced rules are built.',
+ 'The combat engine now distinguishes ordinary and elite tokens, accepts half-strength dials and seals spice support with the battle plan. The unfinished Advanced preview is available for the six classic factions while other Advanced rules are completed.',
'One spice supports one token at its full strength. Unsupported tokens fight at half strength. Fremen uses full strength without spice; Sardaukar counts as ordinary against Fremen.',
'After resolution, casualties must match both the dial and spice spent. One legal ordinary/elite allocation settles automatically; several legal allocations ask the winner to choose. The losing army is destroyed. Only a traitor winner avoids its committed spice payment.',
'Elite tokens are tracked in reserves, territories and tanks. Normal revival, Ghola and the Emperor’s extra revival all share the limit of one elite revival per faction per turn.',
@@ -3005,7 +3005,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary:
'Two spice piles, Fremen forecasts, half storm losses and additional worms.',
steps: [
- 'These phase rules have engine support and focused tests. Advanced table starts remain disabled while the other advanced powers are completed.',
+ 'These phase rules have engine support and focused tests. The six classic factions can start the unfinished Advanced preview while remaining powers are completed.',
'Resolve blow A, its worms, Harvester window, Nexus and rides, then do the same for blow B. Each pile uses its own previous discard. First-turn worms are set aside and shuffled back only after both blows finish.',
'After the first storm, Fremen privately learns a random storm card for the next turn. A foresight cancellation hides that card from Fremen without changing its eventual distance. Weather Control may replace a revealed card distance before movement.',
'Fremen storm losses are half the exposed group, rounded up. Choose ordinary and Fedaykin casualties when more than one combination is possible. A legal reserve shipment into storm applies this loss only to the arriving group.',
@@ -3039,7 +3039,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'When neither Guild nor Fremen is playing and nobody has reached the normal target, compare individual qualifying stronghold counts. Every player tied for the highest count wins. A correct Bene Gesserit prediction can replace this stronghold win.',
'With tech tokens enabled, owning all three adds one stronghold. One player must hold all three, even in an alliance. If several sides reach their target at the same check, they share a normal win before any correct prediction replaces it.',
'The table’s Stronghold victory progress shows current qualifying territories, a complete Tech Token set, Ecaz joint occupation and Fremen’s final-turn conditions. Allied Ecaz and Fremen may co-occupy Sietch Tabr without blocking that special win; solitary or non-allied Ecaz does not receive this exception. Tech Tokens cannot supply a jointly occupied territory. These public counts do not resolve a private prediction or award victory before the proper checkpoint.',
- 'Other expansion additions to victory remain in development. Advanced and expansion faction starts remain disabled.',
+ 'Other expansion additions to victory remain in development. Advanced preview supports the six classic factions; expansion starts remain disabled.',
],
example:
'At the final turn with neither Guild nor Fremen, two players each hold two qualifying strongholds and a third holds one. The two leaders of the count share the win.',
@@ -3090,7 +3090,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The mobile card copies a currently controlled stronghold, even if another faction still holds that location’s card. Holding a card after leaving its location does not itself make that location an eligible copy. The choice is public and fixed before battle plans; a sole eligible advantage is announced automatically.',
'Arrakeen pays only for actual support and creates no personal cash. CHOAM force-payment income includes the bank contribution, subject to its normal traitor and donor exclusions. Carthag does not protect an empty or Worthless defense, stop Poison Tooth, or remove a Shield’s Lasgun explosion risk.',
'Sietch Tabr uses the opposing dial, excluding leaders and other bonuses. A single traitor victory qualifies; mutual destruction does not. Tuek pays after an ordinary loss or a single traitor outcome as well as a win; a double traitor gives neither faction spice. Habbanya also decides a tied Stone Burner comparison.',
- 'The six effects, public ownership and pre-plan choice are integrated in development games. Complete Advanced games and every expansion interaction remain under verification, so public Advanced starts remain unavailable.',
+ 'The six effects, public ownership and pre-plan choice are integrated in development games. Enable Stronghold Cards in an Advanced preview lobby. Complete Advanced compliance and every expansion interaction remain under verification.',
],
example:
'You hold Arrakeen’s card and support four forces. The bank pays two spice; you and any permitted ally cover the remaining two. Leaving Arrakeen later does not transfer its card until turn end.',
@@ -3190,7 +3190,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A retained captive is available only to Harkonnen. Its territorial use restrictions remain. It can turn traitor, returns to its faction if it survives its one battle, and enters its original faction’s tanks if killed.',
'When all native Harkonnen leaders are dead, return every unused captive immediately. A new captive retained in that situation also returns immediately.',
'Only Harkonnen and the original owner know a concealed living captive’s identity. Tleilaxu can inspect a concealed executed leader. Using a captive in a revealed battle makes its identity public.',
- 'The capture engine and private views have focused tests. Advanced table starts remain disabled while other advanced rules and expansion interactions are completed.',
+ 'The capture engine and private views have focused tests. The six classic factions can start Advanced preview; remaining Advanced rules and expansion interactions are unfinished.',
],
related: [
'advanced-harkonnen',
@@ -3224,7 +3224,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Tleilaxu receive a decision before another faction’s normal revival completes, whether or not they hold Karama. Decline to continue the revival, or spend a real Karama once per game to prevent that faction’s normal force and leader revivals for the current turn. A stopped attempt spends no spice, returns no pieces and consumes no elite or extra-revival quota.',
'This includes negotiated leaders, KH and Emperor-funded allied extra forces. The Emperor remains the payer when an extra revival is allowed. Hidden leader identities and prices are not added to the public stop decision. Ghola treachery and the Emperor’s special Karama currently remain separate from the normal-revival restriction.',
'The printed Tleilaxu power is brief. Its full-turn scope, precise timing and card/special-power exceptions remain provisional pending the complete primary-source audit. These choices are tested implementation behavior, not certification of the full expansion rules.',
- 'Remaining expansion special powers, plus further timing and expansion interaction audits, remain unfinished. Advanced table starts remain disabled.',
+ 'Remaining expansion special powers, plus further timing and expansion interaction audits, remain unfinished. Advanced preview is available for the six classic factions.',
'Richese’s special purchase is available in development tables: spend a Karama and three spice to choose privately from the cache, with separate Emperor income and explicit full-hand/final-cache guards. See its acquisition guide for current controls and limits.',
],
related: [
@@ -3640,7 +3640,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'When another faction ships, moves or worm rides into your fighters, immediately choose whether to become advisors. The choice opens a Karama response. Repeated entry can offer a new choice.',
'After another faction’s off-planet shipment, choose a free force in the Polar Sink or an accompanying force at the shipment destination. An accompanying advisor group cannot flip to fighters in that turn while other factions remain.',
'Before the first shipment, choose eligible advisor territories to prepare for battle. Each chosen flip has a Karama response. Advisors cannot prepare against an ally or where storm prevents the battle.',
- 'The engine and these choices have focused tests. The full advisor timing audit, forced-flip cancellation and expansion interactions remain unfinished; advanced table starts are disabled.',
+ 'The engine and these choices have focused tests. Advanced preview is available for the six classic factions; the full advisor timing audit, forced-flip cancellation and expansion interactions remain unfinished.',
],
related: ['faction-beneGesserit', 'movement', 'battle', 'victory'],
},
diff --git a/tests/advanced-preview-controls.test.ts b/tests/advanced-preview-controls.test.ts
new file mode 100644
index 00000000..073a1cba
--- /dev/null
+++ b/tests/advanced-preview-controls.test.ts
@@ -0,0 +1,54 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import {
+ RulesetControls,
+ AdvancedPreviewNotice,
+} from '../components/ruleset-controls';
+import { createRoomEntry, parseRoomEntry } from '../lib/room-entry';
+
+void test('rules controls identify the unfinished selection before readiness and restrict peer editing', () => {
+ const render = (advanced: boolean, host: boolean) =>
+ renderToStaticMarkup(
+ createElement(RulesetControls, {
+ advanced,
+ host,
+ lobby: true,
+ onChange() {},
+ }),
+ );
+ assert.match(render(false, true), /value="basic" selected=""/);
+ const preview = render(true, true);
+ assert.match(preview, /value="advanced" selected=""/);
+ assert.match(preview, /Rules and card interactions are still/);
+ assert.match(preview, /six classic factions/);
+ assert.match(preview, /Changing rules clears player readiness/);
+ assert.match(preview, /Switching to Basic removes Stronghold Cards/);
+ assert.match(render(true, false), /