From 0d5858695aad29f92cc5c3ec6751eed0b284e3fd Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 02:45:38 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(events):=20event=20log=20read=20?= =?UTF-8?q?=E2=80=94=20getEvents=20+=20limit=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/game/events.ts: the latest N events of the save, newest first (ORDER BY id DESC), with parseEventLimit for the optional ?limit= param (default 50, cap 200, 400 otherwise). Unit tests cover default/accept/reject and newest-first ordering + slicing. --- lib/game/events.test.ts | 68 +++++++++++++++++++++++++++++++++++++++++ lib/game/events.ts | 40 ++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 lib/game/events.test.ts create mode 100644 lib/game/events.ts diff --git a/lib/game/events.test.ts b/lib/game/events.test.ts new file mode 100644 index 0000000..b2fbb68 --- /dev/null +++ b/lib/game/events.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +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 { getWorldState } from './save' +import { DEFAULT_LIMIT, EventError, MAX_LIMIT, getEvents, parseEventLimit } from './events' + +const dirs: string[] = [] + +async function freshFile(): Promise<{ db: GameDb; file: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'sw-events-test-')) + dirs.push(dir) + const file = path.join(dir, 'test.db') + return { db: await openGameDb(file), file } +} + +afterAll(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }) +}) + +// Appends `count` synthetic events (turns 1..count) on top of the turn-0 new_game event. +async function seedEvents(db: GameDb, count: number): Promise { + const [save] = await db.db.select().from(schema.saves).limit(1) + for (let i = 1; i <= count; i++) { + await db.db.insert(schema.events).values({ saveId: save.id, turn: i, type: 'test', message: `event ${i}`, createdAt: new Date(Date.UTC(2026, 8, 29, 0, i)).toISOString() }) + } +} + +describe('parseEventLimit', () => { + it('defaults to DEFAULT_LIMIT when the param is absent', () => { + expect(parseEventLimit(null)).toBe(DEFAULT_LIMIT) + }) + + it('accepts integers in [1, MAX_LIMIT]', () => { + expect(parseEventLimit('1')).toBe(1) + expect(parseEventLimit(String(MAX_LIMIT))).toBe(MAX_LIMIT) + expect(parseEventLimit('25')).toBe(25) + }) + + it('rejects non-integers and out-of-range values with a 400', () => { + for (const bad of ['abc', '', '0', '-3', String(MAX_LIMIT + 1), '2.5']) { + try { + parseEventLimit(bad) + expect.unreachable(`limit ${JSON.stringify(bad)} should have been rejected`) + } catch (err) { + expect(err).toBeInstanceOf(EventError) + expect((err as EventError).status).toBe(400) + } + } + }) +}) + +describe('getEvents', () => { + it('returns the latest events newest-first, sliced to limit', async () => { + const { db } = await freshFile() + await getWorldState(db) // auto-initializes the save (turn-0 new_game event) + await seedEvents(db, 5) + + const all = await getEvents(db, DEFAULT_LIMIT) + expect(all.map((e) => e.turn)).toEqual([5, 4, 3, 2, 1, 0]) // newest first, turn-0 line included + expect(all[all.length - 1].type).toBe('new_game') + + const two = await getEvents(db, 2) + expect(two.map((e) => [e.turn, e.message])).toEqual([[5, 'event 5'], [4, 'event 4']]) + }) +}) diff --git a/lib/game/events.ts b/lib/game/events.ts new file mode 100644 index 0000000..3ecdb11 --- /dev/null +++ b/lib/game/events.ts @@ -0,0 +1,40 @@ +import { desc } from 'drizzle-orm' +import * as schema from '@/lib/db/schema' +import type { GameDb } from '@/lib/db/client' + +export interface EventDto { + id: number + turn: number + type: string + message: string + createdAt: string +} + +export const DEFAULT_LIMIT = 50 +export const MAX_LIMIT = 200 + +export class EventError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + } +} + +// Parses the optional ?limit= query param. Absent → default; anything that is not an integer in +// [1, MAX_LIMIT] is a 400 (the route maps EventError to its status). +export function parseEventLimit(raw: string | null): number { + if (raw === null) return DEFAULT_LIMIT + const limit = Number(raw) + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) { + throw new EventError(400, `limit must be an integer between 1 and ${MAX_LIMIT}`) + } + return limit +} + +// The latest N events of the save, newest first. Assumes a save exists (the route calls ensureSave). +export async function getEvents(db: GameDb, limit: number): Promise { + const rows = await db.db.select().from(schema.events).orderBy(desc(schema.events.id)).limit(limit) + return rows.map((e) => ({ id: e.id, turn: e.turn, type: e.type, message: e.message, createdAt: e.createdAt })) +} From 8d678d73ce3de0ac1624a3ef5d48a44d0b394ab2 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 02:45:38 +0000 Subject: [PATCH 2/7] test: assert events for the remaining action types expand had no event assertion at all; build and mission-complete asserted type/message but not the event's turn. Issue #11 requires every action type to be covered with correct turn/type/message. --- lib/game/missions.test.ts | 1 + lib/game/ship.test.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/lib/game/missions.test.ts b/lib/game/missions.test.ts index 4ac7d6b..91a90cc 100644 --- a/lib/game/missions.test.ts +++ b/lib/game/missions.test.ts @@ -185,6 +185,7 @@ describe('completion', () => { expect(reward.qty).toBe(2) const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'mission' && /complete/i.test(e.message))! + expect(event.turn).toBe(4) // accept (1) + three scans (2–4); completion is written by the third scan expect(event.message).toContain('Scan 3 bodies') }) diff --git a/lib/game/ship.test.ts b/lib/game/ship.test.ts index 40bad31..1c8f374 100644 --- a/lib/game/ship.test.ts +++ b/lib/game/ship.test.ts @@ -111,6 +111,10 @@ describe('expandSystem', () => { expect(systemOf(state, 'HULL').capacity).toBe(BASE_CAPACITY + 1) expect((await db.db.select().from(schema.inventoryItems)).map((i) => [i.name, i.qty])).toEqual([['IRON ORE', 1]]) + const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'expand')! + expect(event.turn).toBe(1) + expect(event.message).toContain('HULL') + await db.db.update(schema.shipSystems).set({ capacity: MAX_CAPACITY }).where(eq(schema.shipSystems.id, hullId)) await expect(expandSystem(db, hullId)).rejects.toMatchObject({ status: 409 }) }) @@ -144,6 +148,7 @@ describe('buildSystem', () => { expect(state.turn).toBe(1) const event = (await db.db.select().from(schema.events)).find((e) => e.type === 'build')! + expect(event.turn).toBe(1) expect(event.message).toContain('WEAPONS') await expect(buildSystem(db, 'WEAPONS')).rejects.toMatchObject({ status: 409 }) // already built From 21abf8768fa435a6ab19fbd1883cc85bb4a8170f Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 02:45:38 +0000 Subject: [PATCH 3/7] feat(api): GET /api/events endpoint + OpenAPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latest game events, newest first; auto-initializes like the other reads. ?limit= defaults to 50 (cap 200), non-integer/out-of-range → 400. OpenAPI spec gains the path + Event schema (14 -> 15 paths). --- app/api/events/route.ts | 21 +++++++++++++++++++ lib/openapi.test.ts | 17 ++++++++++++++++ lib/openapi.ts | 45 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 app/api/events/route.ts diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 0000000..3427066 --- /dev/null +++ b/app/api/events/route.ts @@ -0,0 +1,21 @@ +import { getGameDb } from '@/lib/db/client' +import { ensureSave } from '@/lib/game/save' +import { EventError, getEvents, parseEventLimit } from '@/lib/game/events' + +// The latest game events, newest first; auto-initializes on first hit. +export async function GET(req: Request) { + const db = await getGameDb() + await ensureSave(db) // getEvents assumes a save exists + + let limit: number + try { + limit = parseEventLimit(new URL(req.url).searchParams.get('limit')) + } catch (err) { + if (err instanceof EventError) { + return Response.json({ error: err.message }, { status: err.status }) + } + throw err + } + + return Response.json(await getEvents(db, limit)) +} diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 1f9cb42..05c283e 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -6,6 +6,7 @@ describe('OpenAPI spec', () => { it('documents every public endpoint', () => { expect(Object.keys(openApiSpec.paths).sort()).toEqual([ '/api/crew', + '/api/events', '/api/explore', '/api/jump', '/api/missions', @@ -77,6 +78,22 @@ describe('OpenAPI spec', () => { } }); + it('describes GET /api/events → Event[] with a limit query param and a 400 error response', () => { + const events = openApiSpec.paths['/api/events'].get.responses[200].content['application/json'].schema; + expect(events.type).toBe('array'); + expect((events.items as { $ref: string }).$ref).toBe('#/components/schemas/Event'); + + const props = openApiSpec.components.schemas.Event.properties; + expect(Object.keys(props)).toEqual(['id', 'turn', 'type', 'message', 'createdAt']); + expect((props.type as { enum: string[] }).enum).toEqual(['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission']); + + const get = openApiSpec.paths['/api/events'].get; + const limit = (get.parameters ?? []).find((p) => p.name === 'limit'); + expect(limit?.in).toBe('query'); + expect(limit?.required).toBe(false); + expect(get.responses['400'].content['application/json'].schema.$ref).toBe('#/components/schemas/Error'); + }); + 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'); diff --git a/lib/openapi.ts b/lib/openapi.ts index 6e01b2e..686aabb 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -523,6 +523,40 @@ export const openApiSpec = { }, }, }, + '/api/events': { + get: { + summary: 'Event log', + description: "The save's game events, newest first — every action (new game, jump, scan, research, ship work, mission) appends one. Auto-initializes a new game when no save exists yet.", + operationId: 'getEvents', + parameters: [ + { + name: 'limit', + in: 'query', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 200 }, + description: 'How many of the latest events to return (default 50)', + }, + ], + responses: { + 200: { + description: 'Latest events, newest first', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Event' } }, + }, + }, + }, + 400: { + description: 'limit is not an integer in [1, 200]', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Error' }, + }, + }, + }, + }, + }, + }, }, components: { schemas: { @@ -682,6 +716,17 @@ export const openApiSpec = { }, required: ['id', 'type', 'description', 'targetSystemId', 'conditionKind', 'progressTarget', 'progress', 'rewardKind', 'rewardQty', 'state'], }, + Event: { + type: 'object', + properties: { + id: { type: 'integer' }, + turn: { type: 'integer', description: 'The save turn the action cost (new_game is 0)' }, + type: { type: 'string', enum: ['new_game', 'jump', 'explore', 'research', 'upgrade', 'expand', 'build', 'mission'] }, + message: { type: 'string' }, + createdAt: { type: 'string', description: 'ISO 8601 timestamp' }, + }, + required: ['id', 'turn', 'type', 'message', 'createdAt'], + }, UpgradeRequest: { type: 'object', properties: { From 29e8ac69099e9a3d3ddd6543d29a3861f66846aa Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 02:45:38 +0000 Subject: [PATCH 4/7] feat(ui): EVENT LOG tab renders the real game log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-fetching EventLogPanel (MissionPanel pattern — refetches on each tab open). One terminal line per event: T // , newest first, with the event's local time as the timestamp; new_game lines render as system lines. Placeholder feed + now() removed from mission-control. --- components/event-log.tsx | 59 ++++++++++++++++++++++++++++++++++ components/mission-control.tsx | 27 +++------------- 2 files changed, 63 insertions(+), 23 deletions(-) create mode 100644 components/event-log.tsx diff --git a/components/event-log.tsx b/components/event-log.tsx new file mode 100644 index 0000000..17d35a7 --- /dev/null +++ b/components/event-log.tsx @@ -0,0 +1,59 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Terminal, type TerminalLine } from '@/components/ui/terminal' +import type { EventDto } from '@/lib/game/events' + +// EVENT LOG tab — live data. Fetches /api/events on mount (Radix Tabs unmounts inactive tabs, so it +// refetches whenever the tab is opened). One terminal line per event: T // , newest first. + +export function EventLogPanel() { + const [lines, setLines] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + fetch('/api/events') + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as EventDto[] + }) + .then((events) => { + if (!cancelled) { + setLines( + events.map((e) => ({ + type: e.type === 'new_game' ? 'system' : 'output', + text: `T${e.turn} // ${e.message.toUpperCase()}`, + timestamp: new Date(e.createdAt).toLocaleTimeString('en-GB', { hour12: false }), + })) + ) + } + }) + .catch((err: unknown) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } + }, []) + + if (error) { + return ( +
+ {error.toUpperCase()} +
+ ) + } + + if (!lines) { + return ( +
+ LOADING EVENT LOG… +
+ ) + } + + return ( + + ) +} diff --git a/components/mission-control.tsx b/components/mission-control.tsx index db87c58..957a8fc 100644 --- a/components/mission-control.tsx +++ b/components/mission-control.tsx @@ -7,8 +7,8 @@ import { Progress } from '@/components/ui/progress' 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 { EventLogPanel } from '@/components/event-log' import { LocalSystemPanel } from '@/components/local-system' import { MissionPanel } from '@/components/missions' import { ShipSystemsPanel } from '@/components/ship-systems' @@ -21,9 +21,8 @@ import type { TechDto } from '@/lib/game/tech' // 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, LOCAL SYSTEM, CREW, SHIP SYSTEMS, SKILL TREE and MISSIONS tabs render live data -// (/api/world, /api/system, /api/crew, /api/ship, /api/tech, /api/missions); the EVENT LOG tab -// keeps placeholder content until its own issue lands. Single player, no auth, by design. +// All seven tabs render live data (/api/world, /api/system, /api/crew, /api/ship, /api/tech, +// /api/missions, /api/events). Single player, no auth, by design. function useNarrow(threshold = 900): boolean { const [narrow, setNarrow] = useState( @@ -37,10 +36,6 @@ function useNarrow(threshold = 900): boolean { return narrow } -function now(): string { - return new Date().toLocaleTimeString('en-GB', { hour12: false }) -} - // ── Placeholder content (no game logic — real values land with the turn engine) ── const encounters = [ @@ -59,15 +54,6 @@ const shipData = [ { label: 'TURN', value: '12' }, ] -const logLines: TerminalLine[] = [ - { type: 'system', text: 'SPACEWANDERER // INITIALIZING SHIP SYSTEMS...', timestamp: now() }, - { type: 'output', text: 'HULL SCAN COMPLETE — INTEGRITY 87%', timestamp: now() }, - { type: 'output', text: 'REACTOR OUTPUT STABLE AT 94.2%', timestamp: now() }, - { type: 'output', text: 'CREW MANIFEST LOADED — 3 ACTIVE PERSONNEL', timestamp: now() }, - { type: 'warn', text: 'WEAPONS OFFLINE — MODULE NOT INSTALLED', timestamp: now() }, - { type: 'system', text: 'ALL SYSTEMS NOMINAL. AWAITING FIRST TURN.', timestamp: now() }, -] - export function MissionControl() { const narrow = useNarrow() const [tick, setTick] = useState(0) @@ -393,12 +379,7 @@ export function MissionControl() { {/* EVENT LOG TAB */} - + From 0b435a583b4e6a776266207b36956b67438e633d Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 02:47:36 +0000 Subject: [PATCH 5/7] test(e2e): event log entries + restart persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smoke: jump (shared helper) + scan, then assert both events are in GET /api/events with the newer one first, and that the EVENT LOG terminal renders both T // lines. Idempotent across retries — asserts on this run's own entries by turn/message, never absolute counts. state: the latest five log lines come back identical (ids included) after the mid-run container restart. --- e2e/smoke.spec.ts | 35 +++++++++++++++++++++++++++++++++++ e2e/state.spec.ts | 10 ++++++++++ 2 files changed, 45 insertions(+) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 7e0c260..9c783da 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -13,6 +13,7 @@ interface CrewDto { id: number; name: string; role: string; traitGood: string; t interface ShipSystemDto { id: number; type: string; level: number; capacity: number; built: boolean } interface ShipState { systems: ShipSystemDto[]; inventory: InventoryItemDto[]; turn: number } interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string } +interface EventDto { id: number; turn: number; type: string; message: string; createdAt: string } const totalQty = (items: InventoryItemDto[], kind: string) => items.filter((i) => i.kind === kind).reduce((sum, i) => sum + i.qty, 0); @@ -362,6 +363,40 @@ test('MISSIONS tab accepts a contract and completing it grants the reward', asyn expect(totalQty(shipAfter.inventory, picked.rewardKind)).toBeGreaterThanOrEqual(baseline + picked.rewardQty); }); +test('EVENT LOG tab shows real game events, newest first', async ({ page }) => { + // Idempotent across retries sharing a DB: assert on THIS run's own entries (identified by turn and + // message), never on absolute counts — earlier attempts leave their events behind. + const afterJump = await jumpToFreshSystem(page); + const jumpedName = afterJump.systems.find((s) => s.current)!.name; + const jumpTurn = afterJump.turn; + + // Scan one body — if the current system is exhausted (leftover from a previous attempt), hop again first. + let sys = (await (await page.request.get('/api/system')).json()) as SystemState; + if (!sys.planets.some((p) => !p.explored)) { + await jumpToFreshSystem(page); // fresh systems always have unscanned bodies + sys = (await (await page.request.get('/api/system')).json()) as SystemState; + } + const planet = sys.planets.find((p) => !p.explored)!; + const exploreRes = await page.request.post('/api/explore', { data: { planetId: planet.id } }); + expect(exploreRes.status()).toBe(200); + const explored = (await exploreRes.json()) as ExploreResult; + + // API: this run's jump + scan are in the log, and the newer one (the scan) comes first. + const events = (await (await page.request.get('/api/events')).json()) as EventDto[]; + const exploreEvent = events.find((e) => e.turn === explored.turn && e.type === 'explore'); + expect(exploreEvent?.message).toMatch(new RegExp(`^Explored ${planet.name} — found`)); + const jumpEvent = events.find((e) => e.type === 'jump' && e.message === `Jumped to ${jumpedName}`); + expect(jumpEvent).toBeDefined(); // system names are unique, so the message pins this run's jump + expect(events.indexOf(exploreEvent!)).toBeLessThan(events.indexOf(jumpEvent!)); + + // UI: the terminal renders both lines (T // , uppercased). + await page.goto('/'); + await page.getByRole('tab', { name: 'EVENT LOG' }).click(); + const panel = page.getByRole('tabpanel'); + await expect(panel.getByText(new RegExp(`T${explored.turn} // EXPLORED ${planet.name.toUpperCase()}`))).toBeVisible(); + await expect(panel.getByText(new RegExp(`T${jumpTurn} // JUMPED TO ${jumpedName.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 6ba6d17..bec588b 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -17,6 +17,7 @@ interface ShipSystemDto { id: number; type: string; level: number; capacity: num interface ShipState { systems: ShipSystemDto[]; inventory: { kind: string; name: string; qty: number }[]; turn: number } interface TechDto { id: number; slug: string; name: string; costKind: string; costQty: number; prereqs: string[]; effect: string; state: string; missingPrereqs: string[] } interface MissionDto { id: number; type: string; description: string; targetSystemId: number | null; conditionKind: string | null; progressTarget: number; progress: number; rewardKind: string; rewardQty: number; state: string } +interface EventDto { id: number; turn: number; type: string; message: string; createdAt: string } test('app survives a container restart (stack + volume persistence)', async ({ page }) => { test.setTimeout(180_000); @@ -59,6 +60,11 @@ test('app survives a container restart (stack + volume persistence)', async ({ p const missionsBefore = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; expect(missionsBefore.length).toBeGreaterThan(0); // the board is always seeded on new game + // Event log baseline: append-only, and nothing writes between this snapshot and the restart — so the + // latest lines must come back byte-identical (ids included) after it. + const eventsBefore = (await (await page.request.get('/api/events?limit=5')).json()) as EventDto[]; + expect(eventsBefore.length).toBeGreaterThan(0); + // Restart the app container mid-run — the named DB volume must survive it. execSync(`docker compose ${composeArgs} restart space-wanderer`, { cwd: repoRoot, stdio: 'pipe' }); @@ -105,6 +111,10 @@ test('app survives a container restart (stack + volume persistence)', async ({ p const missionsAfter = (await (await page.request.get('/api/missions')).json()) as MissionDto[]; expect(missionsAfter).toEqual(missionsBefore); + // Event log survived too: same latest lines, in the same order. + const eventsAfter = (await (await page.request.get('/api/events?limit=5')).json()) as EventDto[]; + expect(eventsAfter).toEqual(eventsBefore); + // The page itself serves again with the landing title. await page.goto('/'); await expect(page.getByRole('heading', { name: 'SpaceWanderer' })).toBeVisible(); From 760a45fc4b231c0f7eaab47d43f8a56e3926a57c Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 02:48:25 +0000 Subject: [PATCH 6/7] release v0.9.0 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed0f0a..f10c043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.9.0 — 2026-09-29 02:47 +- EVENT LOG tab — the last of the seven tabs to go live (issue #11): the terminal now renders the save's real game events instead of a placeholder feed. Every action already appends an event row (new game, jump, scan, research, ship upgrade/expand/build, mission accept + completion); this issue adds the read side and the rendering. +- New endpoint: `GET /api/events` → latest N events, newest first (`{id, turn, type, message, createdAt}`), auto-initializes like all other reads. Optional `?limit=` (default 50, cap 200; non-integers or out-of-range values → 400). OpenAPI spec in code updated (14 → 15 paths) with the Event schema enumerating the eight event types. +- Read model (`lib/game/events.ts`): `getEvents(db, limit)` — ORDER BY id DESC LIMIT N over the existing append-only events table; `parseEventLimit` for the query param. No schema change — the events table and all its writers already existed. +- EVENT LOG tab: self-fetching EventLogPanel (MissionPanel pattern — refetches on each tab open). One terminal line per event: `T // ` uppercased, with the event's local time as the timestamp; new_game lines render as system lines. Placeholder feed + now() removed from mission-control.tsx. +- Tests: unit — parseEventLimit default/accept/reject (400) and getEvents newest-first ordering + slicing; writer coverage gaps closed so every action type is asserted with correct turn/type/message (expand had no event assertion at all; build and mission-complete lacked the turn check); e2e — after a jump + scan, both events appear in GET /api/events with the newer one first and the terminal renders both lines (idempotent across retries: asserts on this run's own entries by turn/message, never absolute counts), plus the latest five log lines come back identical (ids included) after the mid-run container restart. + ## v0.8.0 — 2026-09-29 01:56 - MISSIONS tab — contract generation, acceptance and completion (issue #10): the first objective loop on top of issue #9's research. The board is generated deterministically from the save seed; one mission is ACTIVE at a time; completion is checked after every jump and scan and grants its reward into the cargo hold. - New table (`lib/db/schema.ts` + DDL): `missions` (id/save_id/type/description/target_system_id/condition_kind/progress_target/progress/reward_kind/reward_qty/state). Seeded on new game AND as backfill step v6 — same pattern as tech; saves created by older code get their board on first read. `SCHEMA_VERSION` 5 → 6. diff --git a/package.json b/package.json index 3080782..eb1a4a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "space-wanderer", - "version": "0.8.0", + "version": "0.9.0", "private": true, "scripts": { "dev": "next dev -p 3000", From 37b193ffb8255dbca3c046106bc3f345d79829cf Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Tue, 29 Sep 2026 03:02:04 +0000 Subject: [PATCH 7/7] =?UTF-8?q?test(e2e):=20tab-switch=20test=20=E2=80=94?= =?UTF-8?q?=20assert=20real=20log=20lines,=20not=20the=20removed=20placeho?= =?UTF-8?q?lder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI run 361: v0.9.0 replaced the placeholder feed with EventLogPanel, but this pre-existing assertion still expected 'SHIP LOG // PLACEHOLDER FEED'. Assert a real T // line instead — every save has the turn-0 new_game event, so it holds on a fresh stack and across retries. --- e2e/smoke.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 9c783da..fc7d4e0 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -65,7 +65,9 @@ test('game template tabs render (scificn-ui component layer wired in)', async ({ test('tabs switch panels client-side (event log terminal)', async ({ page }) => { await page.goto('/'); await page.getByRole('tab', { name: 'EVENT LOG' }).click(); - await expect(page.getByText('SHIP LOG // PLACEHOLDER FEED')).toBeVisible(); + // The placeholder feed was replaced by the real log (v0.9.0): at least one T // line + // must render — every save has the turn-0 new_game event, so this holds on a fresh stack and across retries. + await expect(page.getByText(/T\d+ \/\/ /).first()).toBeVisible(); }); test('world map shows the generated starting system and its neighbours', async ({ page }) => {