This table shows a combined force count; starred counters are not listed separately.
}
+
+ {inventory.nativeReserves.length > 0 &&
+
Reserves on native Homeworlds
+
These counters are already included in Reserves above.
+
+ }
+ {player.faction === 'beneGesserit' &&
{inventory.advisors} deployed counters currently show their advisor face. Advisors are the same physical counters; reserves and the Tanks have no deployed stance.
}
+ {inventory.marker &&
+ ?
+
Concealed No-Field
{inventory.marker.name}
Counts as one force for board presence until revealed. It is a separate marker, not an extra physical force counter. Its hidden force value is not shown here. Revelation draws from the reserves available then.
+ }
+
Inspecting counters does not move them or commit a Battle Plan. Spice support, temporary powers and leaders may change battle strength without adding counters.
Counter faces are enlarged diagrams. Each is one physical counter; the current rules and powers determine its strength. The gallery shows component types, not a saved table’s quantities.
+ {selected === 'beneGesserit' &&
Fighter and advisor are two faces of the same counter, not two separate forces.
}
+ ;
+}
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 29e00c7e..80a5dc2a 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -2,6 +2,7 @@
import { EcazSetup } from './ecaz-setup';
import { StormCardInspector, StormCardLogInspector } from './storm-cards';
import { KwisatzInspector } from './kwisatz-inspector';
+import { ForceInspector } from './force-inspector';
import { LobbyBotControls } from './lobby-bot-controls';
import { IxRicheseTechnology } from './ix-richese-technology';
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
@@ -1107,6 +1108,7 @@ export function GameTable({
)}
+
{p.revealedTraitors.length > 0 && (
Revealed traitors:{' '}
diff --git a/components/rules-reference.tsx b/components/rules-reference.tsx
index 1cb83dd6..cd5378ee 100644
--- a/components/rules-reference.tsx
+++ b/components/rules-reference.tsx
@@ -5,6 +5,7 @@ import { HomeworldCardGallery } from './homeworld-cards';
import { NexusCardGallery } from './nexus-cards';
import { StormCardGallery } from './storm-cards';
import { KwisatzReferenceCard } from './kwisatz-inspector';
+import { ForceReferenceGallery } from './force-inspector';
import { createStrongholdCards } from '@/game/stronghold-cards';
import { useSearchParams } from 'next/navigation';
import { Suspense, useState, useEffect, useRef } from 'react';
@@ -200,6 +201,7 @@ function RulesReferenceContent() {
{topic.id === 'nexus-cards' && }
{topic.id === 'storm-cards' && }
{topic.id === 'kwisatz-haderach' && }
+ {topic.id === 'force-counters' && }
{topic.id === 'richese-cards' && (
>;
+ elites?: { reserves: number; tanks: number; forces: Readonly> };
+ advisors?: Record;
+ noField?: { deployed: { location: { territory: string; sector: number } } | null } | null;
+};
+export type PublicForceWorld = {
+ id: string;
+ native: string;
+ card: string;
+ forces: Readonly>;
+};
+export type ForcePool = {
+ id: string;
+ name: string;
+ total: number;
+ normal: number;
+ elite: number;
+ advisors: boolean;
+};
+
+export function forceCounterName(faction: FactionId, kind: 'force' | 'special' | 'advisor') {
+ if (kind === 'advisor') return 'Advisor';
+ if (kind === 'special') return specialForceName(faction);
+ return faction === 'ixians' ? 'Suboid' : faction === 'beneGesserit' ? 'Fighter' : 'Force';
+}
+
+function locationName(id: string, sector: number) {
+ if (!Number.isSafeInteger(sector) || sector < 0) throw new Error('Invalid force location.');
+ const name = territory(id).name;
+ return sector > 0 ? `${name} · sector ${sector}` : name;
+}
+
+function pool(id: string, name: string, total: number, elite = 0, advisors = false): ForcePool {
+ if (![total, elite].every(n => Number.isSafeInteger(n) && n >= 0) || elite > total)
+ throw new Error('Invalid public force count.');
+ return { id, name, total, normal: total - elite, elite, advisors };
+}
+
+/** Native Homeworlds subdivide reserves; only foreign deployments add a pool.
+ * Concealed No-Fields are markers, never physical counters or inferred values.
+ * A malformed projection produces no fabricated totals and cannot crash a table. */
+export function forceInventory(player: PublicForcePlayer, worlds: readonly PublicForceWorld[] | null = null) {
+ try {
+ const reserves = pool('reserves', 'Reserves', player.reserves, player.elites?.reserves);
+ const tanks = pool('tanks', 'Tleilaxu Tanks', player.tanks, player.elites?.tanks);
+ const rows = [reserves, tanks];
+ for (const key of [...new Set([...Object.keys(player.forces), ...Object.keys(player.elites?.forces ?? {})])].sort()) {
+ const { territory: id, sector } = splitLocation(key);
+ const row = pool(key, locationName(id, sector), player.forces[key] ?? 0,
+ player.elites?.forces[key], isAdvisor(player, id));
+ if (row.total > 0) rows.push(row);
+ }
+ const nativeReserves: ForcePool[] = [];
+ for (const world of worlds ?? []) {
+ const force = world.forces[player.id];
+ if (!force) continue;
+ const card = HOMEWORLD_CARDS.find(c => c.id === world.card);
+ if (!card) throw new Error('Unknown Homeworld.');
+ const row = pool(world.id, card.name, force.normal + force.elite, force.elite);
+ if (world.native === player.id) nativeReserves.push(row);
+ else if (row.total > 0) rows.push(row);
+ }
+ if (nativeReserves.length && (nativeReserves.reduce((n, r) => n + r.total, 0) !== reserves.total ||
+ nativeReserves.reduce((n, r) => n + r.elite, 0) !== reserves.elite))
+ throw new Error('Native reserves disagree with their Homeworld breakdown.');
+ const marker = player.noField?.deployed?.location;
+ const total = rows.reduce((n, r) => n + r.total, 0);
+ if (!Number.isSafeInteger(total)) throw new Error('Invalid force inventory.');
+ return {
+ rows, nativeReserves,
+ total,
+ normal: rows.reduce((n, r) => n + r.normal, 0),
+ elite: rows.reduce((n, r) => n + r.elite, 0),
+ advisors: rows.reduce((n, r) => n + (r.advisors ? r.total : 0), 0),
+ split: !!player.elites,
+ marker: marker ? { name: locationName(marker.territory, marker.sector) } : null,
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/game/reference.ts b/game/reference.ts
index 6ba49c13..1d2203b5 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -2107,6 +2107,31 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Full-table rendering, JSON continuation, pending-window rejection and SQLite acceptance/withdrawal races are covered. All-configuration and full visual acceptance remain open.', evidence: ['tests/nexus-offer-controls.test.tsx', 'tests/nexus-offer-recovery.test.ts', 'tests/nexus-alliance.test.ts'] },
],
},
+ {
+ id: 'force-counters',
+ title: 'Inspect force counters and their locations',
+ category: 'Getting started',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ summary: 'Enlarge faction counters and read public reserves, Tanks, deployed groups and Homeworld breakdowns.',
+ steps: [
+ 'Use Inspect forces beneath any player’s force summary. Everyone can inspect these public counts, including while another player is making a decision. Opening the inspector does not move forces or submit a game action.',
+ 'Reserves, the Tleilaxu Tanks and each deployed location show physical counters. A special counter is included in that location’s total, not added again. Battle strength, spice support, leaders and temporary powers are separate.',
+ 'Ixian normal counters are Suboids and their special counters are Cyborgs. Emperor’s special counters are Sardaukar; Fremen’s are Fedaykin. When a table uses combined counts, it does not assign a separate starred count to each location.',
+ 'Bene Gesserit advisors are the reverse face of the same force counters. A territory’s advisor stance applies across its sectors; advisor counts are included in that territory’s total. Reserves and the Tanks have no deployed stance.',
+ 'Native Homeworld groups subdivide reserves and are listed separately as a breakdown. Foreign Homeworld deployments are additional locations. Counters inside the Mobile Stronghold remain inspectable before its setup placement; Discovery locations use their names without a map sector.',
+ 'A concealed No-Field appears as a separate marker with its public location. It counts as one for board presence, but adds no physical counter to this inventory. Its hidden value is not revealed or inferred; revelation draws from the reserves available then.',
+ 'Refresh restores the saved public groups. The inspector follows table updates while open. Counter illustrations are enlarged original diagrams; the gallery shows component types rather than quantities in a saved game.',
+ ],
+ related: ['advanced-combat', 'ix-forces', 'advanced-advisors', 'homeworlds', 'richese-no-field'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Public physical pools, special subsets, advisor stances, native reserve breakdowns and foreign deployments are projected without changing game state. Full physical-component and rules certification remain open.', evidence: ['game/force-inventory.ts'] },
+ { area: 'Player controls', status: 'Implemented', detail: 'Every live player summary opens enlarged force inspection; the internal reference includes all twelve factions.', evidence: ['components/force-inspector.tsx', 'components/game-table.tsx', 'components/rules-reference.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'Read-only inspection adds no game action. Existing minimal legal participation is unchanged; strategy tuning remains deferred.' },
+ { area: 'Documentation', status: 'Implemented', detail: 'Physical counts, special identities, advisor faces, Homeworld accounting and concealed markers are explained.', evidence: ['docs/FORCE_INSPECTION.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused checks cover all faction identities, public-view equivalence, JSON recovery, physical subsets, Basic Homeworld special identity, unplaced HMS, foreign deployments and concealed-value privacy. Full visual and combined-game acceptance remain open.', evidence: ['tests/force-inventory.test.ts', 'tests/force-inspector.test.tsx'] },
+ ],
+ },
{
id: 'alliance-funding',
title: 'Fund an ally’s bid or shipment',
diff --git a/tests/force-inspector.test.tsx b/tests/force-inspector.test.tsx
new file mode 100644
index 00000000..ec437d6d
--- /dev/null
+++ b/tests/force-inspector.test.tsx
@@ -0,0 +1,62 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { 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 { createGame, joinGame, newPlayer, viewGame } from '../game/engine';
+import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField } from '../game/richese-no-field';
+
+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 { ForceCounterFace, ForceInventoryDetails, ForceReferenceGallery }=await import('../components/force-inspector');
+const { GameTable }=await import('../components/game-table');
+aliases.deregister();
+
+void test('live player summaries expose named inspection to every seat without sending an action', () => {
+ const g=createGame('FORCEUI',newPlayer('a','A observer','atreides'));
+ joinGame(g,newPlayer('h','H observer','harkonnen'));
+ const before=JSON.stringify(g);
+ const html=renderToStaticMarkup(createElement(GameTable,{game:viewGame(g,'a'),send:async()=>{assert.fail('Inspection must not act.');},onExit(){},busy:true}));
+ assert.match(html,/Inspect forces: A observer · Atreides/);
+ assert.match(html,/Inspect forces: H observer · Harkonnen/);
+ assert.equal((html.match(/>Inspect forces {
+ const html=renderToStaticMarkup(createElement(ForceReferenceGallery));
+ for(const house of FACTIONS) {
+ assert.ok(html.includes(`value="${house.id}"`));
+ const face=renderToStaticMarkup(createElement(ForceCounterFace,{factionId:house.id,kind:'force'}));
+ assert.ok(face.includes(`${house.name} `));
+ }
+ for(const [id,name] of [['emperor','Sardaukar'],['fremen','Fedaykin'],['ixians','Cyborg']] as const)
+ assert.match(renderToStaticMarkup(createElement(ForceCounterFace,{factionId:id,kind:'special'})),new RegExp(name));
+ assert.match(renderToStaticMarkup(createElement(ForceCounterFace,{factionId:'beneGesserit',kind:'advisor'})),/Advisor counter/);
+});
+
+void test('visible counter details preserve concealed-value privacy and render invalid pools without invented counts', () => {
+ const bodies=[];
+ for(const value of [0,3,5] as const) {
+ const state=createRicheseNoField(['hidden-zero','hidden-three','hidden-five']);
+ const deployed=deployRicheseNoField(state,{tokenId:state.tokens.find(t=>t.value===value)!.id,controller:'r',location:{territory:'arrakeen',sector:10}});
+ const player={...newPlayer('r','Richese','richese'),noField:projectRicheseNoField(deployed,false)};
+ const html=renderToStaticMarkup(createElement(ForceInventoryDetails,{player}));
+ assert.match(html,/20 physical force counters/);assert.match(html,/Concealed No-Field/);
+ assert.match(html,/Arrakeen · sector 10/);assert.doesNotMatch(html,/hidden-zero|hidden-three|hidden-five/);
+ bodies.push(html);
+ }
+ assert.equal(bodies[0],bodies[1]);assert.equal(bodies[1],bodies[2]);
+ const invalid={...newPlayer('i','Ixians','ixians'),reserves:1};
+ const html=renderToStaticMarkup(createElement(ForceInventoryDetails,{player:invalid}));
+ assert.match(html,/