From ccd156b7c62b3de66e6f1b821c3d149a7ce2d451 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:42:28 +0000 Subject: [PATCH 1/6] 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) +} -- 2.54.0 From 7a9ff110f354fa5a6eba01416956b3e9259ae472 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:45:55 +0000 Subject: [PATCH 2/6] game: starting crew seeded on new game; exploration rolls for a joining crew member (same per-planet stream) --- lib/game/save.test.ts | 74 ++++++++++++++++++++++++++++++++++++++++++- lib/game/save.ts | 50 ++++++++++++++++++++++++++--- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/lib/game/save.test.ts b/lib/game/save.test.ts index 1f3a7df..3b0129e 100644 --- a/lib/game/save.test.ts +++ b/lib/game/save.test.ts @@ -4,7 +4,8 @@ import path from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { openGameDb, type GameDb } from '@/lib/db/client' import * as schema from '@/lib/db/schema' -import { getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save' +import { generateStartingCrew, rollCrewMember, startingCrewRng } from './crew' +import { getCrewState, getSystemState, explorePlanet, getWorldState, jumpToSystem, type WorldState } from './save' import { bodyRng, explorationRng, generatePlanets, rollExploration } from './world' const dirs: string[] = [] @@ -228,3 +229,74 @@ describe('explorePlanet', () => { expect(state.turn).toBe(planetIds.length) // one turn per scan }) }) + +describe('getCrewState', () => { + it('seeds a starting crew of 2–3 members derived from the save seed', async () => { + const { db } = await freshFile() + const crew = await getCrewState(db) // ensureSave runs inside, so this also seeds the crew + + expect(crew.length).toBeGreaterThanOrEqual(2) + expect(crew.length).toBeLessThanOrEqual(3) + for (const member of crew) { + expect(member.origin).toBe('STARTING CREW') + expect(member.name.trim()).not.toBe('') + expect(member.role.trim()).not.toBe('') + expect(member.traitGood.trim()).not.toBe('') + expect(member.traitBad.trim()).not.toBe('') + } + + // The roster is exactly the pure generation for this save's seed. + const [save] = await db.db.select().from(schema.saves) + const expected = generateStartingCrew(startingCrewRng(save.seed)) + expect(crew.map((c) => c.name)).toEqual(expected.map((m) => m.name)) + expect(crew.map((c) => c.role)).toEqual(expected.map((m) => m.role)) + expect(crew.map((c) => [c.traitGood, c.traitBad])).toEqual(expected.map((m) => [m.traitGood, m.traitBad])) + }) +}) + +describe('explorePlanet crew finds', () => { + it('lands a found member in the roster with the planet+system origin and never joins twice (known seed)', async () => { + // Hand-built save with a known seed so the crew roll is recomputable offline. + const { db } = await freshFile() + const [save] = await db.db.insert(schema.saves).values({ seed: '42', turn: 0, createdAt: new Date().toISOString() }).returning() + const [sys] = await db.db + .insert(schema.solarSystems) + .values({ saveId: save.id, name: 'Test System', x: 50, y: 50, discovered: true, isCurrent: true }) + .returning({ id: schema.solarSystems.id }) + + // Find a small planet id whose exploration roll finds crew (deterministic — recomputed here, + // mirroring save.ts: the item haul draws first, then the crew roll on the same stream). + let crewPlanetId = -1 + for (let id = 1; id <= 50 && crewPlanetId === -1; id++) { + const rng = explorationRng('42', id) + rollExploration(rng) + if (rollCrewMember(rng, new Set())) crewPlanetId = id + } + expect(crewPlanetId).toBeGreaterThan(0) + + const planetName = `P${crewPlanetId}` + for (let i = 1; i <= crewPlanetId; i++) { + await db.db.insert(schema.planets).values({ systemId: sys.id, parentId: null, name: `P${i}`, type: 'ROCKY', hazard: false }) + } + + const before = await getCrewState(db) // empty — this save was hand-built, no starting crew seeded + const result = await explorePlanet(db, crewPlanetId) + + expect(result.crewFound).not.toBeNull() + const expectedRng = explorationRng('42', crewPlanetId) + rollExploration(expectedRng) // consume the item haul draws first, exactly as save.ts does + const expectedMember = rollCrewMember(expectedRng, new Set())! // same save+planet → same member + expect(result.crewFound!.name).toBe(expectedMember.name) + expect(result.crewFound!.role).toBe(expectedMember.role) + expect([result.crewFound!.traitGood, result.crewFound!.traitBad]).toEqual([expectedMember.traitGood, expectedMember.traitBad]) + expect(result.crewFound!.origin).toBe(`${planetName}, Test System`) + + const after = await getCrewState(db) + expect(after.length).toBe(before.length + 1) // exactly one join + expect(after.find((c) => c.id === result.crewFound!.id)).toMatchObject({ name: expectedMember.name, origin: `${planetName}, Test System` }) + + // The body is already explored → a second scan is rejected and no duplicate joins. + await expect(explorePlanet(db, crewPlanetId)).rejects.toMatchObject({ status: 409 }) + expect((await getCrewState(db)).length).toBe(before.length + 1) + }) +}) diff --git a/lib/game/save.ts b/lib/game/save.ts index a7ea44b..4922e3e 100644 --- a/lib/game/save.ts +++ b/lib/game/save.ts @@ -2,6 +2,7 @@ import { eq } from 'drizzle-orm' import type { GameDb } from '@/lib/db/client' import { persist } from '@/lib/db/client' import * as schema from '@/lib/db/schema' +import { generateStartingCrew, rollCrewMember, startingCrewRng, type CrewMember } from './crew' import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, mulberry32, rollExploration, type FoundItem } from './world' // Save lifecycle: auto-init on first hit, world state reads, and the jump action. @@ -67,6 +68,13 @@ async function insertRoutes(db: GameDb, edges: [number, number][], resolve: (ind } } +// Seeds the starting crew for a fresh save: 2–3 members derived from the seed alone. +async function seedStartingCrew(db: GameDb, saveId: number, seed: string): Promise { + for (const member of generateStartingCrew(startingCrewRng(seed))) { + await db.db.insert(schema.crew).values({ saveId, name: member.name, role: member.role, traitGood: member.traitGood, traitBad: member.traitBad, origin: 'STARTING CREW' }) + } +} + // Creates the save + starting region when the DB has none. No-op otherwise — safe to call // on every request (single player, one process: check-then-insert cannot race in practice). export async function ensureSave(db: GameDb): Promise { @@ -86,6 +94,7 @@ export async function ensureSave(db: GameDb): Promise { const resolve = (index: number) => (index === 0 ? startId : neighborIds[index - 1]) await insertRoutes(db, region.edges, resolve) + await seedStartingCrew(db, save.id, seed) await db.db.insert(schema.events).values({ saveId: save.id, turn: 0, type: 'new_game', message: `New game — seed ${seed}, starting system ${region.start.name}`, createdAt: nowIso }) await persist(db) @@ -210,13 +219,32 @@ export async function getSystemState(db: GameDb): Promise { return readSystemState(db) } +// --- Crew roster -------------------------------------------------------------- + +export interface CrewDto { + id: number + name: string + role: string + traitGood: string + traitBad: string + origin: string // 'STARTING CREW' or ', ' for members found while exploring +} + +// The full roster in join order (starting crew first, then finds). Auto-initializes like the other reads. +export async function getCrewState(db: GameDb): Promise { + await ensureSave(db) + return (await db.db.select().from(schema.crew)).map((c) => ({ id: c.id, name: c.name, role: c.role, traitGood: c.traitGood, traitBad: c.traitBad, origin: c.origin })) +} + export interface ExploreResult extends SystemState { outcome: FoundItem[] // what this scan found (may be empty — some scans find nothing) + crewFound: CrewDto | null // a member who joined the ship on this scan (null when none) } // Scan one body of the save: deterministic haul from (seed, planet id), marks it explored, -// costs a turn, accumulates finds into inventory and writes an event. Any existing planet -// may be scanned (the UI only offers the current system's rows). +// costs a turn, accumulates finds into inventory and writes an event. The same per-planet +// rng stream also rolls for a crew member (~18% of scans) who joins the roster. Any existing +// planet may be scanned (the UI only offers the current system's rows). export async function explorePlanet(db: GameDb, planetId: number): Promise { await ensureSave(db) const [save] = await db.db.select().from(schema.saves).limit(1) @@ -225,7 +253,10 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise c.name)) + const foundMember = rollCrewMember(rng, takenNames) // …then the crew roll on the same stream const turn = save.turn + 1 await db.db.update(schema.planets).set({ explored: true }).where(eq(schema.planets.id, target.id)) @@ -239,10 +270,19 @@ export async function explorePlanet(db: GameDb, planetId: number): Promise s.id === target.systemId)?.name ?? 'unknown system' + const [row] = await db.db.insert(schema.crew).values({ saveId: save.id, name: foundMember.name, role: foundMember.role, traitGood: foundMember.traitGood, traitBad: foundMember.traitBad, origin: `${target.name}, ${systemName}` }).returning() + crewFound = { id: row.id, name: row.name, role: row.role, traitGood: row.traitGood, traitBad: row.traitBad, origin: row.origin } + } + const foundText = outcome.length > 0 ? outcome.map((i) => `${i.qty}× ${i.name}`).join(', ') : 'nothing' + const crewText = crewFound ? ` — ${crewFound.name} joined the crew` : '' await db.db.update(schema.saves).set({ turn }).where(eq(schema.saves.id, save.id)) - await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}`, createdAt: new Date().toISOString() }) + await db.db.insert(schema.events).values({ saveId: save.id, turn, type: 'explore', message: `Explored ${target.name} — found ${foundText}${crewText}`, createdAt: new Date().toISOString() }) await persist(db) - return { ...(await readSystemState(db)), outcome } + return { ...(await readSystemState(db)), outcome, crewFound } } -- 2.54.0 From a2c85866181ee552b8f1ae0bb562e842a016fcea Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:47:18 +0000 Subject: [PATCH 3/6] api: GET /api/crew roster endpoint, OpenAPI spec updated in code --- app/api/crew/route.ts | 8 ++++++++ lib/openapi.test.ts | 14 ++++++++++++++ lib/openapi.ts | 36 +++++++++++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 app/api/crew/route.ts diff --git a/app/api/crew/route.ts b/app/api/crew/route.ts new file mode 100644 index 0000000..6ca4457 --- /dev/null +++ b/app/api/crew/route.ts @@ -0,0 +1,8 @@ +import { getGameDb } from '@/lib/db/client' +import { getCrewState } from '@/lib/game/save' + +// The ship's crew roster: starting crew plus members found while exploring. Auto-initializes on first hit. +export async function GET() { + const db = await getGameDb() + return Response.json(await getCrewState(db)) +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 4da6060..1538aca 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -5,6 +5,7 @@ import { openApiSpec } from './openapi'; describe('OpenAPI spec', () => { it('documents every public endpoint', () => { expect(Object.keys(openApiSpec.paths).sort()).toEqual([ + '/api/crew', '/api/explore', '/api/jump', '/api/system', @@ -13,6 +14,19 @@ describe('OpenAPI spec', () => { ]); }); + it('describes /api/crew as an array of CrewMember and crewFound on ExploreResult', () => { + const crew = openApiSpec.paths['/api/crew'].get.responses[200].content['application/json'].schema; + expect(crew.type).toBe('array'); + expect((crew.items as { $ref: string }).$ref).toBe('#/components/schemas/CrewMember'); + + const member = openApiSpec.components.schemas.CrewMember.properties; + expect(Object.keys(member)).toEqual(['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin']); + + const exploreResult = openApiSpec.components.schemas.ExploreResult as { allOf: unknown[] }; + const extra = (exploreResult.allOf[1] as { properties: Record }).properties; + expect(Object.keys(extra)).toEqual(['outcome', 'crewFound']); + }); + it('describes the system endpoints as SystemState in and out', () => { const system = openApiSpec.paths['/api/system'].get.responses[200].content['application/json'].schema; expect(system.$ref).toBe('#/components/schemas/SystemState'); diff --git a/lib/openapi.ts b/lib/openapi.ts index cb22c07..3102655 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -168,6 +168,24 @@ export const openApiSpec = { }, }, }, + '/api/crew': { + get: { + summary: 'Crew roster', + description: + 'The ship crew in join order — the starting crew (seeded on new game) plus members found while exploring planets/moons. Each member has one good and one bad trait and an origin. Auto-initializes a new game when no save exists yet.', + operationId: 'getCrew', + responses: { + 200: { + description: 'Crew roster', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/CrewMember' } }, + }, + }, + }, + }, + }, + }, }, components: { schemas: { @@ -245,6 +263,18 @@ export const openApiSpec = { }, required: ['kind', 'name', 'qty'], }, + CrewMember: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + role: { type: 'string', description: 'Ship role, e.g. PILOT or ENGINEER' }, + trait_good: { type: 'string', description: 'The member good trait' }, + trait_bad: { type: 'string', description: 'The member bad trait' }, + origin: { type: 'string', description: "'STARTING CREW' or ', ' for members found while exploring" }, + }, + required: ['id', 'name', 'role', 'trait_good', 'trait_bad', 'origin'], + }, SystemState: { type: 'object', properties: { @@ -278,8 +308,12 @@ export const openApiSpec = { description: 'What this scan found — may be empty (some scans find nothing)', items: { $ref: '#/components/schemas/FoundItem' }, }, + crewFound: { + description: 'A crew member who joined the ship on this scan (~18% of scans), null when none', + anyOf: [{ $ref: '#/components/schemas/CrewMember' }, { type: 'null' }], + }, }, - required: ['outcome'], + required: ['outcome', 'crewFound'], }, ], }, -- 2.54.0 From 08a15a09cf2821059ec8fd5b716104ee299b25f4 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:50:01 +0000 Subject: [PATCH 4/6] ui: CREW tab renders real roster cards with traits + origin (real data from /api/crew) --- components/crew-panel.tsx | 101 +++++++++++++++++++++++++++++++++ components/mission-control.tsx | 55 ++---------------- 2 files changed, 106 insertions(+), 50 deletions(-) create mode 100644 components/crew-panel.tsx diff --git a/components/crew-panel.tsx b/components/crew-panel.tsx new file mode 100644 index 0000000..19580a0 --- /dev/null +++ b/components/crew-panel.tsx @@ -0,0 +1,101 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import type { CrewDto } from '@/lib/game/save' + +// CREW tab — the ship's roster: starting crew plus members found while exploring. Fetches +// /api/crew on mount; Radix unmounts inactive tabs, so opening this tab always refetches +// (no polling needed — turn-based game). Display-only for now: no dismiss/assign actions yet. + +interface CrewPanelProps { + narrow?: boolean +} + +export function CrewPanel({ narrow }: CrewPanelProps) { + const [roster, setRoster] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + fetch('/api/crew') + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as CrewDto[] + }) + .then((r) => { + if (!cancelled) setRoster(r) + }) + .catch((err: unknown) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, []) + + if (error && !roster) { + return ( +
+ + CREW MANIFEST OFFLINE // {error.toUpperCase()} + +
+ ) + } + + if (!roster) { + return ( +
+ LOADING CREW MANIFEST… +
+ ) + } + + return ( +
+ {roster.map((member) => ( + + +
+ {member.name} + + ACTIVE + +
+

+ {member.role} +

+
+ +
+ + + {member.traitGood} +
+
+ - + {member.traitBad} +
+

+ ORIGIN // {member.origin.toUpperCase()} +

+
+
+ ))} +
+ ) +} diff --git a/components/mission-control.tsx b/components/mission-control.tsx index 55d8f34..f67cb28 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -11,13 +11,14 @@ import { StatCard } from '@/components/ui/stat-card' import { StatusGrid, type SystemEntry } from '@/components/ui/status-grid' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Terminal, type TerminalLine } from '@/components/ui/terminal' +import { CrewPanel } from '@/components/crew-panel' import { LocalSystemPanel } from '@/components/local-system' import { WorldMapPanel } from '@/components/world-map' // Interface base: layout + visual treatment rebuilt from the scificn-ui Mission Control // showcase (src/app/_pages/showcase/mission-control-content.tsx in baxy5/scificn-ui). The -// WORLD MAP and LOCAL SYSTEM tabs render live data (/api/world, /api/system); the other -// tabs keep placeholder content until their own issues land. Single player, no auth, by design. +// WORLD MAP, LOCAL SYSTEM and CREW tabs render live data (/api/world, /api/system, /api/crew); +// the other tabs keep placeholder content until their own issues land. Single player, no auth, by design. function useNarrow(threshold = 900): boolean { const [narrow, setNarrow] = useState( @@ -45,13 +46,6 @@ const shipSystems: SystemEntry[] = [ { name: 'COMMS', status: 'ACTIVE' }, ] -const crew = [ - { name: 'CMDR. E. VANCE', role: 'COMMANDER', good: 'STEADY UNDER PRESSURE', bad: 'RIGID — SLOW TO ADAPT' }, - { name: 'LT. M. OKONKWO', role: 'PILOT', good: 'RECKLESS COURAGE', bad: 'SHORT FUSE' }, - { name: 'DR. I. SORENSEN', role: 'MEDIC', good: 'CALM IN A CRISIS', bad: 'OVERCAUTIOUS' }, - { name: 'R. "SPARK" DELACRUZ', role: 'ENGINEER', good: 'FIXES ANYTHING', bad: 'CARELESS WITH FUSES' }, -] - const shipSystemCards = [ { name: 'REACTOR', level: 'LV 2', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] }, { name: 'LIFE SUPPORT', level: 'LV 1', status: 'ACTIVE' as const, actions: ['EXPAND', 'UPGRADE'] }, @@ -331,48 +325,9 @@ export function MissionControl() { - {/* CREW TAB */} + {/* CREW TAB — live data from /api/crew */} -
- {crew.map((member) => ( - - -
- {member.name} - - ACTIVE - -
-

- {member.role} -

-
- -
- + - {member.good} -
-
- - - {member.bad} -
-
-
- ))} -
+
{/* SHIP SYSTEMS TAB */} -- 2.54.0 From 60947d3cdac67a7cf4b26167088873f88f0d0621 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:51:26 +0000 Subject: [PATCH 5/6] e2e: CREW tab renders real roster cards with both traits; roster survives container restart --- e2e/smoke.spec.ts | 22 +++++++++++++++++++++- e2e/state.spec.ts | 9 +++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 2a0e819..a103a25 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -7,7 +7,8 @@ interface PlanetDto { id: number; name: string; type: string; hazard: boolean; e interface InventoryItemDto { kind: string; name: string; qty: number } interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: InventoryItemDto[]; turn: number } interface FoundItem { kind: string; name: string; qty: number } -interface ExploreResult extends SystemState { outcome: FoundItem[] } +interface ExploreResult extends SystemState { outcome: FoundItem[]; crewFound: unknown } +interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } test('landing page loads with the SpaceWanderer title', async ({ page }) => { await page.goto('/'); @@ -119,6 +120,25 @@ test('exploration yields finds that accumulate in the inventory', async ({ page } }); +test('CREW tab renders real roster cards, each showing both traits and an origin', async ({ page }) => { + const roster = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; + expect(roster.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded + + await page.goto('/'); + await page.getByRole('tab', { name: 'CREW' }).click(); + + for (const member of roster) { + const heading = page.getByRole('heading', { name: member.name }); + await expect(heading).toBeVisible(); + // The card is the third div ancestor of the title (flex row → CardHeader → Card). + const card = heading.locator('xpath=ancestor::div[3]'); + await expect(card.getByText(member.role)).toBeVisible(); + await expect(card.getByText(member.traitGood)).toBeVisible(); + await expect(card.getByText(member.traitBad)).toBeVisible(); + await expect(card.getByText(`ORIGIN // ${member.origin.toUpperCase()}`)).toBeVisible(); + } +}); + test('GET /api/version reports the app version as JSON', async ({ page }) => { const res = await page.request.get('/api/version'); expect(res.status()).toBe(200); diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index 4958300..f0d2a75 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -13,6 +13,7 @@ interface WorldSystemDto { id: number; name: string; x: number; y: number; disco interface WorldState { systems: WorldSystemDto[]; edges: [number, number][]; turn: number } interface PlanetDto { id: number; name: string; type: string; hazard: boolean; explored: boolean; parent: number | null } interface SystemState { systemId: number; systemName: string; planets: PlanetDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } +interface CrewDto { id: number; name: string; role: string; traitGood: string; traitBad: string; origin: string } test('app survives a container restart (stack + volume persistence)', async ({ page }) => { test.setTimeout(180_000); @@ -47,6 +48,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p expect(exploreRes.status()).toBe(200); const sysExplored = (await exploreRes.json()) as SystemState; + // Crew baseline: the roster (starting crew + any finds so far) must survive too. + const crewBefore = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; + expect(crewBefore.length).toBeGreaterThanOrEqual(2); // starting crew is always seeded + // Restart the app container mid-run — the named DB volume must survive it. execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' }); @@ -77,6 +82,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p expect(sysAfter.turn).toBe(sysExplored.turn); expect(sysAfter.planets.find((p) => p.id === targetPlanet.id)?.explored).toBe(true); + // Crew roster survived too: same members, traits and origins (no re-seed on restart). + const crewAfter = (await (await page.request.get('/api/crew')).json()) as CrewDto[]; + expect(crewAfter).toEqual(crewBefore); + // The page itself serves again with the landing title. await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); -- 2.54.0 From 59ccf5674efa82f281b9894a5974f9dc54d07b5b Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:56:25 +0000 Subject: [PATCH 6/6] release v0.5.0 --- CHANGELOG.md | 9 +++++++++ package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d128f9..e3939df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.5.0 — 2026-09-28 22:51 +- CREW tab — crew roster with good/bad traits (issue #7): the ship's people on top of issue #6's exploration loop. Single-player, no auth, by design. Display + arrival only — no dismiss/assign actions yet. +- New table (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `crew` (id/save_id/name/role/trait_good/trait_bad/origin). Origin is 'STARTING CREW' for the seeded crew or ', ' for members found while exploring. +- Pure seeded crew generation (`lib/game/crew.ts`, same pattern as world.ts): name pools (16 first × 22 last, unique per roster), role pool of 8, good/bad trait pools of 10 each. `generateStartingCrew` gives a new game 2–3 members with unique names and roles; `rollCrewMember` finds a joining member on ~18% of scans (`CREW_CHANCE = 0.18`). +- Starting crew is seeded in `ensureSave` from the save seed via `startingCrewRng(seed)` — same per-save derivation pattern as bodies/exploration, so a given seed always starts with the same crew. +- Exploration extended: the crew roll draws from the SAME per-planet rng stream as the resource/part haul (called after it in `explorePlanet`), so outcomes stay deterministic per save+planet and existing hauls are unchanged. Found members join the roster with origin = ', '; each body can only be scanned once, so no duplicate joins. +- New endpoint: `GET /api/crew` → `[{id,name,role,trait_good,trait_bad,origin}]` in join order (auto-initializes like the others). `POST /api/explore` responses now also carry `crewFound` (the joining member or null). OpenAPI spec in code (`lib/openapi.ts`) updated for both. +- CREW tab now renders REAL data: fetches `/api/crew` on mount (Radix unmounts inactive tabs, so opening the tab always refetches — no polling), cards show name + role, the good (+) and bad (−) traits and an ORIGIN line. Placeholder crew constants removed for this tab only — other tabs keep theirs until their issues land. + ## v0.4.0 — 2026-09-28 22:17 - LOCAL SYSTEM tab — planet list + exploration to find things (issue #6): the first in-system game loop on top of issue #5's save/world foundation. Single-player, no auth, by design. - New tables (`lib/db/schema.ts` + DDL in `lib/db/client.ts`): `planets` (id/system_id/parent_id/name/type/hazard/explored — planets have parent_id NULL, moons point at their planet) and `inventory_items` (id/save_id/kind[name]/qty — the dumb-simple cargo hold SHIP SYSTEMS (#8) will spend from later). diff --git a/package.json b/package.json index 0e9fbc5..2763c13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.4.0", + "version": "0.5.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0