Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb04d2a51 | ||
|
|
e27ff7015e | ||
|
|
e6c88b10c2 | ||
|
|
f4690bc1a2 |
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.9.7 — 2026-09-29 09:59
|
||||||
|
- LOCAL SYSTEM no longer shows the previous system after a jump (#30): WorldMapPanel owns the world state and applied the `/api/jump` response locally, so MissionControl's lifted `/api/system` state (which drives the LOCAL SYSTEM tab, CARGO HOLD and the SECTOR row) went stale until a page refresh. The panel now takes an `onJumped` callback that MissionControl wires to a `/api/system` refetch after every successful jump — same failure policy as the mount fetch (keep old state on error).
|
||||||
|
- e2e: new smoke spec — click-to-jump on WORLD MAP, wait for the chart's turn counter to update, switch to LOCAL SYSTEM and assert its header shows the NEW current system from `/api/world` (no `page.reload()` workaround; one jump per run/retry, same pattern as the existing jump test).
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- 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.
|
- `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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
import { Panel, PanelContent, PanelHeader, PanelTitle } from '@/components/ui/panel'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
@@ -38,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)
|
||||||
@@ -49,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
|
||||||
@@ -98,6 +106,25 @@ 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)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// #30 — re-fetch /api/system after a jump: WorldMapPanel owns the world state, so without this
|
||||||
|
// the lifted system state stays on the previous system until a page refresh. Same failure
|
||||||
|
// policy as the mount fetch — keep the old state on error.
|
||||||
|
const refreshSystem = useCallback(() => {
|
||||||
|
fetch('/api/system')
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as SystemState
|
||||||
|
})
|
||||||
|
.then((s) => setSystem(s))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
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
|
||||||
@@ -356,7 +383,13 @@ export function MissionControl() {
|
|||||||
</PanelHeader>
|
</PanelHeader>
|
||||||
|
|
||||||
<PanelContent style={{ padding: 0, flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
<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',
|
||||||
@@ -375,7 +408,7 @@ export function MissionControl() {
|
|||||||
|
|
||||||
{/* WORLD MAP TAB — live data from /api/world */}
|
{/* WORLD MAP TAB — live data from /api/world */}
|
||||||
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
<TabsContent value="world-map" style={{ margin: 0, padding: '1rem' }}>
|
||||||
<WorldMapPanel height={380} />
|
<WorldMapPanel height={380} onJumped={refreshSystem} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
|
{/* LOCAL SYSTEM TAB — live data (state owned by MissionControl; scans report up) */}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import type { WorldState } from '@/lib/game/save'
|
|||||||
|
|
||||||
interface WorldMapPanelProps {
|
interface WorldMapPanelProps {
|
||||||
height?: number
|
height?: number
|
||||||
|
onJumped?: () => void // #30 — let the parent refresh /api/system after a successful jump
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
export function WorldMapPanel({ height = 380, onJumped }: WorldMapPanelProps) {
|
||||||
const [world, setWorld] = useState<WorldState | null>(null)
|
const [world, setWorld] = useState<WorldState | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
const [jumpingId, setJumpingId] = useState<number | null>(null)
|
||||||
@@ -47,12 +48,13 @@ export function WorldMapPanel({ height = 380 }: WorldMapPanelProps) {
|
|||||||
const body = (await res.json()) as WorldState & { error?: string }
|
const body = (await res.json()) as WorldState & { error?: string }
|
||||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||||
setWorld(body) // fresh state straight from the response — no refetch needed
|
setWorld(body) // fresh state straight from the response — no refetch needed
|
||||||
|
onJumped?.() // #30 — parent re-fetches /api/system (LOCAL SYSTEM + CARGO HOLD mirror it)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
setJumpingId(null)
|
setJumpingId(null)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [onJumped])
|
||||||
|
|
||||||
if (error && !world) {
|
if (error && !world) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ 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('active tab survives a page reload (#27)', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// 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 }) => {
|
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;
|
const world = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
|
||||||
@@ -100,6 +112,30 @@ test('jumping to an adjacent system updates the world state and turn counter', a
|
|||||||
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
expect(after.systems.find((s) => s.current)?.id).toBe(targetId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('local system shows the new current system after a jump, without a page reload (#30)', async ({ page }) => {
|
||||||
|
const before = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
const currentId = before.systems.find((s) => s.current)!.id;
|
||||||
|
const edge = before.edges.find(([a, b]) => a === currentId || b === currentId)!;
|
||||||
|
const targetId = edge[0] === currentId ? edge[1] : edge[0];
|
||||||
|
const targetName = before.systems.find((s) => s.id === targetId)!.name;
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
// Jump through the UI: click the target's chart field on WORLD MAP (the default tab).
|
||||||
|
await page.getByRole('tabpanel').getByRole('button', { name: targetName.toUpperCase(), exact: true }).click();
|
||||||
|
|
||||||
|
// Wait until the jump response is applied to the chart — server state is committed by then, so
|
||||||
|
// the /api/world read below can't race the in-flight POST.
|
||||||
|
await expect(page.getByText(`GALACTIC CHART // JUMP ROUTES — TURN ${before.turn + 1}`)).toBeVisible();
|
||||||
|
|
||||||
|
const after = (await (await page.request.get('/api/world')).json()) as WorldState;
|
||||||
|
const newCurrent = after.systems.find((s) => s.current)!;
|
||||||
|
expect(newCurrent.id).toBe(targetId); // the jump landed where we aimed
|
||||||
|
|
||||||
|
// Switch to LOCAL SYSTEM — it must show the NEW current system without a page reload (#30).
|
||||||
|
await page.getByRole('tab', { name: 'LOCAL SYSTEM' }).click();
|
||||||
|
await expect(page.getByText(`EXPLORABLE WORLDS // ${newCurrent.name.toUpperCase()} — TURN ${after.turn}`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => {
|
test('local system shows the current system bodies with staggered moons and explore buttons', async ({ page }) => {
|
||||||
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
const sys = (await (await page.request.get('/api/system')).json()) as SystemState;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.9.5",
|
"version": "0.9.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user