db+game: crew table and pure seeded crew generation (starting crew + exploration roll)

This commit is contained in:
2026-09-28 22:42:28 +00:00
parent a6a5e827a1
commit ccd156b7c6
4 changed files with 187 additions and 0 deletions
+10
View File
@@ -61,6 +61,16 @@ CREATE TABLE IF NOT EXISTS inventory_items (
qty INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS crew (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
name TEXT NOT NULL,
role TEXT NOT NULL,
trait_good TEXT NOT NULL,
trait_bad TEXT NOT NULL,
origin TEXT NOT NULL -- 'STARTING CREW' or '<planet>, <system>' for members found while exploring
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id INTEGER NOT NULL REFERENCES saves(id),
+13
View File
@@ -58,6 +58,19 @@ export const inventoryItems = sqliteTable('inventory_items', {
qty: integer('qty').notNull().default(1),
})
// The ship's crew roster: the starting crew (seeded on new game) plus members found while
// exploring planets/moons. Display-only for now — no dismiss/assign actions yet (#7 scope).
export const crew = sqliteTable('crew', {
id: integer('id').primaryKey({ autoIncrement: true }),
saveId: integer('save_id').notNull().references(() => saves.id),
name: text('name').notNull(),
role: text('role').notNull(),
traitGood: text('trait_good').notNull(),
traitBad: text('trait_bad').notNull(),
// Where the member came from: 'STARTING CREW' or '<planet>, <system>' for found members.
origin: text('origin').notNull(),
})
// Append-only game log. Writers so far: new-game + jump events; later issues add more
// writers and a read endpoint.
export const events = sqliteTable('events', {
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { CREW_CHANCE, generateStartingCrew, makeCrewName, rollCrewMember, startingCrewRng } from './crew'
import { explorationRng } from './world'
describe('generateStartingCrew', () => {
it('gives a new game 2–3 members, each with exactly one good and one bad trait', () => {
for (const seed of [1, 7, 42, 999]) {
const crew = generateStartingCrew(startingCrewRng(String(seed)))
expect(crew.length).toBeGreaterThanOrEqual(2)
expect(crew.length).toBeLessThanOrEqual(3)
for (const member of crew) {
expect(member.name.trim().length).toBeGreaterThan(0)
expect(member.role.trim().length).toBeGreaterThan(0)
expect(member.traitGood.trim().length).toBeGreaterThan(0)
expect(member.traitBad.trim().length).toBeGreaterThan(0)
}
// Names are unique within the crew; roles too (drawn without replacement).
expect(new Set(crew.map((m) => m.name)).size).toBe(crew.length)
expect(new Set(crew.map((m) => m.role)).size).toBe(crew.length)
}
})
it('is deterministic under a fixed seed and differs across seeds', () => {
const a = generateStartingCrew(startingCrewRng('42'))
expect(generateStartingCrew(startingCrewRng('42'))).toEqual(a) // same save → same crew
const b = generateStartingCrew(startingCrewRng('43'))
expect(JSON.stringify(b)).not.toBe(JSON.stringify(a)) // different seed → different crew
})
})
describe('rollCrewMember', () => {
it('finds a member with the documented probability and never reuses a taken name', () => {
const rng = startingCrewRng('42')
let found = 0
for (let i = 0; i < 1000; i++) found += rollCrewMember(rng, new Set<string>()) ? 1 : 0
// 18% ± a comfortable band — catches an inverted or missing chance without flaking.
expect(found).toBeGreaterThan(90)
expect(found).toBeLessThan(270)
const taken = new Set(['Ava Vance'])
for (let i = 0; i < 50; i++) {
const member = rollCrewMember(startingCrewRng(String(i)), taken)
if (member) expect(member.name).not.toBe('Ava Vance') // never reuses a roster name
}
})
it('is deterministic per save+planet when drawn from the exploration stream', () => {
const first = rollCrewMember(explorationRng('42', 13), new Set<string>())
expect(rollCrewMember(explorationRng('42', 13), new Set<string>())).toEqual(first) // same save+planet → same result
// Across many planets both outcomes occur (someone is found, someone isn't).
let found = 0
for (let planetId = 1; planetId <= 200; planetId++) {
if (rollCrewMember(explorationRng('42', planetId), new Set<string>())) found++
}
expect(found).toBeGreaterThan(0)
expect(found).toBeLessThan(200)
})
it('keeps the crew chance documented and sane', () => {
expect(CREW_CHANCE).toBeGreaterThanOrEqual(0.15)
expect(CREW_CHANCE).toBeLessThanOrEqual(0.2)
})
})
describe('makeCrewName', () => {
it('never reuses a taken name and always terminates', () => {
const rng = startingCrewRng('7')
const taken = new Set<string>()
for (let i = 0; i < 40; i++) makeCrewName(rng, taken)
expect(taken.size).toBe(40)
})
})
+90
View File
@@ -0,0 +1,90 @@
// 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.
// 16×22 = 352 combinations — a roster realistically never approaches that size.
export function makeCrewName(rng: () => number, taken: Set<string>): string {
for (;;) {
const name = `${title(pick(rng, FIRST_NAMES))} ${pick(rng, LAST_NAMES)}`
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)
}