CI / build + unit + e2e (pull_request) Successful in 2m38s
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.
101 lines
4.0 KiB
TypeScript
101 lines
4.0 KiB
TypeScript
// 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)
|
||
}
|