Add public force counter inspectors and all-faction gallery

Each player summary opens enlarged original counter diagrams and public physical
pools. The inventory separates reserves, Tanks, board locations and foreign
Homeworld deployments. Native Homeworlds break down existing reserves; elites
and advisor stances remain subsets. The reference gallery covers all twelve
factions, including Suboids/Cyborgs, Sardaukar, Fedaykin and BG advisor faces.

Actual force keys retain unplaced Mobile Stronghold and Discovery groups.
Basic Homeworld special identity comes from public typed pools, not mode alone.
Concealed No-Fields expose only a separate marker/location; no value or future
materialization is inferred. Temporary battle strength creates no physical
counter. Invalid projections show a refresh notice instead of invented totals.
Inspection updates from current props and submits no action or saved mutation.

Independent review found no material issue and clarified No-Field wording:
revelation draws from reserves available then, without earmarking counters.
Eleven new focused cases cover faction identity, typed pools, advisors, genuine
Basic Homeworld public views, native/foreign splits, genuine Ix setup before HMS
placement, a controlled Discovery position, JSON continuation, input immutability,
concealed0/3/5 equivalence, named controls and malformed projection feedback.
A test-only Homeworld projection initially omitted its card identity; corrected
before final checks. Lint's semantic status element was corrected to output.

Types, lint, all 4984 offline tests and production build passed on stable
source. No HTTP/auth/schema/persistence behavior changed, so no additional HTTP
suite was required. Browser QA reused an isolated existing three-human Basic
Nexus without game actions. All three public inspectors showed correct pools;
Escape restored trigger focus, Enter opened inspection and PageDown scrolled the
labelled region. At390px the dialog359px fit, body text16px and Close44px high.
All twelve gallery choices rendered named160px diagrams without gallery overflow.
Internal guide, Back and refresh worked; viewport reset and preview retained.
Screenshot capture timed out. Full visual/screen-reader/200% zoom, manufactured
component fidelity and all-configuration browser acceptance remain open.

All 1499 original rooms, 3171 seats and auxiliary rows are
unchanged; SQLite integrity and foreign keys pass. The healthy server was reused
without restart, migration, reset or automation. AI strategy/calibration remains
deferred until non-AI features are complete; no mode/publication gate was opened.

Private evidence: 20260921-force-inspection.
Verified source tree: 5d7ffd471229faa13224fd5a87d33c3ac5af866446241a7881926a54c1ee49eb
This commit is contained in:
Quintine committed 2026-09-21 11:07:07 +10:00
1 parent 2b071a44a2
commit 537da4a12c
13 files changed
+536 -13

No files matched your search

+4
View File
@@ -65,6 +65,10 @@ At a Nexus, an unanswered alliance offer names its recipient and offers
**Withdraw your alliance offer**. Formed alliances keep the separate **Break
alliance** action. See [Nexus offers](docs/NEXUS_OFFERS.md).
Use **Inspect forces** under any player to enlarge their counters and read public
reserves, Tanks and deployed groups, including Homeworld reserve breakdowns.
See [force inspection](docs/FORCE_INSPECTION.md).
The table header offers optional **Sound effects**, with mute, volume and a test
sample. Short cues mark phase changes and automatic action notices. Sound starts
off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md)
+34
View File
@@ -1952,3 +1952,37 @@ footer {
.nexus-offer .notice, .nexus-offer .game-action { font-size: 1rem; }
.nexus-offer .game-action { min-height: 44px; height: auto; max-width: 100%; white-space: normal; }
.force-inspect-trigger { min-height: 44px; max-width: 100%; white-space: normal; font-size: 1rem; margin-top: .65rem; }
.force-dialog { position: fixed; top: 50%; left: 50%; z-index: 50; transform: translate(-50%, -50%); display: flex; flex-direction: column; width: calc(100% - 1rem); max-width: 46rem; max-height: calc(100dvh - 1rem); overflow: hidden; border: 1px solid #8e7952; border-radius: 1rem; background: #141814; color: #eeeae0; box-shadow: 0 24px 80px #000b; outline: none; }
.force-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .75rem 1rem; border-bottom: 1px solid #414536; font-size: 1rem; }
.force-dialog-scroll { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 1.25rem; }
.force-dialog-scroll:focus-visible { outline: 2px solid #ddbc77; outline-offset: -3px; }
.force-dialog-scroll h2 { margin: 0 0 .75rem; font: 1.6rem/1.3 Georgia, serif; overflow-wrap: anywhere; }
.force-dialog-scroll h3 { margin: 0; font-size: 1.15rem; }
.force-dialog-scroll p, .force-reference p { font-size: 1rem; line-height: 1.65; }
.force-details { display: grid; gap: 1rem; min-width: 0; font-size: 1rem; line-height: 1.6; }
.force-details p { margin: 0; }
.force-details a { color: #edca83; text-decoration: underline; text-underline-offset: .2em; }
.force-examples { display: flex; justify-content: center; gap: 1.25rem; flex-wrap: wrap; margin: 1.25rem 0; }
.force-example { margin: 0; width: 10rem; max-width: 100%; text-align: center; }
.force-disc { width: 10rem; max-width: 100%; aspect-ratio: 1; border-radius: 50%; border: .4rem double var(--counter-color); background: #222a25; color: var(--counter-color); display: flex; position: relative; align-items: center; justify-content: center; box-shadow: inset 0 0 0 .35rem #131713, 0 .4rem 0 #070a07; }
.force-disc > span:first-child { font: bold 4rem/1 Georgia, serif; }
.force-disc-special > span:first-child { transform: translateY(-.6rem); }
.force-disc .force-star { position: absolute; bottom: .7rem; width: 1.7rem; height: 1.7rem; fill: currentColor; }
.force-disc-advisor { border-style: dashed; background: #111811; }
.force-disc-advisor > span:first-child { transform: translateY(-.7rem); }
.force-disc .force-stance { position: absolute; bottom: 1rem; font-size: 1rem; }
.force-example figcaption { margin-top: 1rem; font-size: 1rem; line-height: 1.5; }
.force-example figcaption strong { display: block; }
.force-notice { padding: .8rem 1rem; border: 1px solid #6b714d; border-radius: .5rem; background: #252b20; }
.force-pools { display: grid; gap: 0; margin: 0; }
.force-pools > div { display: flex; justify-content: space-between; flex-wrap: wrap; gap: .4rem 1rem; padding: .75rem 0; border-bottom: 1px solid #424638; }
.force-pools dt { font-weight: 600; overflow-wrap: anywhere; }
.force-pools dd { margin: 0; }
.force-pools dd span { display: block; color: #d8d4c5; }
.force-marker { display: flex; align-items: start; gap: 1rem; padding: 1rem; border: 1px dashed #bbaa7e; border-radius: .5rem; }
.force-marker-back { display: grid; place-items: center; flex: 0 0 2.5rem; height: 3rem; font: 2rem/1 Georgia, serif; border: 2px dashed #bbaa7e; border-radius: .3rem; }
.force-reference { padding: 1rem; border: 1px solid #807352; border-radius: .75rem; font-size: 1rem; }
.force-reference label { display: grid; gap: .5rem; }
.force-reference select { min-height: 44px; width: 100%; font-size: 1rem; }
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { useState, type CSSProperties } from 'react';
import Link from 'next/link';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Eye, Star, X } from 'lucide-react';
import { FACTIONS, faction, type FactionId } from '@/game/catalog';
import { forceCounterName, forceInventory, type ForcePool, type PublicForcePlayer, type PublicForceWorld } from '@/game/force-inventory';
import { Button } from './ui/button';
import { Dialog, DialogClose, DialogDescription, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from './ui/dialog';
type CounterKind = 'force' | 'special' | 'advisor';
/** Original diagrammatic counter faces. Text stays crisp at enlarged sizes. */
export function ForceCounterFace({ factionId, kind }: { factionId: FactionId; kind: CounterKind }) {
const house = faction(factionId);
const name = forceCounterName(factionId, kind);
return <figure className="force-example" aria-label={`${house.name} ${name} counter`} style={{ '--counter-color': house.color } as CSSProperties}>
<div className={`force-disc force-disc-${kind}`} aria-hidden="true">
<span>{house.sigil}</span>
{kind === 'special' && <Star className="force-star" />}
{kind === 'advisor' && <span className="force-stance">Advisor</span>}
</div>
<figcaption>{house.name}<strong>{name}</strong></figcaption>
</figure>;
}
function Examples({ factionId, split = true }: { factionId: FactionId; split?: boolean }) {
const special = ['emperor', 'fremen', 'ixians'].includes(factionId) && split;
return <div className="force-examples">
<ForceCounterFace factionId={factionId} kind="force" />
{special && <ForceCounterFace factionId={factionId} kind="special" />}
{factionId === 'beneGesserit' && <ForceCounterFace factionId={factionId} kind="advisor" />}
</div>;
}
function PoolList({ rows, player, split }: { rows: ForcePool[]; player: PublicForcePlayer; split: boolean }) {
return <dl className="force-pools">{rows.map(row => <div key={row.id}>
<dt>{row.name}</dt>
<dd>
<strong>{row.total}</strong> {row.total === 1 ? 'counter' : 'counters'}
{split && <span>{row.normal} {player.faction === 'ixians' ? 'Suboid' : 'normal'} · {row.elite} {forceCounterName(player.faction, 'special')}</span>}
{row.advisors && <span>Advisor face · included in this count</span>}
</dd>
</div>)}</dl>;
}
export function ForceInventoryDetails({ player, worlds = null }: { player: PublicForcePlayer; worlds?: readonly PublicForceWorld[] | null }) {
const inventory = forceInventory(player, worlds);
if (!inventory) return <output className="force-notice">Force counts could not be reconciled. Refresh the table to read its latest state.</output>;
return <div className="force-details">
<Examples factionId={player.faction} split={inventory.split} />
<p className="force-notice"><strong>{inventory.total} physical force counters</strong> across reserves, the Tanks and deployed groups. This is a counter count, not battle strength.</p>
{!inventory.split && ['emperor', 'fremen'].includes(player.faction) && <p>This table shows a combined force count; starred counters are not listed separately.</p>}
<PoolList rows={inventory.rows} player={player} split={inventory.split} />
{inventory.nativeReserves.length > 0 && <section aria-label="Native Homeworld reserve breakdown">
<h3>Reserves on native Homeworlds</h3>
<p>These counters are already included in Reserves above.</p>
<PoolList rows={inventory.nativeReserves} player={player} split={inventory.split} />
</section>}
{player.faction === 'beneGesserit' && <p>{inventory.advisors} deployed counters currently show their advisor face. Advisors are the same physical counters; reserves and the Tanks have no deployed stance.</p>}
{inventory.marker && <section className="force-marker" aria-label="Concealed No-Field marker">
<span className="force-marker-back" aria-hidden="true">?</span>
<div><h3>Concealed No-Field</h3><p>{inventory.marker.name}</p><p>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.</p></div>
</section>}
<p>Inspecting counters does not move them or commit a Battle Plan. Spice support, temporary powers and leaders may change battle strength without adding counters.</p>
<Link href="/rules?topic=force-counters#force-counters">Force counter guide</Link>
</div>;
}
export function ForceInspector({ player, worlds }: { player: PublicForcePlayer; worlds?: readonly PublicForceWorld[] | null }) {
return <Dialog>
<DialogTrigger render={<Button variant="outline" className="force-inspect-trigger" />} aria-label={`Inspect forces: ${player.name} · ${faction(player.faction).name}`}><Eye aria-hidden="true" />Inspect forces</DialogTrigger>
<DialogPortal>
<DialogOverlay className="bg-black/75 motion-reduce:animate-none motion-reduce:transition-none" />
<DialogPrimitive.Popup className="force-dialog">
<div className="force-dialog-header"><span>Public force counters</span><DialogClose render={<Button variant="outline" className="min-h-11 min-w-11" />}><X aria-hidden="true" />Close</DialogClose></div>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- This separately scrolling inspection region needs keyboard access. */}
<section className="force-dialog-scroll" aria-label="Force counter details" tabIndex={0}>
<DialogTitle>{player.name} · {faction(player.faction).name}</DialogTitle>
<DialogDescription>Enlarged counter faces and the force locations visible to everyone at this table.</DialogDescription>
<ForceInventoryDetails player={player} worlds={worlds} />
</section>
</DialogPrimitive.Popup>
</DialogPortal>
</Dialog>;
}
export function ForceReferenceGallery() {
const [selected, setSelected] = useState<FactionId>('atreides');
return <section className="force-reference" aria-label="Force counter gallery">
<label>Faction<select value={selected} onChange={event => setSelected(event.target.value as FactionId)}>{FACTIONS.map(house => <option key={house.id} value={house.id}>{house.name}</option>)}</select></label>
<Examples factionId={selected} />
<p>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.</p>
{selected === 'beneGesserit' && <p>Fighter and advisor are two faces of the same counter, not two separate forces.</p>}
</section>;
}
+2
View File
@@ -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({
</span>
)}
</div>
<ForceInspector player={p} worlds={g.homeworlds?.worlds} />
{p.revealedTraitors.length > 0 && (
<p className="fine">
Revealed traitors:{' '}
+2
View File
@@ -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' && <NexusCardGallery />}
{topic.id === 'storm-cards' && <StormCardGallery />}
{topic.id === 'kwisatz-haderach' && <KwisatzReferenceCard />}
{topic.id === 'force-counters' && <ForceReferenceGallery />}
{topic.id === 'richese-cards' && (
<section
aria-label="Richese card collection"
+8
View File
@@ -1,5 +1,13 @@
# Component inventory and verification ledger
**Force counter inspection, 21 September 2026:** each public player summary now
opens enlarged faction diagrams and a physical location inventory. All twelve
sets have reference faces; special counters, advisor stances, native Homeworld
reserve breakdowns and foreign deployments retain distinct accounting. Concealed
No-Fields show only a separate marker. This is a connected inspection prototype;
manufactured-face fidelity and full visual/combined-rule acceptance remain open.
See [scope and evidence](FORCE_INSPECTION.md).
**Kwisatz Haderach inspection, 21 September 2026:** original companion artwork,
readable enlargement, private loss tracking and availability guidance now connect
to Atreides controls, authorized plan inspection and the internal reference.
+14 -13
View File
@@ -6,20 +6,21 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
Latest delivered checkpoint: `91dcca4`, [ordinary bribe controls](BRIBE_CONTROLS.md).
Types, lint, **4,968 offline tests**, production build and **49 HTTP tests** passed.
All 1,418 opening rooms, 2,991 seats and auxiliary rows stayed unchanged. Isolated
QA brought the local baseline to 1,459 rooms and 3,081 seats. Browser payment,
refresh, keyboard controls and a 390px layout passed; screenshot capture and full
visual acceptance remain open. The healthy server was reused. Prior checkpoints
include `7814f74` for table discussion and `b2fcef8` for Storm/Spice practice.
Latest delivered checkpoint: `2b071a4`, [live Nexus offer withdrawal](NEXUS_OFFERS.md).
Types, lint, **4,973 offline tests**, production build and **49 HTTP tests** passed.
All 1,459 opening rooms, 3,081 seats and auxiliary rows stayed unchanged. Isolated
QA brought the local baseline to 1,499 rooms and 3,171 seats. Browser proposal,
refresh, keyboard withdrawal, preserved incoming offer, acceptance, break and
390px layout checks passed. Full visual acceptance remains open. The healthy
server was reused. Prior checkpoints include `91dcca4` for bribes, `7814f74` for
table discussion and `b2fcef8` for Storm/Spice practice.
Current work: [live Nexus offer withdrawal](NEXUS_OFFERS.md) connects the existing
unanswered-offer action, shows the recipient and records named proposal/withdrawal
history. Current alliance rules, pending-window locks, readiness and version
fences remain intact. Source-bound checks, independent review, browser and
preservation/Git evidence belong to the private checkpoint. The broader game
remains unfinished. The separate Bureaucrat/Emperor gift question is pending.
Current work: [public force inspection](FORCE_INSPECTION.md) connects enlarged
counters and public location inventories for all factions, with special/advisor
subsets, Homeworld reserve breakdowns and concealed No-Field markers. This is
read-only; source-bound checks, independent review, browser and preservation/Git
evidence belong to the private checkpoint. The broader game remains unfinished.
The separate Bureaucrat/Emperor gift question is pending.
The `8f7a62c` policy checkpoint removed usage checks/cutoffs and deferred AI
refinement until feature completion, followed by three adjacent 75% targets.
+49
View File
@@ -0,0 +1,49 @@
# Public force counter inspection
21 September 2026. Each live player summary now opens **Inspect forces**.
The enlarged diagrams and public location list work during other players’
decisions without submitting an action. The internal reference has a faction
selector covering all twelve sets, including special and advisor faces.
The [inventory projection](../game/force-inventory.ts) consumes public player
force fields and the existing public Homeworld table. Reserves, Tanks, nonzero
board groups and foreign Homeworld deployments count physical counters. Elites
are subsets, never additional counters; native Homeworld groups subdivide the
reserve pool. Emperor’s two worlds remain separate in that breakdown. The
existing [Homeworld custody](../game/homeworld-custody.ts) and
[public table](../game/homeworld-game.ts) define those identities.
Ixian ordinary forces are Suboids; special names reuse
[the combat registry](../game/combat.ts). Physical special identity comes from
the public elite pools, including Basic Homeworld games. Ordinary Basic tables
that combine Emperor/Fremen counts do not fabricate a starred distribution.
Bene Gesserit [advisor stance](../game/advisors.ts) applies across a territory’s
sectors and does not create an extra counter. Reserves and Tanks have no deployed
stance. Temporary Sardaukar powers, support, leaders and Kwisatz do not change
this physical inventory.
The projection iterates actual force locations, including the unplaced Mobile
Stronghold during genuine Ix setup and named Discovery locations. It does not
filter away pieces because their location is outside the current map. Native
worlds stay separate from board location keys. Invalid counts or unreconciled
native reserves show a refresh notice instead of invented totals.
A deployed [No-Field](../game/richese-no-field.ts) contributes only a separate
concealed marker and its public location. No hidden face, inventory, last-used
value or future force quantity is read or inferred. Revelation draws from the
reserves available then; it does not reserve particular counters. Effective
presence helpers are deliberately excluded from physical counting.
Focused tests cover twelve faction identities, mixed pools, multi-sector
advisors, native and foreign Homeworlds, actual Basic Homeworld projections,
genuine Ix setup before HMS placement, a controlled Discovery location, identical
0/3/5 concealed-marker results, immutable input and JSON continuation. Rendering
tests cover named live triggers, the reference gallery, readable details and
invalid-state feedback. This is read-only inspection, so no AI action or strategy
was added. Existing legal participation and saves remain authoritative.
Source-bound checks, independent review, browser observations, preservation and
Git delivery are recorded in the private checkpoint. These are original
diagrammatic faces, not certification of the manufactured components’ layout.
Full visual/accessibility acceptance and combined-game verification remain open;
mode and publication gates are unchanged.
+10
View File
@@ -1,5 +1,15 @@
# Dune implementation status
## 21 September 2026 — public force counter inspection
Every player now has **Inspect forces**, with enlarged diagrams, special subsets,
advisor faces, board locations and native/foreign Homeworld accounting. The
reference includes all twelve factions. [Force inspection](FORCE_INSPECTION.md)
excludes concealed No-Field values and temporary strength bonuses from physical
counts. Focused projections, rendering, genuine setup and JSON recovery accompany
independent review and private browser/preservation/Git evidence. No game action,
AI strategy, schema, mode or publication gate changed; the full goal is unfinished.
## 21 September 2026 — live Nexus offer withdrawal
An unallied sender can now see the recipient of its outgoing Nexus offer and
+94
View File
@@ -0,0 +1,94 @@
import { splitLocation, territory } from './board';
import type { FactionId } from './catalog';
import { specialForceName } from './combat';
import { isAdvisor } from './advisors';
import { HOMEWORLD_CARDS } from './homeworld-cards';
/** Public physical custody only. No private cards, commitments or token faces. */
export type PublicForcePlayer = {
id: string;
name: string;
faction: FactionId;
reserves: number;
tanks: number;
forces: Readonly<Record<string, number>>;
elites?: { reserves: number; tanks: number; forces: Readonly<Record<string, number>> };
advisors?: Record<string, { lockedTurn?: number }>;
noField?: { deployed: { location: { territory: string; sector: number } } | null } | null;
};
export type PublicForceWorld = {
id: string;
native: string;
card: string;
forces: Readonly<Record<string, { normal: number; elite: number }>>;
};
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;
}
}
+25
View File
@@ -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',
+62
View File
@@ -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</g)??[]).length,2);
assert.equal(JSON.stringify(g),before);
});
void test('reference covers every faction and renders enlarged ordinary, special and advisor identities', () => {
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,/<output/);assert.match(html,/could not be reconciled/);assert.doesNotMatch(html,/physical force counters/);
});
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { FACTIONS } from '../game/catalog';
import { forceCounterName, forceInventory, type PublicForcePlayer } from '../game/force-inventory';
import { applyAction, createGame, initializeIxGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { gameTerritories, MOBILE_STRONGHOLD } from '../game/board';
import { DISCOVERY_LOCATIONS } from '../game/discoveries';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { homeworldPopulations } from '../game/homeworld-population';
import { homeworldRevivalFixture } from './fixture-homeworld-revival';
import { createRicheseNoField, deployRicheseNoField, projectRicheseNoField } from '../game/richese-no-field';
void test('every faction has inspectable physical reserves without consulting private fields', () => {
for (const house of FACTIONS) {
const player = newPlayer('p', house.name, house.id);
const inventory = forceInventory(player)!;
assert.ok(inventory); assert.equal(inventory.total, 20);
assert.equal(inventory.rows[0].total, 20);
assert.equal(inventory.normal + inventory.elite, 20);
}
assert.equal(forceCounterName('ixians', 'force'), 'Suboid');
assert.equal(forceCounterName('ixians', 'special'), 'Cyborg');
assert.equal(forceCounterName('emperor', 'special'), 'Sardaukar');
assert.equal(forceCounterName('fremen', 'special'), 'Fedaykin');
assert.equal(forceCounterName('beneGesserit', 'advisor'), 'Advisor');
});
void test('special counters are subsets in reserves, Tanks and board groups, never extra strength counters', () => {
const player: PublicForcePlayer = { ...newPlayer('e', 'Emperor', 'emperor'), reserves: 8, tanks: 4,
forces: { 'arrakeen:10': 5, 'imperial_basin:9': 3 },
elites: { reserves: 3, tanks: 1, forces: { 'arrakeen:10': 1 } } };
const before = JSON.stringify(player);
const inventory = forceInventory(player)!;
assert.deepEqual([inventory.total, inventory.normal, inventory.elite], [20, 15, 5]);
assert.deepEqual(inventory.rows.map(r => [r.total, r.normal, r.elite]), [[8,5,3],[4,3,1],[5,4,1],[3,3,0]]);
assert.deepEqual(forceInventory(JSON.parse(before)), inventory);
inventory.rows[0].total = 99;
assert.equal(JSON.stringify(player), before);
});
void test('native Homeworlds subdivide reserves and foreign deployments count exactly once', () => {
const player: PublicForcePlayer = { ...newPlayer('e', 'Emperor', 'emperor'), reserves: 10, tanks: 4,
forces: { 'arrakeen:10': 4 }, elites: { reserves: 3, tanks: 0, forces: { 'arrakeen:10': 1 } } };
const context = { advanced: true, players: [
{ id:'e', faction:'emperor' as const, reserves:10, eliteReserves:3 },
{ id:'i', faction:'ixians' as const, reserves:10, eliteReserves:2 },
] };
const custody = { salusa: { normal:3, elite:2 }, visitors: {
'homeworld:emperor': { i: { normal:2, elite:0 } },
'homeworld:ixians': { e: { normal:1, elite:1 } },
} };
const populations = homeworldPopulations(context, custody);
const worlds = homeworldForceGroups(context, custody).map(world => ({ ...world, ...populations.find(p => p.location === world.id)! }));
const before = JSON.stringify({ player, worlds });
const inventory = forceInventory(player, worlds)!;
assert.deepEqual([inventory.total, inventory.elite], [20, 5]);
assert.deepEqual(inventory.nativeReserves.map(r => [r.name, r.total, r.elite]), [['Kaitain',5,1],['Salusa Secundus',5,2]]);
assert.equal(inventory.rows.find(r => r.name === 'Ix')!.total, 2);
assert.equal(JSON.stringify({ player, worlds }), before);
});
void test('genuine Basic Homeworld setup retains physical Sardaukar and Fedaykin through all private views and JSON', () => {
const game = homeworldRevivalFixture();
assert.equal(game.advanced, false);
const before = JSON.stringify(game);
for (const target of game.players) {
const inventories = game.players.map(viewer => {
const view = viewGame(JSON.parse(before), viewer.id);
return forceInventory(view.players.find(p => p.id === target.id)!, view.homeworlds?.worlds);
});
assert.ok(inventories[0]);
assert.equal(inventories[0]!.total, 20);
assert.equal(inventories[0]!.elite, target.faction === 'emperor' ? 5 : 3);
assert.ok(inventories.every(i => JSON.stringify(i) === JSON.stringify(inventories[0])));
}
assert.equal(JSON.stringify(game), before);
});
void test('advisor stance covers all sectors of its territory without adding counters or changing reserves', () => {
const player = { ...newPlayer('b','Bene Gesserit','beneGesserit'), reserves:11,
forces: { 'imperial_basin:9':3, 'imperial_basin:10':2, 'arrakeen:10':4 }, advisors: { imperial_basin:{} } };
const inventory = forceInventory(player)!;
assert.equal(inventory.total,20); assert.equal(inventory.advisors,5);
assert.deepEqual(inventory.rows.filter(r => r.advisors).map(r => r.total),[2,3]);
assert.equal(inventory.rows[0].advisors,false);
assert.equal(forceInventory({...player,faction:'atreides'})!.advisors,0);
});
void test('genuine Ix setup exposes its six counters before the Mobile Stronghold enters the map', () => {
let game=createGame('FORCEIX',newPlayer('i','Ixians','ixians'),false,['ix']);
joinGame(game,newPlayer('a','Atreides','atreides'));
for(const p of game.players) game=applyAction(game,p.id,{type:'ready'});
game=initializeIxGameForAudit(game);
for(let n=0; !game.players[0].forces[`${MOBILE_STRONGHOLD}:0`] && n<30; n++) {
let moved=false;
for(const p of game.players) {
const view=viewGame(game,p.id); view.players.find(s=>s.id===p.id)!.bot='Easy';
const action=botActions(view)[0];
if(action) { game=applyAction(game,p.id,action); moved=true; break; }
}
assert.ok(moved,'Setup must make legal progress.');
}
assert.equal(game.mobileStronghold?.location,null);
assert.ok(!gameTerritories(game).some(t=>t.id===MOBILE_STRONGHOLD));
const view=viewGame(game,'a');
const inventory=forceInventory(view.players.find(p=>p.id==='i')!)!;
assert.equal(inventory.total,20);assert.equal(inventory.elite,7);
const hms=inventory.rows.find(r=>r.id===`${MOBILE_STRONGHOLD}:0`)!;
assert.equal(hms.total,6);assert.equal(hms.elite,3);assert.doesNotMatch(hms.name,/sector 0/);
const discovery=DISCOVERY_LOCATIONS[0];
const placed={...newPlayer('a','Atreides','atreides'),reserves:18,forces:{[`${discovery.id}:0`]:2}};
assert.equal(forceInventory(placed)!.rows[2].name,discovery.name);
});
void test('concealed No-Field values have identical public inventories and add no physical counter', () => {
const outputs=[];
for(const value of [0,3,5] as const) {
const state=createRicheseNoField(['secret-zero','secret-three','secret-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 inventory=forceInventory(player)!;
assert.equal(inventory.total,20);assert.deepEqual(inventory.marker,{name:'Arrakeen · sector 10'});
assert.doesNotMatch(JSON.stringify(inventory),/secret-|tokenId|lastShipped/);
outputs.push(inventory);
}
assert.deepEqual(outputs[0],outputs[1]);assert.deepEqual(outputs[1],outputs[2]);
});
void test('inconsistent public pools produce no fabricated inventory', () => {
const player=newPlayer('i','Ixians','ixians');
assert.equal(forceInventory({...player,reserves:2}),null);
assert.equal(forceInventory({...player,forces:{'unknown:2':1}}),null);
assert.equal(forceInventory(player,[{id:'homeworld:ixians',native:'i',card:'ix',forces:{i:{normal:0,elite:0}}}]),null);
});