Files
SpaceWanderer/lib/game/crew.ts
T
kamilkrzywda 06f8a28e96
CI / build + unit + e2e (pull_request) Successful in 2m38s
fix(game): numbered name fallback when pool exhausted — server no longer freezes past 128 systems
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.
2026-09-29 09:18:00 +00:00

101 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure, seeded crew generation — no DB, no Date, no Math.random. Same seed → same starting
// crew; the exploration crew roll draws from the caller's per-planet rng stream so a found
// member is deterministic per save+planet (same derivation as the resource/part haul).
import { mulberry32 } from './world'
export interface CrewMember {
name: string
role: string
traitGood: string
traitBad: string
}
const FIRST_NAMES = [
'AVA', 'BEN', 'CASS', 'DANA', 'ELI', 'FERN', 'GIDEON', 'HALYA',
'IVAR', 'JUNO', 'KAI', 'LORE', 'MARA', 'NICO', 'ODESSA', 'PETRA',
]
const LAST_NAMES = [
'VANCE', 'OKONKWO', 'SORENSEN', 'DELACRUZ', 'HALLOWAY', 'IBARRA', 'KOVAC', 'LINDQVIST',
'MERCER', 'NADEAU', 'ORLOV', 'QUINTANA', 'REYES', 'STALKE', 'TANAKA', 'UNDERWOOD',
'VALDEZ', 'WHITLOCK', 'YAMADA', 'ZUBEROV', 'ASHFORD', 'BLACKWELL',
]
const ROLES = ['COMMANDER', 'PILOT', 'ENGINEER', 'MEDIC', 'NAVIGATOR', 'SCIENTIST', 'GUNNER', 'TECHNICIAN']
const GOOD_TRAITS = [
'STEADY UNDER PRESSURE', 'RECKLESS COURAGE', 'CALM IN A CRISIS', 'FIXES ANYTHING',
'SHARP SHOOTER', 'READS STARS BLIND', 'QUICK HANDS', 'IRON WILL',
'FINDS HIDDEN THINGS', 'TALKS TO ANYONE',
]
const BAD_TRAITS = [
'RIGID — SLOW TO ADAPT', 'SHORT FUSE', 'OVERCAUTIOUS', 'CARELESS WITH FUSES',
'SLOWS THE CREW DOWN', 'ARGUES WITH EVERYONE', 'FORGETS TO EAT', 'JINXED IN A DOGFIGHT',
'HOARDS RATIONS', 'DOUBTS THE CAPTAIN',
]
const STARTING_CREW_MIN = 2
const STARTING_CREW_SPREAD = 2 // 2..3 starting members
export const CREW_CHANCE = 0.18 // a scan finds a crew member who joins the ship (~1 in 5-6 scans)
function title(word: string): string {
return word.charAt(0) + word.slice(1).toLowerCase()
}
function pick<T>(rng: () => number, arr: T[]): T {
return arr[Math.floor(rng() * arr.length)]
}
// A name unique against `taken` (the current roster), claimed into the set on success.
// 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 {
for (;;) {
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
}
}
}
}
}
// The crew a new game starts with: 2–3 members, unique names and roles (roles are drawn
// without replacement). Pure — the caller derives the rng from the save seed.
export function generateStartingCrew(rng: () => number): CrewMember[] {
const taken = new Set<string>()
const roles = [...ROLES]
const crew: CrewMember[] = []
for (let i = 0; i < STARTING_CREW_MIN + Math.floor(rng() * STARTING_CREW_SPREAD); i++) {
const role = roles.splice(Math.floor(rng() * roles.length), 1)[0]
crew.push({ name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) })
}
return crew
}
// The exploration crew roll: with CREW_CHANCE a member is found and joins the ship. Draws
// from the SAME per-planet rng stream as the resource/part haul (called after it in
// save.ts), so the outcome stays deterministic per save+planet. `taken` is the current
// roster's names, keeping every name on the ship unique.
export function rollCrewMember(rng: () => number, taken: Set<string>): CrewMember | null {
if (rng() >= CREW_CHANCE) return null
const role = pick(rng, ROLES)
return { name: makeCrewName(rng, taken), role, traitGood: pick(rng, GOOD_TRAITS), traitBad: pick(rng, BAD_TRAITS) }
}
// Independent PRNG stream for the starting crew of one save (same pattern as bodyRng /
// explorationRng in world.ts — a fixed offset keeps it away from every other derivation).
export function startingCrewRng(seed: string): () => number {
return mulberry32((Number(seed) + 0x51ed270b) >>> 0)
}