Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6c88b10c2 | ||
|
|
f4690bc1a2 | ||
|
|
4b030716f7 | ||
|
|
06f8a28e96 | ||
|
|
36ff412f0b | ||
|
|
c35edc60d0 | ||
|
|
f545792c36 | ||
|
|
593cca5dce | ||
|
|
d2fb252a98 | ||
|
|
bf6d8714e6 | ||
|
|
32dda04e92 | ||
|
|
ef1d282770 |
@@ -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).
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+16
-41
@@ -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) => {
|
||||||
|
const jumpable = !s.current && reachableIds.has(s.id)
|
||||||
|
return {
|
||||||
id: String(s.id),
|
id: String(s.id),
|
||||||
label: s.name,
|
label: s.name,
|
||||||
x: s.x,
|
x: s.x,
|
||||||
y: s.y,
|
y: s.y,
|
||||||
status: s.current ? 'ACTIVE' : s.discovered ? 'NEUTRAL' : 'OFFLINE',
|
status: s.current ? 'ACTIVE' : jumpable ? 'NEUTRAL' : 'OFFLINE',
|
||||||
sublabel: s.current ? 'CURRENT' : s.discovered ? 'DISCOVERED' : 'UNKNOWN',
|
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
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+12
-2
@@ -49,15 +49,25 @@ 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(base)) {
|
||||||
|
taken.add(base)
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if (taken.size >= FIRST_NAMES.length * LAST_NAMES.length) {
|
||||||
|
for (let n = 2; ; n++) {
|
||||||
|
const name = `${base} ${n}`
|
||||||
if (!taken.has(name)) {
|
if (!taken.has(name)) {
|
||||||
taken.add(name)
|
taken.add(name)
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The crew a new game starts with: 2–3 members, unique names and roles (roles are drawn
|
// The crew a new game starts with: 2–3 members, unique names and roles (roles are drawn
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
+14
-2
@@ -36,15 +36,27 @@ 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(base)) {
|
||||||
|
taken.add(base)
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if (taken.size >= FIRST.length * SECOND.length) {
|
||||||
|
for (let n = 2; ; n++) {
|
||||||
|
const name = `${base} ${n}`
|
||||||
if (!taken.has(name)) {
|
if (!taken.has(name)) {
|
||||||
taken.add(name)
|
taken.add(name)
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(v: number): number {
|
function clamp(v: number): number {
|
||||||
|
|||||||
+1
-1
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user