This table uses {tableAdvanced ? 'Advanced' : 'Basic'} rules.{advanced !== tableAdvanced && ' You are previewing the other rules mode; the table has not changed.'}
}
+
+
+
;
+}
+
+export function FactionInspector({ factionId, advanced }: { factionId: FactionId; advanced: boolean }) {
+ const house = faction(factionId);
+ return ;
+}
+
+export function FactionSheetGallery() {
+ const [selected, setSelected] = useState('atreides');
+ return
+
+
+ ;
+}
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 3f022723..69e4e5d4 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -3,6 +3,7 @@ import { EcazSetup } from './ecaz-setup';
import { StormCardInspector, StormCardLogInspector } from './storm-cards';
import { KwisatzInspector } from './kwisatz-inspector';
import { ForceInspector } from './force-inspector';
+import { FactionInspector } from './faction-inspector';
import { DrawPiles } from './draw-piles';
import { HandBrowser } from './hand-browser';
import { LobbyBotControls } from './lobby-bot-controls';
@@ -1111,6 +1112,7 @@ export function GameTable({
)}
+
{p.revealedTraitors.length > 0 && (
Revealed traitors:{' '}
diff --git a/components/rules-reference.tsx b/components/rules-reference.tsx
index cd5378ee..a32771f2 100644
--- a/components/rules-reference.tsx
+++ b/components/rules-reference.tsx
@@ -6,6 +6,7 @@ import { NexusCardGallery } from './nexus-cards';
import { StormCardGallery } from './storm-cards';
import { KwisatzReferenceCard } from './kwisatz-inspector';
import { ForceReferenceGallery } from './force-inspector';
+import { FactionSheetGallery } from './faction-inspector';
import { createStrongholdCards } from '@/game/stronghold-cards';
import { useSearchParams } from 'next/navigation';
import { Suspense, useState, useEffect, useRef } from 'react';
@@ -202,6 +203,7 @@ function RulesReferenceContent() {
{topic.id === 'storm-cards' && }
{topic.id === 'kwisatz-haderach' && }
{topic.id === 'force-counters' && }
+ {topic.id === 'faction-sheets' && }
{topic.id === 'richese-cards' && (
= {
choam: {
@@ -11,13 +13,14 @@ export const FACTION_RULES: Record<
'At each phase end, sell Worthless cards for two spice or reveal exact duplicates and sell surplus copies for three each. A Karama response precedes each sale. Once per turn, exchange one card each with your ally; both players choose and CHOAM confirms before either hand changes.',
'Use Kulon on your movement turn to add one territory of range. Use La La La during Revival to prevent a player’s free force returns for the phase; a reactive decision lets you stop a pending free revival before it resolves. Both cards have Karama responses before discard.',
'Trip to Gamont returns one other player’s force from a chosen sector to its reserves during Mentat. Elite types are preserved. A Karama response precedes the return, and CHOAM gets a final opportunity after the closing market before victory is checked.',
- 'Charity, Inflation, hand limits, force revival, card sales, allied exchanges, Kulon, La La La and Trip to Gamont now have engine controls. The other three Worthless effects and remaining faction powers are unfinished, so CHOAM starts remain disabled.',
+ 'Baliset prevents a player from moving into a territory occupied by CHOAM for the turn, while permitting shipment. Jubba Cloak protects CHOAM forces in one territory from a moving storm. Both powers have Karama responses before the card is discarded.',
+ 'Kull Wahad and combined faction interactions remain unfinished. The available development powers do not enable complete CHOAM games.',
],
advanced: [
'Once per game, spend a real Karama and discard selected other cards for three spice each. The cash-in can occur during pending phase or power decisions, while committed battle cards remain protected. See the CHOAM special Karama topic for funding and timing details.',
'Reserve spice to pay for some or all of your ally’s battle support. Each sealed plan records its own and allied shares. A Karama response may prevent this alliance benefit for one battle; unused funding returns at the end of Battle.',
'Receive half of each other player’s actual force-support payment, rounded down, unless any traitor is revealed. Your own payments, including support paid for your ally, go to the bank. A separate Karama response can cancel this battle’s income. See CHOAM combat for examples and outstanding interaction audits.',
- 'The Auditor and printed leader setup are not implemented yet.',
+ 'The Auditor is an additional strength-two leader. After using it in battle, CHOAM may inspect two randomly selected unused opposing Treachery Cards if it survives, or one if it dies, limited by the unused cards available. The opponent may pay CHOAM one spice per card that would be inspected to prevent the whole inspection; Karama can also cancel the power. Use the Auditor guide for the exact private choices and limits. Setup and inspection are supported in development games; complete combined play remains unfinished.',
],
},
ixians: {
@@ -98,7 +101,7 @@ export const FACTION_RULES: Record<
'Bring reserves to the Great Flat or one territory within two territories of it for no spice, subject to storm and occupancy restrictions. Ordinary movement has range two, or three when eligible for ornithopters.',
'Worms do not devour Fremen. After the Nexus, move some or all forces from the worm territory to a legal destination. The destination’s existing forces survive.',
'You choose whether a worm spares your ally and whether to grant your ally three free force revivals. These benefits are optional.',
- 'At the final turn, the special victory requires Sietch Tabr and Habbanya Sietch to be empty or occupied by Fremen, with no Atreides, Harkonnen or Emperor in Tuek’s Sietch. Allied occupation does not override those conditions.',
+ 'At the final turn, the special victory requires Sietch Tabr and Habbanya Sietch to be empty or occupied by Fremen, with no Atreides, Harkonnen or Emperor in Tuek’s Sietch. The specific Ecaz alliance exception permits Fremen and Ecaz to share Sietch Tabr; it does not waive the other conditions or extend to Habbanya Sietch. Advanced Richese also prevents this victory while occupying Tuek’s Sietch.',
],
advanced: [
'Once per game during Spice Blow and Nexus, spend Karama to call Shai-Hulud in sand. Resolve destruction and optional protections immediately; Nexus follows at the end of the blow, with eligible Fremen rides afterward. The called worm consumes no spice-deck card.',
@@ -141,4 +144,49 @@ export const FACTION_RULES: Record<
'Receive two charity spice regardless of wealth. Worthless cards may be used as Karama, with the card discarded when used.',
],
},
+ richese: {
+ basic: [
+ 'Begin with five spice, twenty forces in reserve and two free force revivals. Keep your ten technology cards in a separate cache, outside your hand and the ordinary Treachery Deck.',
+ 'At each Bidding phase, declare whether a cache lot replaces the first or last ordinary lot. Reveal its card when that lot begins, using Once Around or Silent bidding. Other buyers pay you; your own purchase pays the Emperor or bank. The card must fit in the buyer’s hand.',
+ 'Once Around lets each other faction bid or pass once in your chosen direction before your final opportunity. Silent bidding reveals simultaneous offers and resolves ties in storm order. A zero result allows the cache card to be kept or removed. Ordinary Karama acquisition cannot take this special lot.',
+ 'Your concealed No-Field tokens represent zero, three or five forces, while counting as one presence until revelation. Actual forces come from the reserves available when the token is revealed. Use the No-Field guide for shipment, movement and revelation choices.',
+ 'You may give your ally a Richese-family card already in your hand when they have room. Allied No-Field shipments reveal immediately and use the ally’s reserves.',
+ 'Empty-cache continuation, exceptional allied pricing, mixed No-Field battles and several technology-card effects remain unfinished. Consult the linked guides for the available development choices.',
+ ],
+ advanced: [
+ 'Before the regular auction sequence, Black Market may offer a concealed card from your hand. The buyer pays you. An unsold card stays in your hand; a completed sale reduces the regular auction pool by one. Atreides can inspect the offered card.',
+ 'Once per game, spend Karama and three spice to acquire an eligible cache card privately for your hand. This is separate from an auction purchase.',
+ 'Richese occupation of Tuek’s Sietch also prevents the Fremen special victory in Advanced play.',
+ 'Black Market self-purchase, Ixian substitution on special lots and wider card interactions remain guarded in development games.',
+ ],
+ },
+ ecaz: {
+ basic: [
+ 'Begin with twelve spice, six forces in Imperial Basin, fourteen in reserves and two free force revivals. Your Ambassador supply contains the reusable Ecaz token and five random other identities.',
+ 'After Revival, place Ambassadors in eligible strongholds, paying one spice for the first, two for the second and so on that turn. Eligible enemy entry lets you trigger an effect for yourself or your ally. The Ambassador guide explains each identity and its choices.',
+ 'Your Ecaz Ambassador can acquire an available Duke Vidal or offer an alliance when both participants are unallied. Duke availability, loans and custody use his separate rules.',
+ 'You and your ally may share territories. Divide shared desert spice by agreement; without agreement, divide as equally as possible and give the odd spice to your ally. Both factions occupying each of three qualifying strongholds provides an allied victory route.',
+ 'When you are allied with Fremen, sharing Sietch Tabr does not itself prevent the Fremen special victory. Its other conditions still apply.',
+ 'Combined Occupy combat, Tleilaxu Ambassador revival, Duke loans and revival, and competing arrival reactions remain unfinished. The connected Sietch Tabr exception does not establish every special-victory combination.',
+ ],
+ advanced: [
+ 'At Spice Collection, both allies receive the normal bank income from a jointly occupied Arrakeen, Carthag or Tuek’s Sietch. Canceling Ecaz’s Collection benefit leaves the ally’s income intact.',
+ 'This is the currently documented Advanced Collection power. The remaining Advanced powers and combined interactions are still being completed; this sheet is not a complete faction certification.',
+ ],
+ },
+ moritani: {
+ basic: [
+ 'Set up last with six forces in an unoccupied territory, fourteen in reserves, twelve spice and two free force revivals.',
+ 'Keep six Terror identities hidden: Assassination, Atomics, Extortion, Robbery, Sabotage and Sneak Attack. Once each Mentat Pause, place one token or relocate one already placed. Eligible enemy entry into its stronghold offers an optional reaction.',
+ 'Before revealing a would-trigger token, Enemy of My Enemy may offer the entrant an alliance, except Ecaz. Acceptance replaces existing alliances and returns the token; refusal requires its revelation.',
+ 'When your ally loses a battle that has a winner, the ally may retain one eligible played Treachery Card that it could have kept after winning.',
+ 'Moritani can acquire Duke Vidal at the end of Shipment and Movement under his separate stronghold-battle and custody conditions. Inspect his guide before choosing him for a battle.',
+ 'Atomics, Extortion, exceptional leader custody and full combined play remain unfinished. Public Terror descriptions do not reveal the identities of placed tokens.',
+ ],
+ advanced: [
+ 'After losing a qualifying battle with no traitor called and the opposing leader surviving, you may reveal a held Traitor Card naming a different leader from that opposing faction. A living target dies and pays its printed strength from the bank; an already-dead target pays nothing.',
+ 'During Mentat Pause, set the revealed card aside face up as that faction’s use marker and draw its replacement. Use this advantage once against each faction; Karama cannot prevent it.',
+ 'Advanced assassination is available in an opted-in development preview. The duration of forfeiture after calling a normal traitor and exceptional leader-custody interactions remain unresolved.',
+ ],
+ },
};
diff --git a/game/reference.ts b/game/reference.ts
index 1d97c8b9..a9829669 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -229,6 +229,27 @@ export const RULE_TOPICS: RuleTopic[] = [
'advanced-combat',
],
},
+ {
+ id: 'faction-sheets',
+ title: 'Inspect public faction sheets',
+ category: 'Getting started', coverage: 'Partial', developmentStage: 'Prototyped',
+ summary: 'Open a faction’s powers and alliance guidance beside the table, or browse all twelve factions here.',
+ steps: [
+ 'Choose Inspect faction beneath any player. Every faction sheet is public; opening one does not reveal that player’s cards, spice, leaders, cache or hidden tokens.',
+ 'The sheet begins in your table’s Basic or Advanced mode. Include Advanced powers previews the other mode without changing table rules. Advanced powers appear after the Basic powers they extend.',
+ 'Read the enlarged sheet, scroll with the keyboard and follow internal faction links for detailed timing and exceptions. Close or Escape returns to the table. Inspection remains available while a game action is pending.',
+ 'The gallery includes all twelve factions. Changing its faction starts with Basic guidance. It is a reference, not a faction selection or a change to a saved game.',
+ 'The sheets share their text with the internal faction reference. Remaining powers, unresolved interactions and development-only availability stay explicit; this prototype does not certify complete faction text or rules compliance.',
+ ],
+ checklist: [
+ { area: 'Implementation', status: 'Implemented', detail: 'Public faction identity and rules mode select shared guidance and bundled artwork, with no game action or private state.', evidence: ['game/faction-reference.ts', 'components/faction-inspector.tsx'] },
+ { area: 'Player controls', status: 'Implemented', detail: 'Every table player has an inspector; the internal gallery offers all twelve factions, Advanced preview, scrolling and close controls.', evidence: ['components/game-table.tsx', 'components/rules-reference.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'Static inspection introduces no AI decision. Existing legal participation remains; full strategy work waits for feature completion.' },
+ { area: 'Documentation', status: 'Partial', detail: 'Shared guidance covers existing faction powers and names remaining boundaries. Full printed-text and combined-rule verification remain open.', evidence: ['docs/FACTION_SHEETS.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused rendering checks all identities, mode separation, shared reference and public inspector controls; browser and final acceptance have separate evidence.', evidence: ['tests/faction-inspector.test.tsx'] },
+ ],
+ related: ['force-counters', 'privacy', ...FACTIONS.map(house => `faction-${house.id}`)],
+ },
{
id: 'interactive-introduction',
title: 'Interactive introduction',
@@ -2630,29 +2651,14 @@ export const RULE_TOPICS: RuleTopic[] = [
? 'Partial'
: 'Planned',
summary: f.title,
- steps:
- f.id === 'ecaz'
- ? [
- 'Begin with six forces in the Imperial Basin, fourteen in reserves and twelve spice. Two force revivals are free.',
- 'Your Ambassador supply contains the Ecaz token and five random tokens from the remaining pool. Inspect the linked Ambassador guide for all eleven identities and their custody rules.',
- 'End-of-Revival placement has owner controls, escalating prices, cancellation responses and bot support in development fixtures. Full faction starts remain disabled.',
- 'Eight entry effects and their Bene Gesserit copies, plus direct Ecaz Duke acquisition, have focused support. Storm/explosion token returns, ordinary territorial co-occupation and the three jointly occupied stronghold victory are also supported. Shared desert allocation and Advanced Collection income, including cancellation, are also supported in development fixtures. Tleilaxu revival, combined Occupy battles, the Fremen endgame exception and the complete Duke revival and custody interactions remain unfinished. Consult the feature checklist for the exact tested boundary.',
- ]
- : f.id === 'moritani'
- ? [
- 'Set up after every other faction: place six forces in an unoccupied territory, keep fourteen in reserves, and begin with twelve spice. Two force revivals are free.',
- 'Keep six Terror tokens hidden: Assassination, Atomics, Extortion, Robbery, Sabotage and Sneak Attack. During each Mentat Pause, you may place one token or relocate one already on the board.',
- 'Enemy entry into a marked stronghold offers an optional Terror reaction. Enemy of My Enemy can instead offer the entrant an alliance before the token is revealed, except when the entrant is Ecaz.',
- 'When your ally loses a battle that has a winner, the ally may retain one played Treachery Card that it could have kept after winning.',
- 'These topics describe the verified rules. Moritani remains unavailable for new games while its complete faction implementation and interaction checks are unfinished.',
- ]
- : (FACTION_RULES[f.id]?.basic ?? [
- 'This faction’s complete rules engine and reference are still being developed.',
- ]),
+ steps: FACTION_RULES[f.id].basic,
related: [
'setup',
'battle',
- ...(FACTION_RULES[f.id] ? [`advanced-${f.id}`] : []),
+ `advanced-${f.id}`,
+ 'faction-sheets',
+ ...(f.id === 'richese' ? ['richese-cards', 'richese-no-field', 'richese-gift', 'richese-acquisition'] : []),
+ ...(f.id === 'choam' ? ['choam-auditor', 'choam-worthless', 'choam-combat'] : []),
...(f.id === 'ecaz'
? ['ecaz-ambassadors', 'duke-vidal', 'implementation-checklist']
: []),
diff --git a/tests/faction-inspector.test.tsx b/tests/faction-inspector.test.tsx
new file mode 100644
index 00000000..8247ac6f
--- /dev/null
+++ b/tests/faction-inspector.test.tsx
@@ -0,0 +1,74 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { existsSync, readFileSync } from 'node:fs';
+import { registerHooks } from 'node:module';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { FACTIONS } from '../game/catalog';
+import { FACTION_RULES } from '../game/faction-reference';
+import { RULE_TOPICS } from '../game/reference';
+import { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) { return next(specifier === 'next/link' ? 'vinext/shims/link' : specifier === 'next/image' ? 'vinext/shims/image' : specifier, context); },
+ load(url, context, next) {
+ if (!url.endsWith('.module.css')) return next(url, context);
+ const css = readFileSync(new URL(url), 'utf8');
+ const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(m => [m[1], m[1]]));
+ return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
+ },
+});
+const { FactionSheet, FactionSheetGallery } = await import('../components/faction-inspector');
+const { GameTable } = await import('../components/game-table');
+aliases.deregister();
+
+void test('all twelve faction sheets share reference text, distinct identities, bundled art and valid internal links', () => {
+ assert.deepEqual(Object.keys(FACTION_RULES).sort(), FACTIONS.map(f => f.id).sort());
+ for (const house of FACTIONS) {
+ const basic = RULE_TOPICS.find(t => t.id === `faction-${house.id}`)!;
+ const advanced = RULE_TOPICS.find(t => t.id === `advanced-${house.id}`)!;
+ assert.equal(basic.steps, FACTION_RULES[house.id].basic);
+ assert.equal(advanced.steps, FACTION_RULES[house.id].advanced);
+ assert.ok(basic.steps.length > 1 && advanced.steps.length > 0);
+ const html = renderToStaticMarkup(createElement(FactionSheet, { factionId: house.id, advanced: true }));
+ assert.ok(html.includes(`aria-label="${house.name} faction sheet"`));
+ assert.ok(html.includes(`href="/rules?topic=faction-${house.id}#faction-${house.id}"`));
+ assert.ok(html.includes(`href="/rules?topic=advanced-${house.id}#advanced-${house.id}"`));
+ const art = html.match(/src="(\/art\/homeworlds\/[^" ]+)"/)?.[1];
+ assert.ok(art && existsSync(new URL(`../public${art}`, import.meta.url)));
+ assert.doesNotMatch(html, /https?:\/\//);
+ }
+});
+
+void test('Basic sheets omit Advanced rules; Advanced sheets retain the underlying Basic powers', () => {
+ const basic = renderToStaticMarkup(createElement(FactionSheet, { factionId: 'harkonnen', advanced: false }));
+ const advanced = renderToStaticMarkup(createElement(FactionSheet, { factionId: 'harkonnen', advanced: true }));
+ assert.match(basic, /Harkonnen Basic powers and alliances/);
+ assert.doesNotMatch(basic, /aria-label="Harkonnen Advanced powers"|Advanced Harkonnen reference/);
+ assert.match(advanced, /Harkonnen Basic powers and alliances/);
+ assert.match(advanced, /aria-label="Harkonnen Advanced powers"/);
+ assert.match(advanced, /The rules and game are still being completed/);
+});
+
+void test('the reference gallery includes every faction and starts in Basic without a saved-table mode claim', () => {
+ const html = renderToStaticMarkup(createElement(FactionSheetGallery));
+ for (const house of FACTIONS) assert.ok(html.includes(`value="${house.id}"`));
+ assert.match(html, /Atreides faction sheet/);
+ assert.match(html, /Include Advanced powers/);
+ assert.doesNotMatch(html, /This table uses|Atreides Advanced powers/);
+});
+
+void test('every occupied player has a public inspector for either viewer even while actions are busy', () => {
+ const game = createGame('SHEETUI', newPlayer('a', 'Own name', 'atreides'));
+ joinGame(game, newPlayer('h', 'Other name', 'harkonnen'));
+ const before = JSON.stringify(game);
+ for (const id of ['a', 'h']) {
+ const html = renderToStaticMarkup(createElement(GameTable, { game: viewGame(game, id), busy: true, send: async () => { assert.fail('Inspection must not act.'); }, onExit() {} }));
+ for (const name of ['Atreides', 'Harkonnen']) {
+ const button = html.match(new RegExp(`