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:
1 parent
2b071a44a2
commit
537da4a12c
13 files changed
+536
-13
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
@@ -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,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:{' '}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user