Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ccf5674e | ||
|
|
60947d3cda | ||
|
|
08a15a09cf | ||
|
|
a2c8586618 | ||
|
|
7a9ff110f3 | ||
|
|
ccd156b7c6 | ||
|
|
a6a5e827a1 |
@@ -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 '<planet>, <system>' 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 = '<planet>, <system>'; 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).
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<CrewDto[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--color-red)', letterSpacing: '0.08em' }}>
|
||||
CREW MANIFEST OFFLINE // {error.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!roster) {
|
||||
return (
|
||||
<div style={{ border: '1px solid var(--border)', background: 'var(--surface)', padding: '0.75rem' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>LOADING CREW MANIFEST…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{roster.map((member) => (
|
||||
<Card key={member.id}>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{member.name}</CardTitle>
|
||||
<Badge variant="ACTIVE" className="ml-auto">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{member.role}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-green)' }}>+ </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.traitGood}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-red)' }}>- </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.traitBad}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', letterSpacing: '0.08em' }}>
|
||||
ORIGIN // {member.origin.toUpperCase()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
<LocalSystemPanel />
|
||||
</TabsContent>
|
||||
|
||||
{/* CREW TAB */}
|
||||
{/* CREW TAB — live data from /api/crew */}
|
||||
<TabsContent value="crew" style={{ margin: 0, padding: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: narrow ? '1fr' : 'repeat(2, 1fr)',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{crew.map((member) => (
|
||||
<Card key={member.name}>
|
||||
<CardHeader>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CardTitle>{member.name}</CardTitle>
|
||||
<Badge variant="ACTIVE" className="ml-auto">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.6rem',
|
||||
color: 'var(--text-muted)',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{member.role}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-green)' }}>+ </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.good}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', letterSpacing: '0.05em' }}>
|
||||
<span style={{ color: 'var(--color-red)' }}>- </span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{member.bad}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<CrewPanel narrow={narrow} />
|
||||
</TabsContent>
|
||||
|
||||
{/* SHIP SYSTEMS TAB */}
|
||||
|
||||
+21
-1
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
+73
-1
@@ -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<string>())) 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<string>())! // 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)
|
||||
})
|
||||
})
|
||||
|
||||
+45
-5
@@ -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<void> {
|
||||
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<void> {
|
||||
@@ -86,6 +94,7 @@ export async function ensureSave(db: GameDb): Promise<void> {
|
||||
|
||||
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<SystemState> {
|
||||
return readSystemState(db)
|
||||
}
|
||||
|
||||
// --- Crew roster --------------------------------------------------------------
|
||||
|
||||
export interface CrewDto {
|
||||
id: number
|
||||
name: string
|
||||
role: string
|
||||
traitGood: string
|
||||
traitBad: string
|
||||
origin: string // 'STARTING CREW' or '<planet>, <system>' 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<CrewDto[]> {
|
||||
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<ExploreResult> {
|
||||
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<Explo
|
||||
if (!target) throw new ExploreError(404, `Unknown planet ${planetId}`)
|
||||
if (target.explored) throw new ExploreError(409, `${target.name} was already explored`)
|
||||
|
||||
const outcome = rollExploration(explorationRng(save.seed, target.id))
|
||||
const rng = explorationRng(save.seed, target.id)
|
||||
const outcome = rollExploration(rng) // resource/part haul first…
|
||||
const takenNames = new Set((await db.db.select().from(schema.crew)).map((c) => 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<Explo
|
||||
}
|
||||
}
|
||||
|
||||
let crewFound: CrewDto | null = null
|
||||
if (foundMember) {
|
||||
const systems = await db.db.select().from(schema.solarSystems)
|
||||
const systemName = systems.find((s) => 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 }
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> }).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');
|
||||
|
||||
+35
-1
@@ -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 '<planet>, <system>' 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'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space-wanderer",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user