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