diff --git a/CHANGELOG.md b/CHANGELOG.md index cb11311..dc2d125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 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. diff --git a/lib/game/crew.test.ts b/lib/game/crew.test.ts index 4547296..ee8c98a 100644 --- a/lib/game/crew.test.ts +++ b/lib/game/crew.test.ts @@ -71,4 +71,17 @@ describe('makeCrewName', () => { for (let i = 0; i < 40; i++) makeCrewName(rng, taken) 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() + 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) + }) }) diff --git a/lib/game/crew.ts b/lib/game/crew.ts index d3a1c0f..20da79a 100644 --- a/lib/game/crew.ts +++ b/lib/game/crew.ts @@ -49,13 +49,23 @@ function pick(rng: () => number, arr: T[]): T { } // 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 { for (;;) { - const name = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}` - if (!taken.has(name)) { - taken.add(name) - return name + 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)) { + taken.add(name) + return name + } + } } } } diff --git a/lib/game/world.test.ts b/lib/game/world.test.ts index 0c37d78..826c225 100644 --- a/lib/game/world.test.ts +++ b/lib/game/world.test.ts @@ -76,6 +76,19 @@ describe('generateRing (expansion)', () => { for (let i = 0; i < 40; i++) makeName(rng, taken) 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() + 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', () => { diff --git a/lib/game/world.ts b/lib/game/world.ts index 0807953..42c37d1 100644 --- a/lib/game/world.ts +++ b/lib/game/world.ts @@ -36,13 +36,25 @@ function title(word: string): string { 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 { for (;;) { - const name = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}` - if (!taken.has(name)) { - taken.add(name) - return name + 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)) { + taken.add(name) + return name + } + } } } } diff --git a/package.json b/package.json index 7abc0ef..278651e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.9.4", + "version": "0.9.5", "private": true, "scripts": { "dev": "next dev -p 3000",