Compare commits

...
12 Commits
Author SHA1 Message Date
kamilkrzywda e6c88b10c2 Merge pull request 'v0.9.6 — keep active tab between page refresh (#27)' (#29) from keep-active-tab into master
CI / build + unit + e2e (push) Successful in 2m46s
2026-09-29 11:43:21 +02:00
kamilkrzywda f4690bc1a2 feat(ui): keep the active operations tab across page refreshes (#27)
CI / build + unit + e2e (pull_request) Successful in 2m15s
Persist the selected tab ID to localStorage (sw-active-tab) on every change
and restore it on mount when it matches one of the seven valid tab IDs. The
Radix Tabs become controlled for this; SSR and first client render still agree
on the default, so there is no hydration mismatch.
2026-09-29 09:42:18 +00:00
kamilkrzywda 4b030716f7 Merge pull request 'v0.9.5 — fix infinite loop on name-pool exhaustion (server freeze) (#21)' (#26) from ui-layout-fix into master
CI / build + unit + e2e (push) Successful in 2m54s
2026-09-29 11:22:07 +02:00
kamilkrzywda 06f8a28e96 fix(game): numbered name fallback when pool exhausted — server no longer freezes past 128 systems
CI / build + unit + e2e (pull_request) Successful in 2m38s
Production freeze reported after the v0.9.3 (#21) deploy: every API route
hung and all panels stayed at LOADING. Root cause was not the #21 layout
(verified healthy across all 7 tabs/viewports) but makeName's unbounded
rejection loop over a finite 16x8=128 system-name pool — once a save grew
past ~128 systems, any jump into an undiscovered system spun forever on
Node's single thread (sql.js is synchronous), blocking the whole process.

makeName and makeCrewName now fall back to numbered variants ('Kepler Drift 2')
once their pools are exhausted, so they always terminate.
2026-09-29 09:18:00 +00:00
kamilkrzywda 36ff412f0b Merge pull request 'v0.9.4 — top bar shows app version instead of ◄ SHIP (#23)' (#25) from topbar-version into master
CI / build + unit + e2e (push) Successful in 3m12s
2026-09-29 10:33:19 +02:00
kamilkrzywda c35edc60d0 feat(ui): top bar shows app version instead of ◄ SHIP label (#23)
CI / build + unit + e2e (pull_request) Successful in 2m44s
2026-09-29 08:30:35 +00:00
kamilkrzywda f545792c36 Merge pull request 'v0.9.3 — layout: only middle pane scrolls, side panels stay fixed (#21)' (#24) from middle-pane-scroll into master
CI / build + unit + e2e (push) Successful in 2m29s
2026-09-29 10:24:56 +02:00
kamilkrzywda 593cca5dce release v0.9.3
CI / build + unit + e2e (pull_request) Successful in 2m49s
2026-09-29 08:21:14 +00:00
kamilkrzywda d2fb252a98 feat(ui): only the middle column scrolls — side panes stay fixed (#21)
Pin the wide layout to the viewport (height:100vh + overflow:hidden on the
root), add min-height:0 to the BODY wrapper and 3-column grid so the flex
chain actually constrains, and make the OPERATIONS panel a flex column whose
PanelContent is the scroll area (header stays pinned; the Panel root's default
overflow:hidden would otherwise clip). Columns' existing overflow-y:auto now
scrolls inside their tracks. Narrow mode keeps natural body flow.
2026-09-29 08:21:14 +00:00
kamilkrzywda bf6d8714e6 Merge pull request 'v0.9.2 — WORLD MAP: click-to-jump on galactic chart, drop jump routes list (#19)' (#22) from world-map-click-jump into master
CI / build + unit + e2e (push) Successful in 2m19s
2026-09-29 10:06:05 +02:00
kamilkrzywda 32dda04e92 release v0.9.2
CI / build + unit + e2e (pull_request) Successful in 2m57s
2026-09-29 08:00:44 +00:00
kamilkrzywda ef1d282770 feat(ui): world map — jump by clicking chart fields (#19)
Remove the bottom JUMP ROUTES button list; systems adjacent to the current
one become clickable galactic-chart nodes (same POST /api/jump flow, same
state refreshes). Current + unreachable systems are dimmed and not
clickable. NodeGraph gains optional onNodeClick + per-node disabled
(backward-compatible); two smoke tests updated to click chart fields.
2026-09-29 08:00:44 +00:00
10 changed files with 174 additions and 67 deletions
+26
View File
@@ -1,5 +1,31 @@
# Changelog # Changelog
## v0.9.6 — 2026-09-29 09:41
- The active operations tab now survives a page refresh (#27): the selected tab ID is persisted to `localStorage` (`sw-active-tab`) on every change and restored on mount when it matches one of the seven valid tab IDs (unknown/stale values fall back to WORLD MAP). The Radix Tabs are controlled for this; SSR and first client render still agree on the default, so there is no hydration mismatch.
- e2e: new smoke spec — click CREW, reload, assert `aria-selected=true` again (fresh context per run/retry, no API state touched, idempotent).
## v0.9.5 — 2026-09-29 09:13
- Fixed the production freeze reported after the v0.9.3 (#21) deploy ("entire UI system is now fucked"). Root cause was NOT the #21 layout change (verified healthy across all 7 tabs and multiple viewports — no clipping, middle pane scrolls internally, side panes fixed): it was world name generation. `makeName` draws from a finite pool of 16×8=128 system names with an unbounded rejection loop, so once a save grew past ~128 systems (turns of play / e2e jump loops), any jump into an undiscovered system spun forever on Node's single thread — sql.js runs synchronously, so the whole process blocked and EVERY API route hung, leaving all panels stuck at LOADING.
- `makeName` (lib/game/world.ts) now falls back to numbered variants (`Kepler Drift 2`) once the pool is exhausted, so it always terminates; `makeCrewName` (lib/game/crew.ts) gets the same guard — the roster has no cap and its 16×22=352 pool would eventually exhaust too.
- Tests: unit — both name generators terminate with unique numbered names after full pool exhaustion (80 tests green, was 78); e2e unchanged — no spec depends on the old behavior and a fresh CI stack never approaches 128 systems.
## v0.9.4 — 2026-09-29 08:29
- Top bar (#23): the `◄ SHIP` label in the mission control header is replaced with the app version number (e.g. `v0.9.4`). The header reads it from `package.json` via the same `@/package.json` import that `/api/version` and the OpenAPI spec use, so there is a single source of truth — no second hardcoded copy.
- e2e: no spec changes needed — no spec asserted on the old `◄ SHIP` label (the landing-page test only checks the SpaceWanderer heading). All 15 specs compile unchanged.
## v0.9.3 — 2026-09-29 08:20
- Layout (#21): only the middle column scrolls now — the app is pinned to the viewport (`height: 100vh` + `overflow: hidden` on the root in wide mode) so body-level scrolling no longer moves the side panes. Left/right columns keep their existing `overflow-y: auto`, so any overflowing content (e.g. a long planet list) scrolls inside its own track instead.
- Added `min-height: 0` to the BODY wrapper and the 3-column grid — flex/grid items default to a content-based minimum size, which is what let the page grow past the viewport before.
- The middle OPERATIONS panel became a flex column with its PanelContent as the scroll area (`flex: 1; min-height: 0; overflow-y: auto`): long tab content scrolls inside the panel while the "OPERATIONS // SHIP COMMAND" header stays pinned — without this, the Panel root's default `overflow: hidden` would have clipped it.
- Narrow layout (<900px) is unchanged: stacked columns with natural body scroll (the viewport pin applies to wide mode only).
- e2e: no spec changes needed — none use mouse.wheel/scrollTo, and Playwright scrolls click targets into view within any scroll container. All 15 specs compile unchanged.
## v0.9.2 — 2026-09-29 08:00
- WORLD MAP (#19): the bottom JUMP ROUTES button list is gone — jumps now happen by clicking galactic chart fields. Systems adjacent to the current one (the same set as before, undiscovered included) are clickable nodes with a pointer cursor and a `// JUMP` sublabel; the current system stays bright green with CURRENT, unreachable systems are dimmed (OFFLINE) and not clickable.
- NodeGraph (`components/ui/node-graph.tsx`) gained two optional, backward-compatible additions: an `onNodeClick(id)` prop that turns non-disabled nodes into buttons (click + Enter/Space, role=button, aria-label, focusable), and a per-node `disabled` flag — while a jump is in flight every node locks, mirroring the old disabled-button behaviour.
- The jump flow itself is unchanged: same POST /api/jump call, world state refreshed from the response (turn counter, current system, new routes), event log entry written server-side as before.
- e2e: two smoke tests touched the removed buttons and were updated — "world map shows…" now asserts the adjacent systems render as clickable chart fields (role=button count matches API adjacency) and "jumping to an adjacent system" clicks the target node by its accessible name. All 15 specs compile; `jumpToFreshSystem` is API-driven and needed no change.
## v0.9.1 — 2026-09-29 06:47 ## v0.9.1 — 2026-09-29 06:47
- UI layout pass (visual only — no game logic or API changes): CARGO HOLD // INVENTORY moved out of the LOCAL SYSTEM tab into a persistent panel in the right column; the UPCOMING ENCOUNTERS placeholder was removed entirely; the SHIP DATA placeholder was merged into VESSEL METRICS. - UI layout pass (visual only — no game logic or API changes): CARGO HOLD // INVENTORY moved out of the LOCAL SYSTEM tab into a persistent panel in the right column; the UPCOMING ENCOUNTERS placeholder was removed entirely; the SHIP DATA placeholder was merged into VESSEL METRICS.
- Grid: `260px / 1fr / 240px` → `320px / 1fr / 360px` (left pane ~+23%, right pane ~50% wider). - Grid: `260px / 1fr / 240px` → `320px / 1fr / 360px` (left pane ~+23%, right pane ~50% wider).
+33 -4
View File
@@ -18,6 +18,7 @@ import { CREW_CAPACITY } from '@/lib/game/ship-costs'
import type { CrewDto, SystemState } from '@/lib/game/save' import type { CrewDto, SystemState } from '@/lib/game/save'
import type { ShipState } from '@/lib/game/ship' import type { ShipState } from '@/lib/game/ship'
import type { TechDto } from '@/lib/game/tech' import type { TechDto } from '@/lib/game/tech'
import pkg from '@/package.json'
// Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control // Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control
// showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). All seven // showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). All seven
@@ -37,6 +38,11 @@ function useNarrow(threshold = 900): boolean {
return narrow return narrow
} }
// Must match the TabsTrigger values in the render below — used to validate the tab persisted for
// #27 (an unknown stored value falls back to the default).
const TAB_IDS = ['world-map', 'local-system', 'crew', 'ship-systems', 'skill-tree', 'missions', 'event-log']
const ACTIVE_TAB_KEY = 'sw-active-tab'
export function MissionControl() { export function MissionControl() {
const narrow = useNarrow() const narrow = useNarrow()
const [tick, setTick] = useState(0) const [tick, setTick] = useState(0)
@@ -48,6 +54,9 @@ export function MissionControl() {
const [system, setSystem] = useState<SystemState | null>(null) const [system, setSystem] = useState<SystemState | null>(null)
const [techs, setTechs] = useState<TechDto[] | null>(null) const [techs, setTechs] = useState<TechDto[] | null>(null)
const [crewCount, setCrewCount] = useState<number | null>(null) const [crewCount, setCrewCount] = useState<number | null>(null)
// #27 — the active tab survives a page refresh. Restored from localStorage on mount; null until
// then so SSR and the first client render agree on the default tab (no hydration mismatch).
const [activeTab, setActiveTab] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -97,6 +106,12 @@ export function MissionControl() {
return () => clearInterval(id) return () => clearInterval(id)
}, []) }, [])
// #27 — restore the persisted tab once on mount (validated against TAB_IDS).
useEffect(() => {
const stored = window.localStorage.getItem(ACTIVE_TAB_KEY)
if (stored && TAB_IDS.includes(stored)) setActiveTab(stored)
}, [])
const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false }) const timeStr = new Date().toLocaleTimeString('en-GB', { hour12: false })
// Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS // Slugs of the researched techs — drives the effective caps + build gates in the SHIP SYSTEMS
@@ -107,6 +122,10 @@ export function MissionControl() {
<div <div
style={{ style={{
minHeight: '100vh', minHeight: '100vh',
// Wide layout is pinned to the viewport so only the columns scroll internally (#21);
// narrow keeps natural flow with body scroll.
height: narrow ? undefined : '100vh',
overflow: narrow ? undefined : 'hidden',
background: 'var(--background)', background: 'var(--background)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@@ -137,7 +156,7 @@ export function MissionControl() {
flexShrink: 0, flexShrink: 0,
}} }}
> >
◄ SHIP v{pkg.version}
</span> </span>
<span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span> <span style={{ color: 'var(--border)', flexShrink: 0 }}>│</span>
@@ -196,6 +215,7 @@ export function MissionControl() {
<div <div
style={{ style={{
flex: 1, flex: 1,
minHeight: 0, // let the grid below be constrained to the viewport instead of growing it
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '1px', gap: '1px',
@@ -242,6 +262,7 @@ export function MissionControl() {
<div <div
style={{ style={{
flex: 1, flex: 1,
minHeight: 0, // same — the columns' overflowY:auto then scrolls inside their tracks
display: 'grid', display: 'grid',
gridTemplateColumns: narrow ? '1fr' : '320px 1fr 360px', gridTemplateColumns: narrow ? '1fr' : '320px 1fr 360px',
gap: '1px', gap: '1px',
@@ -338,7 +359,9 @@ export function MissionControl() {
overflowY: 'auto', overflowY: 'auto',
}} }}
> >
<Panel notch="md" style={{ flex: 1 }}> {/* flex column + minHeight:0 so the tab content scrolls inside the panel (#21) —
the Panel root's default overflow:hidden would otherwise clip it */}
<Panel notch="md" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<PanelHeader> <PanelHeader>
<PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle> <PanelTitle>OPERATIONS // SHIP COMMAND</PanelTitle>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}> <div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
@@ -346,8 +369,14 @@ export function MissionControl() {
</div> </div>
</PanelHeader> </PanelHeader>
<PanelContent style={{ padding: 0 }}> <PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}>
<Tabs defaultValue="world-map"> <Tabs
value={activeTab ?? 'world-map'}
onValueChange={(v) => {
setActiveTab(v)
window.localStorage.setItem(ACTIVE_TAB_KEY, v) // #27 — keep the tab across refreshes
}}
>
<TabsList <TabsList
style={{ style={{
padding: '0 1rem', padding: '0 1rem',
+12 -1
View File
@@ -13,6 +13,7 @@ export interface NodeGraphNode {
y: number y: number
status?: NodeStatus status?: NodeStatus
sublabel?: string sublabel?: string
disabled?: boolean // not clickable even when onNodeClick is set (e.g. the current system)
} }
export interface NodeGraphEdge { export interface NodeGraphEdge {
@@ -29,6 +30,7 @@ export interface NodeGraphProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string title?: string
height?: number height?: number
directed?: boolean directed?: boolean
onNodeClick?: (id: string) => void // makes non-disabled nodes clickable buttons
} }
const VARIANT_COLORS: Record<NodeGraphVariant, string> = { const VARIANT_COLORS: Record<NodeGraphVariant, string> = {
@@ -59,6 +61,7 @@ const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
title, title,
height = 320, height = 320,
directed = false, directed = false,
onNodeClick,
style, style,
...props ...props
}, },
@@ -219,13 +222,21 @@ const NodeGraph = React.forwardRef<HTMLDivElement, NodeGraphProps>(
const cy = nodePy(node) const cy = nodePy(node)
const isHovered = hovered === node.id const isHovered = hovered === node.id
const statusColor = node.status ? STATUS_COLOR[node.status] : color const statusColor = node.status ? STATUS_COLOR[node.status] : color
const clickable = !!onNodeClick && !node.disabled
return ( return (
<g <g
key={node.id} key={node.id}
onMouseEnter={() => setHovered(node.id)} onMouseEnter={() => setHovered(node.id)}
onMouseLeave={() => setHovered(null)} onMouseLeave={() => setHovered(null)}
style={{ cursor: 'default' }} onClick={clickable ? () => onNodeClick(node.id) : undefined}
onKeyDown={clickable
? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onNodeClick(node.id) } }
: undefined}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
aria-label={clickable ? node.label.toUpperCase() : undefined}
style={{ cursor: clickable ? 'pointer' : 'default' }}
> >
{/* Background */} {/* Background */}
<rect <rect
+20 -45
View File
@@ -1,13 +1,12 @@
'use client' 'use client'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { NodeGraph } from '@/components/ui/node-graph' import { NodeGraph } from '@/components/ui/node-graph'
import type { WorldState } from '@/lib/game/save' import type { WorldState } from '@/lib/game/save'
// WORLD MAP tab — the first live game panel: renders real world state from /api/world and // WORLD MAP tab — renders real world state from /api/world. Clicking a system adjacent to the
// offers a jump button per system adjacent to the current one (NodeGraph nodes are not // current one on the galactic chart jumps there (same POST /api/jump flow as before); the
// clickable upstream, so the affordance lives in this list). Turn-based: fetch on mount, // current and unreachable systems are dimmed and not clickable. Turn-based: fetch on mount,
// refetch via the jump response — no polling. // refetch via the jump response — no polling.
interface WorldMapPanelProps { interface WorldMapPanelProps {
@@ -74,58 +73,34 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
} }
const current = world.systems.find((s) => s.current)! const current = world.systems.find((s) => s.current)!
const reachable = world.edges // Systems one jump away — the only clickable chart fields (the same set as the old JUMP ROUTES list).
.filter(([a, b]) => a === current.id || b === current.id) const reachableIds = new Set(
.map(([a, b]) => (a === current.id ? b : a)) world.edges.filter(([a, b]) => a === current.id || b === current.id).map(([a, b]) => (a === current.id ? b : a))
.map((id) => world.systems.find((s) => s.id === id)!) )
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<NodeGraph <NodeGraph
nodes={world.systems.map((s) => ({ nodes={world.systems.map((s) => {
id: String(s.id), const jumpable = !s.current && reachableIds.has(s.id)
label: s.name, return {
x: s.x, id: String(s.id),
y: s.y, label: s.name,
status: s.current ? 'ACTIVE' : s.discovered ? 'NEUTRAL' : 'OFFLINE', x: s.x,
sublabel: s.current ? 'CURRENT' : s.discovered ? 'DISCOVERED' : 'UNKNOWN', y: s.y,
}))} status: s.current ? 'ACTIVE' : jumpable ? 'NEUTRAL' : 'OFFLINE',
sublabel: s.current ? 'CURRENT' : jumpable ? (s.discovered ? 'DISCOVERED // JUMP' : 'UNKNOWN // JUMP') : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
disabled: !jumpable || jumpingId !== null, // dimmed + not clickable; all locked while a jump is in flight
}
})}
edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))} edges={world.edges.map(([a, b]) => ({ from: String(a), to: String(b) }))}
variant="ACTIVE" variant="ACTIVE"
title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`} title={`GALACTIC CHART // JUMP ROUTES — TURN ${world.turn}`}
height={height} height={height}
style={{ width: '100%' }} style={{ width: '100%' }}
onNodeClick={(id) => void jump(Number(id))}
/> />
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', overflow: 'hidden' }}>
<div
style={{
padding: '0.4rem 0.75rem',
borderBottom: '1px solid var(--border)',
background: 'var(--surface-raised)',
fontSize: '0.6rem',
color: 'var(--text-muted)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
}}
>
JUMP ROUTES // FROM {current.name.toUpperCase()}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', padding: '0.6rem 0.75rem' }}>
{reachable.map((s) => (
<Button
key={s.id}
variant="OUTLINE"
size="SM"
disabled={jumpingId !== null}
onClick={() => void jump(s.id)}
>
{jumpingId === s.id ? 'JUMPING…' : `${s.name.toUpperCase()} // JUMP`}
</Button>
))}
</div>
</div>
{error && ( {error && (
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}> <div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.5rem 0.75rem' }}>
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}> <span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
+24 -6
View File
@@ -70,13 +70,30 @@ test('tabs switch panels client-side (event log terminal)', async ({ page }) =>
await expect(page.getByText(/T\d+ \/\/ /).first()).toBeVisible(); await expect(page.getByText(/T\d+ \/\/ /).first()).toBeVisible();
}); });
test('world map shows the generated starting system and its neighbours', async ({ page }) => { test('active tab survives a page reload (#27)', async ({ page }) => {
await page.goto('/'); await page.goto('/');
// Live chart title carries the turn counter; a fresh save sits at the start with six jump routes. // Fresh context → nothing stored yet, so CREW starts unselected (the default is WORLD MAP).
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'false');
await page.getByRole('tab', { name: 'CREW' }).click();
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true');
await page.reload();
// The tab is restored from localStorage on mount — no API state involved, so this holds across retries.
await expect(page.getByRole('tab', { name: 'CREW' })).toHaveAttribute('aria-selected', 'true');
});
test('world map shows the generated starting system and its neighbours', async ({ page }) => {
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
await page.goto('/');
// Live chart title carries the turn counter.
await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible(); await expect(page.getByText(/GALACTIC CHART \/\/ JUMP ROUTES — TURN \d+/)).toBeVisible();
const jumpButtons = page.getByRole('button', { name: /\/\/ JUMP$/ }); // The current system is marked, and every adjacent system is a clickable chart field (role=button).
await expect(jumpButtons.first()).toBeVisible(); const current = world.systems.find((s) => s.current)!;
await expect(jumpButtons).toHaveCount(6); await expect(page.getByText('CURRENT', { exact: true })).toBeVisible();
const panel = page.getByRole('tabpanel');
const adjacent = world.edges.filter(([a, b]) => a === current.id || b === current.id); // six on a fresh save
await expect(panel.getByRole('button')).toHaveCount(adjacent.length);
}); });
test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => { test('jumping to an adjacent system updates the world state and turn counter', async ({ page }) => {
@@ -87,7 +104,8 @@ test('jumping to an adjacent system updates the world state and turn counter', a
const targetName = before.systems.find((s) => s.id === targetId)!.name; const targetName = before.systems.find((s) => s.id === targetId)!.name;
await page.goto('/'); await page.goto('/');
await page.getByRole('button', { name: `${targetName.toUpperCase()} // JUMP` }).click(); // Click the target's chart field — clickable nodes are role=button with their name as accessible label.
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
const after = (await (await page.request.get('/api/world')).json()) as WorldState; const after = (await (await page.request.get('/api/world')).json()) as WorldState;
expect(after.turn).toBe(before.turn + 1); expect(after.turn).toBe(before.turn + 1);
+13
View File
@@ -71,4 +71,17 @@ describe('makeCrewName', () => {
for (let i = 0; i < 40; i++) makeCrewName(rng, taken) for (let i = 0; i < 40; i++) makeCrewName(rng, taken)
expect(taken.size).toBe(40) expect(taken.size).toBe(40)
}) })
it('terminates with numbered variants once all 352 base names are taken', () => {
const rng = startingCrewRng('7')
const taken = new Set<string>()
for (let i = 0; i < 352; i++) makeCrewName(rng, taken) // exhaust the whole pool
expect(taken.size).toBe(352)
const a = makeCrewName(rng, taken) // must return instead of spinning forever
const b = makeCrewName(rng, taken)
expect(a).not.toBe(b)
expect(taken.has(a)).toBe(true)
expect(taken.has(b)).toBe(true)
})
}) })
+15 -5
View File
@@ -49,13 +49,23 @@ function pick<T>(rng: () => number, arr: T[]): T {
} }
// A name unique against `taken` (the current roster), claimed into the set on success. // A name unique against `taken` (the current roster), claimed into the set on success.
// 16×22 = 352 combinations — a roster realistically never approaches that size. // The pool is finite (16×22=352) and the roster has no cap, so — like makeName — fall back to
// numbered variants once every base is taken instead of spinning forever.
export function makeCrewName(rng: () => number, taken: Set<string>): string { export function makeCrewName(rng: () => number, taken: Set<string>): string {
for (;;) { for (;;) {
const name = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}` const base = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}`
if (!taken.has(name)) { if (!taken.has(base)) {
taken.add(name) taken.add(base)
return name return base
}
if (taken.size >= FIRST_NAMES.length * LAST_NAMES.length) {
for (let n = 2; ; n++) {
const name = `${base} ${n}`
if (!taken.has(name)) {
taken.add(name)
return name
}
}
} }
} }
} }
+13
View File
@@ -76,6 +76,19 @@ describe('generateRing (expansion)', () => {
for (let i = 0; i < 40; i++) makeName(rng, taken) for (let i = 0; i < 40; i++) makeName(rng, taken)
expect(taken.size).toBe(40) expect(taken.size).toBe(40)
}) })
it('terminates with numbered variants once all 128 base names are taken', () => {
const rng = mulberry32(9)
const taken = new Set<string>()
for (let i = 0; i < 128; i++) makeName(rng, taken) // exhaust the whole pool
expect(taken.size).toBe(128)
const a = makeName(rng, taken) // must return instead of spinning forever
const b = makeName(rng, taken)
expect(a).not.toBe(b)
expect(taken.has(a)).toBe(true)
expect(taken.has(b)).toBe(true)
})
}) })
describe('generatePlanets', () => { describe('generatePlanets', () => {
+17 -5
View File
@@ -36,13 +36,25 @@ function title(word: string): string {
return word.charAt(0) + word.slice(1).toLowerCase() return word.charAt(0) + word.slice(1).toLowerCase()
} }
// Picks a name not in `taken` and adds it, so callers can't accidentally reuse one. // Picks a name not in `taken` and adds it, so callers can't accidentally reuse one. The base
// pool is finite (16×8=128), so once every base is taken the loop would spin forever — which
// froze the whole server on a save that had grown past 128 systems (synchronous sql.js runs on
// Node's single thread). Numbered variants keep names unique and terminate.
export function makeName(rng: () => number, taken: Set<string>): string { export function makeName(rng: () => number, taken: Set<string>): string {
for (;;) { for (;;) {
const name = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}` const base = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
if (!taken.has(name)) { if (!taken.has(base)) {
taken.add(name) taken.add(base)
return name return base
}
if (taken.size >= FIRST.length * SECOND.length) {
for (let n = 2; ; n++) {
const name = `${base} ${n}`
if (!taken.has(name)) {
taken.add(name)
return name
}
}
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "space-wanderer", "name": "space-wanderer",
"version": "0.9.1", "version": "0.9.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",