From 06f8a28e96a7725b714b672d113c4f4160fd8f8d Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 09:18:00 +0000 Subject: [PATCH] =?UTF-8?q?fix(game):=20numbered=20name=20fallback=20when?= =?UTF-8?q?=20pool=20exhausted=20=E2=80=94=20server=20no=20longer=20freeze?= =?UTF-8?q?s=20past=20128=20systems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 5 +++++ lib/game/crew.test.ts | 13 +++++++++++++ lib/game/crew.ts | 20 +++++++++++++++----- lib/game/world.test.ts | 13 +++++++++++++ lib/game/world.ts | 22 +++++++++++++++++----- package.json | 2 +- 6 files changed, 64 insertions(+), 11 deletions(-) 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", -- 2.54.0