From ccd156b7c62b3de66e6f1b821c3d149a7ce2d451 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:42:28 +0000 Subject: [PATCH] db+game: crew table and pure seeded crew generation (starting crew + exploration roll) --- lib/db/client.ts | 10 +++++ lib/db/schema.ts | 13 +++++++ lib/game/crew.test.ts | 74 +++++++++++++++++++++++++++++++++++ lib/game/crew.ts | 90 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 lib/game/crew.test.ts create mode 100644 lib/game/crew.ts diff --git a/lib/db/client.ts b/lib/db/client.ts index 70ff305..a11e4d6 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -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 ', ' for members found while exploring +); + CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, save_id INTEGER NOT NULL REFERENCES saves(id), diff --git a/lib/db/schema.ts b/lib/db/schema.ts index ef42d28..4dccd33 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -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 ', ' 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', { diff --git a/lib/game/crew.test.ts b/lib/game/crew.test.ts new file mode 100644 index 0000000..4547296 --- /dev/null +++ b/lib/game/crew.test.ts @@ -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()) ? 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()) + expect(rollCrewMember(explorationRng('42', 13), new Set())).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())) 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() + for (let i = 0; i < 40; i++) makeCrewName(rng, taken) + expect(taken.size).toBe(40) + }) +}) diff --git a/lib/game/crew.ts b/lib/game/crew.ts new file mode 100644 index 0000000..d3a1c0f --- /dev/null +++ b/lib/game/crew.ts @@ -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(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 { + 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() + 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): 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) +}