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
CI / build + unit + e2e (push) Successful in 2m54s
This commit was merged in pull request #26.
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "space-wanderer",
|
"name": "space-wanderer",
|
||||||
"version": "0.9.4",
|
"version": "0.9.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
Reference in New Issue
Block a user