From 537da4a12c87eaf006b34ac0a78ebd189763a39c Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:07:07 +1000 Subject: [PATCH] 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 --- README.md | 4 + app/globals.css | 34 +++++++++ components/force-inspector.tsx | 97 +++++++++++++++++++++++ components/game-table.tsx | 2 + components/rules-reference.tsx | 2 + docs/COMPONENT_INVENTORY.md | 8 ++ docs/CURRENT_STATUS.md | 27 +++---- docs/FORCE_INSPECTION.md | 49 ++++++++++++ docs/IMPLEMENTATION_STATUS.md | 10 +++ game/force-inventory.ts | 94 +++++++++++++++++++++++ game/reference.ts | 25 ++++++ tests/force-inspector.test.tsx | 62 +++++++++++++++ tests/force-inventory.test.ts | 135 +++++++++++++++++++++++++++++++++ 13 files changed, 536 insertions(+), 13 deletions(-) create mode 100644 components/force-inspector.tsx create mode 100644 docs/FORCE_INSPECTION.md create mode 100644 game/force-inventory.ts create mode 100644 tests/force-inspector.test.tsx create mode 100644 tests/force-inventory.test.ts diff --git a/README.md b/README.md index 73f2ddee..cdda224f 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app/globals.css b/app/globals.css index 39f9b56e..aee0c263 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/components/force-inspector.tsx b/components/force-inspector.tsx new file mode 100644 index 00000000..2da624c4 --- /dev/null +++ b/components/force-inspector.tsx @@ -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
+ +
{house.name}{name}
+
; +} + +function Examples({ factionId, split = true }: { factionId: FactionId; split?: boolean }) { + const special = ['emperor', 'fremen', 'ixians'].includes(factionId) && split; + return
+ + {special && } + {factionId === 'beneGesserit' && } +
; +} + +function PoolList({ rows, player, split }: { rows: ForcePool[]; player: PublicForcePlayer; split: boolean }) { + return
{rows.map(row =>
+
{row.name}
+
+ {row.total} {row.total === 1 ? 'counter' : 'counters'} + {split && {row.normal} {player.faction === 'ixians' ? 'Suboid' : 'normal'} · {row.elite} {forceCounterName(player.faction, 'special')}} + {row.advisors && Advisor face · included in this count} +
+
)}
; +} + +export function ForceInventoryDetails({ player, worlds = null }: { player: PublicForcePlayer; worlds?: readonly PublicForceWorld[] | null }) { + const inventory = forceInventory(player, worlds); + if (!inventory) return Force counts could not be reconciled. Refresh the table to read its latest state.; + return
+ +

{inventory.total} physical force counters across reserves, the Tanks and deployed groups. This is a counter count, not battle strength.

+ {!inventory.split && ['emperor', 'fremen'].includes(player.faction) &&

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.

+ Force counter guide +
; +} + +export function ForceInspector({ player, worlds }: { player: PublicForcePlayer; worlds?: readonly PublicForceWorld[] | null }) { + return + } aria-label={`Inspect forces: ${player.name} · ${faction(player.faction).name}`}> + + + +
Public force counters}>
+ {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- This separately scrolling inspection region needs keyboard access. */} +
+ {player.name} · {faction(player.faction).name} + Enlarged counter faces and the force locations visible to everyone at this table. + +
+
+
+
; +} + +export function ForceReferenceGallery() { + const [selected, setSelected] = useState('atreides'); + return
+ + +

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' && (