diff --git a/.gitignore b/.gitignore index b7b6e6a..785d4a2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ legacy .netlify deno.lock version.json +data diff --git a/adapters/claude-code/README.md b/adapters/claude-code/README.md new file mode 100644 index 0000000..93d23eb --- /dev/null +++ b/adapters/claude-code/README.md @@ -0,0 +1,60 @@ +# Claude Code agent-event hook + +This adapter forwards Claude Code `SessionStart`, `Notification`, and `Stop` hooks into the Lunar Pup agent harness endpoint. + +## Environment + +Set the same shared secret on the game server and the hook process: + +```sh +export AGENT_EVENT_TOKEN="replace-with-a-long-random-token" +export AGENT_EVENT_ENDPOINT="http://localhost:3001/agent/event" +``` + +`AGENT_EVENT_ENDPOINT` defaults to `http://localhost:3001/agent/event` when omitted. + +## Claude Code hooks + +Add this to your Claude Code `settings.json` hooks block. Use an absolute path if Claude Code runs outside this repository. + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bun adapters/claude-code/agent-event-hook.ts" + } + ] + } + ], + "Notification": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bun adapters/claude-code/agent-event-hook.ts" + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "bun adapters/claude-code/agent-event-hook.ts" + } + ] + } + ] + } +} +``` + +The hook reads Claude Code's JSON hook payload from stdin, converts it to the shared `AgentEvent` contract, and POSTs it with `Authorization: Bearer $AGENT_EVENT_TOKEN`. diff --git a/adapters/claude-code/agent-event-hook.ts b/adapters/claude-code/agent-event-hook.ts new file mode 100755 index 0000000..478e88e --- /dev/null +++ b/adapters/claude-code/agent-event-hook.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env bun +import { validateAgentEvent, type AgentEvent, type AgentEventType } from '../../src/contracts/agentEvents.ts'; + +interface ClaudeHookPayload { + hook_event_name?: string; + session_id?: string; + cwd?: string; + transcript_path?: string; + message?: string; + stop_hook_active?: boolean; +} + +const eventTypeByHook = new Map([ + ['SessionStart', 'agent_session_start'], + ['Notification', 'agent_needs_input'], + ['Stop', 'agent_done'], +]); + +async function readHookPayload(): Promise { + const input = await new Response(Bun.stdin.stream()).text(); + if (!input.trim()) return {}; + const value = JSON.parse(input) as unknown; + if (!value || typeof value !== 'object') return {}; + + const payload: ClaudeHookPayload = {}; + if ('hook_event_name' in value && typeof value.hook_event_name === 'string') payload.hook_event_name = value.hook_event_name; + if ('session_id' in value && typeof value.session_id === 'string') payload.session_id = value.session_id; + if ('cwd' in value && typeof value.cwd === 'string') payload.cwd = value.cwd; + if ('transcript_path' in value && typeof value.transcript_path === 'string') payload.transcript_path = value.transcript_path; + if ('message' in value && typeof value.message === 'string') payload.message = value.message; + if ('stop_hook_active' in value && typeof value.stop_hook_active === 'boolean') payload.stop_hook_active = value.stop_hook_active; + return payload; +} + +function eventTypeFor(payload: ClaudeHookPayload): AgentEventType { + const hookEvent = payload.hook_event_name ?? ''; + return eventTypeByHook.get(hookEvent) ?? 'agent_status'; +} + +function projectName(cwd: string | undefined): string { + if (!cwd) return 'unknown-project'; + const parts = cwd.split(/[\\/]+/).filter(Boolean); + return parts.at(-1) ?? cwd; +} + +function messageFor(payload: ClaudeHookPayload, type: AgentEventType): string { + if (payload.message) return payload.message; + if (type === 'agent_session_start') return 'Claude Code session started'; + if (type === 'agent_needs_input') return 'Claude Code needs input'; + if (type === 'agent_done') return 'Claude Code session stopped'; + return 'Claude Code status update'; +} + +async function main(): Promise { + const endpoint = process.env.AGENT_EVENT_ENDPOINT ?? 'http://localhost:3001/agent/event'; + const token = process.env.AGENT_EVENT_TOKEN; + if (!token) throw new Error('AGENT_EVENT_TOKEN is required'); + + const payload = await readHookPayload(); + const type = eventTypeFor(payload); + const event: AgentEvent = { + type, + harness: 'claude-code', + sessionId: payload.session_id ?? crypto.randomUUID(), + project: projectName(payload.cwd), + message: messageFor(payload, type), + timestamp: new Date().toISOString(), + }; + + const valid = validateAgentEvent(event); + if (!valid.ok) throw new Error(valid.error); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(valid.value), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`agent event POST failed: ${response.status} ${text}`); + } +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6885728 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,107 @@ +# Lunar Pup foundation contracts + +This repo keeps feature seams in plain TypeScript modules so parallel work can add files without editing the game loop or the server entry point. + +## Agent event protocol + +`src/contracts/agentEvents.ts` defines harness-to-server-to-client status messages: + +- `agent_session_start` +- `agent_status` +- `agent_needs_input` +- `agent_done` + +Every event carries `harness`, `sessionId`, `project`, `message`, and ISO-compatible `timestamp`. `validateAgentEvent` is the runtime gate for untrusted JSON. + +## Package manifests + +`src/contracts/packageManifest.ts` defines shareable mod-like content manifests. A manifest has: + +- `kind`: `cosmetic` or `gamemode` +- `version` +- `author` +- `displayName` +- `assetRefs`: named URI/media-type references with their own SHA-256 +- `id`: SHA-256 of the canonical JSON for the manifest without `id` + +`canonicalManifestJson` sorts object keys recursively. `packageManifestId` hashes that canonical JSON. `validatePackageManifest` rejects manifests whose `id` does not match the content. + +## Cosmetics + +`src/contracts/cosmetic.ts` defines cosmetic packages only; there is no shop or inventory UI here. + +- `slot`: `board`, `body`, `trail`, or `aura` +- `rarity`: `common`, `rare`, `epic`, or `legendary` +- `visual.colors`: `#RRGGBB` or `#RRGGBBAA` +- optional mesh parameters: `shape`, `scale`, `roughness`, `metalness` +- optional particle parameters: `count`, `size`, `lifetime`, `emissionRate` + +`validateCosmeticDefinition` is the runtime validator. + +## Gamemodes + +`src/contracts/gamemode.ts` defines the gamemode interface, not concrete game content. A gamemode provides: + +- `id` +- lifecycle: `init`, `start`, `tick`, `end` +- player hooks: `onPlayerJoin`, `onPlayerLeave` +- scoring: `score` +- win condition: `isWinConditionMet` +- checkpoint definitions with position, radius, and optional order + +`src/game/loop.ts` exposes `setCurrentGamemode(gamemode, state)`. When set, the loop increments `state.elapsedMs` and calls `gamemode.tick(dt, state)` each frame. + +## Room/lobby protocol + +`src/contracts/roomProtocol.ts` defines lobby messages: + +- client: `create_room`, `join_room`, `leave_room`, `list_rooms` +- server: `room_state`, `room_list` + +Room state includes `roomId`, `gamemodeId`, and player IDs. This is only the protocol contract; no lobby feature is built in this unit. + +## Currency, inventory, and ledger storage + +`src/contracts/services.ts` defines storage interfaces that can be implemented by SQLite now and Postgres/Timescale later. + +`CurrencyInventoryService`: + +- `getBalance(accountId)` +- `grant(accountId, amount, reason)` +- `spend(accountId, amount, reason)`; throws `InsufficientFundsError` when balance is too low +- `listOwnedItems(accountId)` +- `grantOwnedItem(accountId, cosmeticId, reason)` + +`EventLedgerStorage`: + +- `append({ type, entityId, timestamp, payload })` +- `query({ type, entityId, from, to })` + +The default backend is Bun SQLite at `data/lunarpup.db`. The `data` directory is gitignored. The ledger is append-only at the interface level: callers append typed events and query by time range/type/entity. + +## Server routing + +`src/server.ts` now owns only process wiring: + +- create a `ModularRouter` +- register server modules +- start `Bun.serve` +- pass WebSocket messages to the router + +`src/server/router.ts` supports: + +- HTTP routes registered by method/path +- WebSocket handlers registered by channel +- a default `multiplayer` channel when a legacy message has no `channel` field + +`src/server/multiplayer.ts` registers the existing join/state/leave flow on the `multiplayer` channel. Existing `src/net/client.ts` messages are unchanged, so current multiplayer clients continue sending `{ type: 'join' }` and `{ type: 'state' }` without a channel field. + +## Game loop extension hooks + +`src/game/loop.ts` exposes: + +- `registerUpdateHook(fn)` returns an unregister function and calls `fn(dt, state)` once per frame +- `setCurrentGamemode(gamemode, state)` attaches or clears a gamemode tick target +- `getCurrentGamemode()` reports the currently attached gamemode + +Hooks receive `playerGroup`, `physics`, `scene`, and `skateboard`. This lets cosmetics and gamemodes attach behavior without editing `loop.ts` again. diff --git a/src/contracts/agentEvents.ts b/src/contracts/agentEvents.ts new file mode 100644 index 0000000..66bb37d --- /dev/null +++ b/src/contracts/agentEvents.ts @@ -0,0 +1,31 @@ +import { fail, isRecord, ok, readEnum, readString, type ValidationResult } from './validators.ts'; + +export const agentEventTypes = ['agent_session_start', 'agent_status', 'agent_needs_input', 'agent_done'] as const; +export type AgentEventType = (typeof agentEventTypes)[number]; + +export interface AgentEvent { + type: AgentEventType; + harness: string; + sessionId: string; + project: string; + message: string; + timestamp: string; +} + +export function validateAgentEvent(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('agent event must be an object'); + const type = readEnum(value, 'type', agentEventTypes); + if (!type.ok) return type; + const harness = readString(value, 'harness'); + if (!harness.ok) return harness; + const sessionId = readString(value, 'sessionId'); + if (!sessionId.ok) return sessionId; + const project = readString(value, 'project'); + if (!project.ok) return project; + const message = readString(value, 'message'); + if (!message.ok) return message; + const timestamp = readString(value, 'timestamp'); + if (!timestamp.ok) return timestamp; + if (Number.isNaN(Date.parse(timestamp.value))) return fail('timestamp must be an ISO-compatible date string'); + return ok({ type: type.value, harness: harness.value, sessionId: sessionId.value, project: project.value, message: message.value, timestamp: timestamp.value }); +} diff --git a/src/contracts/contracts.test.ts b/src/contracts/contracts.test.ts new file mode 100644 index 0000000..b73d29f --- /dev/null +++ b/src/contracts/contracts.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'bun:test'; +import { + packageManifestId, + validateAgentEvent, + validateCosmeticDefinition, + validatePackageManifest, + validateRoomClientMessage, + validateRoomServerMessage, + type AgentEvent, + type CosmeticDefinition, + type PackageManifest, + type ValidationResult, +} from './index.ts'; + +function expectOk(result: ValidationResult): T { + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.error); + return result.value; +} + +function expectError(result: ValidationResult, error: string): void { + expect(result.ok).toBe(false); + if (result.ok) throw new Error('expected validation to fail'); + expect(result.error).toBe(error); +} + +function validManifest(): PackageManifest { + const withoutId: Omit = { + kind: 'cosmetic', + version: '1.0.0', + author: 'Moon Kennel', + displayName: 'Comet Trail', + assetRefs: [{ + name: 'trail', + uri: 'ipfs://comet-trail', + sha256: 'a'.repeat(64), + mediaType: 'model/gltf+json', + }], + metadata: { license: 'CC0' }, + }; + return { id: packageManifestId(withoutId), ...withoutId }; +} + +describe('runtime contract validators', () => { + test('accepts valid agent events and rejects invalid event types and timestamps', () => { + const event: AgentEvent = { + type: 'agent_status', + harness: 'omp-squad', + sessionId: 'session-1', + project: 'lunarpup', + message: 'skating', + timestamp: '2026-07-09T12:00:00.000Z', + }; + + expect(expectOk(validateAgentEvent(event))).toEqual(event); + expectError(validateAgentEvent({ ...event, type: 'agent_started' }), 'type must be one of agent_session_start, agent_status, agent_needs_input, agent_done'); + expectError(validateAgentEvent({ ...event, timestamp: 'not-a-date' }), 'timestamp must be an ISO-compatible date string'); + }); + + test('accepts complete cosmetic definitions and rejects malformed visuals', () => { + const cosmetic: CosmeticDefinition = { + id: 'comet-board', + slot: 'board', + rarity: 'epic', + visual: { + colors: ['#00ffcc', '#ffffff80'], + mesh: { shape: 'box', scale: [1, 0.2, 2], roughness: 0.75, metalness: 0.1 }, + particles: { count: 32, size: 0.08, lifetime: 0.5, emissionRate: 12 }, + }, + }; + + expect(expectOk(validateCosmeticDefinition(cosmetic))).toEqual(cosmetic); + expectError(validateCosmeticDefinition({ ...cosmetic, slot: 'helmet' }), 'slot must be one of board, body, trail, aura'); + expectError( + validateCosmeticDefinition({ ...cosmetic, visual: { ...cosmetic.visual, colors: ['#00ffcc', 'blue'] } }), + 'visual.colors[1] must be a #RRGGBB or #RRGGBBAA color', + ); + expectError( + validateCosmeticDefinition({ ...cosmetic, visual: { ...cosmetic.visual, mesh: { shape: 'sphere', scale: [1, Number.POSITIVE_INFINITY, 1] } } }), + 'visual.mesh.scale values must be finite', + ); + }); + + test('accepts package manifests only when the canonical id and asset hashes are valid', () => { + const manifest = validManifest(); + + expect(expectOk(validatePackageManifest(manifest))).toEqual(manifest); + expectError( + validatePackageManifest({ ...manifest, id: '0'.repeat(64) }), + `id must be sha256 of canonical manifest JSON (${manifest.id})`, + ); + expectError( + validatePackageManifest({ ...manifest, assetRefs: [{ ...manifest.assetRefs[0], sha256: 'A'.repeat(64) }] }), + 'assetRefs[0].sha256 must be 64 lowercase hex characters', + ); + }); + + test('validates room client commands and room server broadcasts', () => { + expect(expectOk(validateRoomClientMessage({ type: 'create_room', roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', playerId: 'pup-1' }))).toEqual({ + type: 'create_room', + roomId: 'moon-bowl', + gamemodeId: 'checkpoint-race', + playerId: 'pup-1', + }); + expect(expectOk(validateRoomClientMessage({ type: 'list_rooms' }))).toEqual({ type: 'list_rooms' }); + expectError(validateRoomClientMessage({ type: 'create_room', roomId: 'moon-bowl', playerId: 'pup-1' }), 'gamemodeId must be a non-empty string'); + + expect(expectOk(validateRoomServerMessage({ type: 'room_state', roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', players: ['pup-1', 'pup-2'] }))).toEqual({ + type: 'room_state', + roomId: 'moon-bowl', + gamemodeId: 'checkpoint-race', + players: ['pup-1', 'pup-2'], + }); + expect(expectOk(validateRoomServerMessage({ type: 'room_list', rooms: [{ roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', playerCount: 2 }] }))).toEqual({ + type: 'room_list', + rooms: [{ roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', playerCount: 2 }], + }); + expectError(validateRoomServerMessage({ type: 'room_list', rooms: [{ roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', playerCount: 'two' }] }), 'rooms[0].playerCount must be a finite number'); + expectError(validateRoomServerMessage({ type: 'room_state', roomId: 'moon-bowl', gamemodeId: 'checkpoint-race', players: ['pup-1', 2] }), 'players[1] must be a string'); + }); +}); diff --git a/src/contracts/cosmetic.ts b/src/contracts/cosmetic.ts new file mode 100644 index 0000000..a6db62f --- /dev/null +++ b/src/contracts/cosmetic.ts @@ -0,0 +1,98 @@ +import { fail, isRecord, ok, readArray, readEnum, readNumber, type ValidationResult, type Validator } from './validators.ts'; + +export const cosmeticSlots = ['board', 'body', 'trail', 'aura'] as const; +export type CosmeticSlot = (typeof cosmeticSlots)[number]; + +export const cosmeticRarities = ['common', 'rare', 'epic', 'legendary'] as const; +export type CosmeticRarity = (typeof cosmeticRarities)[number]; + +export interface MeshParams { + shape: 'box' | 'sphere' | 'cylinder' | 'custom'; + scale: [number, number, number]; + roughness?: number; + metalness?: number; +} + +export interface ParticleParams { + count: number; + size: number; + lifetime: number; + emissionRate: number; +} + +export interface CosmeticVisualSpec { + colors: string[]; + mesh?: MeshParams; + particles?: ParticleParams; +} + +export interface CosmeticDefinition { + id: string; + slot: CosmeticSlot; + visual: CosmeticVisualSpec; + rarity: CosmeticRarity; +} + +const meshShapes = ['box', 'sphere', 'cylinder', 'custom'] as const; +const colorPattern = /^#(?:[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/; + +const validateColor: Validator = (value, path = 'color') => { + return typeof value === 'string' && colorPattern.test(value) ? ok(value) : fail(`${path} must be a #RRGGBB or #RRGGBBAA color`); +}; + +function validateTuple3(value: unknown, path: string): ValidationResult<[number, number, number]> { + if (!Array.isArray(value) || value.length !== 3) return fail(`${path} must be a three-number tuple`); + const [x, y, z] = value; + if (typeof x !== 'number' || typeof y !== 'number' || typeof z !== 'number') return fail(`${path} must be a three-number tuple`); + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return fail(`${path} values must be finite`); + return ok([x, y, z]); +} + +function validateMeshParams(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('visual.mesh must be an object'); + const shape = readEnum(value, 'shape', meshShapes, 'visual.mesh.shape'); + if (!shape.ok) return shape; + const scale = validateTuple3(value.scale, 'visual.mesh.scale'); + if (!scale.ok) return scale; + const roughness = value.roughness === undefined ? undefined : readNumber(value, 'roughness', 'visual.mesh.roughness'); + if (roughness && !roughness.ok) return roughness; + const metalness = value.metalness === undefined ? undefined : readNumber(value, 'metalness', 'visual.mesh.metalness'); + if (metalness && !metalness.ok) return metalness; + return ok({ shape: shape.value, scale: scale.value, roughness: roughness?.value, metalness: metalness?.value }); +} + +function validateParticleParams(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('visual.particles must be an object'); + const count = readNumber(value, 'count', 'visual.particles.count'); + if (!count.ok) return count; + const size = readNumber(value, 'size', 'visual.particles.size'); + if (!size.ok) return size; + const lifetime = readNumber(value, 'lifetime', 'visual.particles.lifetime'); + if (!lifetime.ok) return lifetime; + const emissionRate = readNumber(value, 'emissionRate', 'visual.particles.emissionRate'); + if (!emissionRate.ok) return emissionRate; + return ok({ count: count.value, size: size.value, lifetime: lifetime.value, emissionRate: emissionRate.value }); +} + +function validateVisualSpec(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('visual must be an object'); + const colors = readArray(value, 'colors', validateColor, 'visual.colors'); + if (!colors.ok) return colors; + const mesh = value.mesh === undefined ? undefined : validateMeshParams(value.mesh); + if (mesh && !mesh.ok) return mesh; + const particles = value.particles === undefined ? undefined : validateParticleParams(value.particles); + if (particles && !particles.ok) return particles; + return ok({ colors: colors.value, mesh: mesh?.value, particles: particles?.value }); +} + +export function validateCosmeticDefinition(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('cosmetic definition must be an object'); + if (typeof value.id !== 'string' || value.id.length === 0) return fail('id must be a non-empty string'); + const slot = readEnum(value, 'slot', cosmeticSlots); + if (!slot.ok) return slot; + const visual = validateVisualSpec(value.visual); + if (!visual.ok) return visual; + const rarity = readEnum(value, 'rarity', cosmeticRarities); + if (!rarity.ok) return rarity; + return ok({ id: value.id, slot: slot.value, visual: visual.value, rarity: rarity.value }); +} diff --git a/src/contracts/gamemode.ts b/src/contracts/gamemode.ts new file mode 100644 index 0000000..bbc539b --- /dev/null +++ b/src/contracts/gamemode.ts @@ -0,0 +1,82 @@ +import type { PlayerSnapshot } from '../net/protocol.ts'; +import { fail, isRecord, ok, readArray, readNumber, readString, type ValidationResult, type Validator } from './validators.ts'; + +export interface CheckpointDefinition { + id: string; + position: { x: number; y: number; z: number }; + radius: number; + order?: number; +} + +export interface GamemodeRuntimeState { + players: Map; + scores: Map; + checkpoints: CheckpointDefinition[]; + elapsedMs: number; +} + +export interface GamemodeContext { + roomId: string; + now: () => number; + broadcast: (message: unknown) => void; +} + +export interface Gamemode { + id: string; + checkpoints: CheckpointDefinition[]; + init(context: GamemodeContext): void | Promise; + start(state: GamemodeRuntimeState): void | Promise; + tick(dt: number, state: GamemodeRuntimeState): void | Promise; + end(state: GamemodeRuntimeState): void | Promise; + onPlayerJoin(player: PlayerSnapshot, state: GamemodeRuntimeState): void | Promise; + onPlayerLeave(playerId: string, state: GamemodeRuntimeState): void | Promise; + score(playerId: string, state: GamemodeRuntimeState): number; + isWinConditionMet(state: GamemodeRuntimeState): boolean; +} + +function validatePosition(value: unknown, path: string): ValidationResult<{ x: number; y: number; z: number }> { + if (!isRecord(value)) return fail(`${path} must be an object`); + const x = readNumber(value, 'x', `${path}.x`); + if (!x.ok) return x; + const y = readNumber(value, 'y', `${path}.y`); + if (!y.ok) return y; + const z = readNumber(value, 'z', `${path}.z`); + if (!z.ok) return z; + return ok({ x: x.value, y: y.value, z: z.value }); +} + +const validateCheckpoint: Validator = (value, path = 'checkpoint') => { + if (!isRecord(value)) return fail(`${path} must be an object`); + const id = readString(value, 'id', `${path}.id`); + if (!id.ok) return id; + const position = validatePosition(value.position, `${path}.position`); + if (!position.ok) return position; + const radius = readNumber(value, 'radius', `${path}.radius`); + if (!radius.ok) return radius; + const order = value.order === undefined ? undefined : readNumber(value, 'order', `${path}.order`); + if (order && !order.ok) return order; + return ok({ id: id.value, position: position.value, radius: radius.value, order: order?.value }); +}; + +export function validateCheckpointDefinition(value: unknown): ValidationResult { + return validateCheckpoint(value, 'checkpoint'); +} + +export function validateCheckpointDefinitions(value: unknown): ValidationResult { + if (!isRecord({ checkpoints: value })) return fail('checkpoints must be an array'); + return readArray({ checkpoints: value }, 'checkpoints', validateCheckpoint); +} + +export function isGamemode(value: unknown): value is Gamemode { + if (!isRecord(value)) return false; + return typeof value.id === 'string' + && Array.isArray(value.checkpoints) + && typeof value.init === 'function' + && typeof value.start === 'function' + && typeof value.tick === 'function' + && typeof value.end === 'function' + && typeof value.onPlayerJoin === 'function' + && typeof value.onPlayerLeave === 'function' + && typeof value.score === 'function' + && typeof value.isWinConditionMet === 'function'; +} diff --git a/src/contracts/index.ts b/src/contracts/index.ts new file mode 100644 index 0000000..6a6d16c --- /dev/null +++ b/src/contracts/index.ts @@ -0,0 +1,7 @@ +export * from './agentEvents.ts'; +export * from './cosmetic.ts'; +export * from './gamemode.ts'; +export * from './packageManifest.ts'; +export * from './roomProtocol.ts'; +export * from './services.ts'; +export * from './validators.ts'; diff --git a/src/contracts/packageManifest.ts b/src/contracts/packageManifest.ts new file mode 100644 index 0000000..0a1a45b --- /dev/null +++ b/src/contracts/packageManifest.ts @@ -0,0 +1,82 @@ +import { createHash } from 'node:crypto'; +import { fail, isRecord, ok, readArray, readEnum, readRecordOfStrings, readString, type ValidationResult, type Validator } from './validators.ts'; + +export const packageKinds = ['cosmetic', 'gamemode'] as const; +export type PackageKind = (typeof packageKinds)[number]; + +export interface AssetRef { + name: string; + uri: string; + sha256: string; + mediaType: string; +} + +export interface PackageManifest { + id: string; + kind: PackageKind; + version: string; + author: string; + displayName: string; + assetRefs: AssetRef[]; + metadata?: Record; +} + +const sha256Pattern = /^[a-f0-9]{64}$/; + +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; + const entries = Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([a], [b]) => a.localeCompare(b)); + return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalize(entryValue)}`).join(',')}}`; +} + +export function canonicalManifestJson(manifest: Omit): string { + return canonicalize(manifest); +} + +export function packageManifestId(manifest: Omit): string { + return createHash('sha256').update(canonicalManifestJson(manifest)).digest('hex'); +} + +const validateAssetRef: Validator = (value, path = 'assetRefs[]') => { + if (!isRecord(value)) return fail(`${path} must be an object`); + const name = readString(value, 'name', `${path}.name`); + if (!name.ok) return name; + const uri = readString(value, 'uri', `${path}.uri`); + if (!uri.ok) return uri; + const sha256 = readString(value, 'sha256', `${path}.sha256`); + if (!sha256.ok) return sha256; + if (!sha256Pattern.test(sha256.value)) return fail(`${path}.sha256 must be 64 lowercase hex characters`); + const mediaType = readString(value, 'mediaType', `${path}.mediaType`); + if (!mediaType.ok) return mediaType; + return ok({ name: name.value, uri: uri.value, sha256: sha256.value, mediaType: mediaType.value }); +}; + +export function validatePackageManifest(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('package manifest must be an object'); + const id = readString(value, 'id'); + if (!id.ok) return id; + if (!sha256Pattern.test(id.value)) return fail('id must be 64 lowercase hex characters'); + const kind = readEnum(value, 'kind', packageKinds); + if (!kind.ok) return kind; + const version = readString(value, 'version'); + if (!version.ok) return version; + const author = readString(value, 'author'); + if (!author.ok) return author; + const displayName = readString(value, 'displayName'); + if (!displayName.ok) return displayName; + const assetRefs = readArray(value, 'assetRefs', validateAssetRef); + if (!assetRefs.ok) return assetRefs; + let metadata: Record | undefined; + if (value.metadata !== undefined) { + const parsed = readRecordOfStrings(value, 'metadata'); + if (!parsed.ok) return parsed; + metadata = parsed.value; + } + const withoutId: Omit = { kind: kind.value, version: version.value, author: author.value, displayName: displayName.value, assetRefs: assetRefs.value, metadata }; + const expectedId = packageManifestId(withoutId); + if (id.value !== expectedId) return fail(`id must be sha256 of canonical manifest JSON (${expectedId})`); + return ok({ id: id.value, ...withoutId }); +} diff --git a/src/contracts/roomProtocol.ts b/src/contracts/roomProtocol.ts new file mode 100644 index 0000000..f616dd8 --- /dev/null +++ b/src/contracts/roomProtocol.ts @@ -0,0 +1,81 @@ +import { fail, isRecord, ok, readArray, readEnum, readNumber, readString, type ValidationResult, type Validator } from './validators.ts'; + +export const roomClientMessageTypes = ['create_room', 'join_room', 'leave_room', 'list_rooms'] as const; +export type RoomClientMessageType = (typeof roomClientMessageTypes)[number]; + +export interface CreateRoomMessage { type: 'create_room'; roomId: string; gamemodeId: string; playerId: string } +export interface JoinRoomMessage { type: 'join_room'; roomId: string; playerId: string } +export interface LeaveRoomMessage { type: 'leave_room'; roomId: string; playerId: string } +export interface ListRoomsMessage { type: 'list_rooms' } +export type RoomClientMessage = CreateRoomMessage | JoinRoomMessage | LeaveRoomMessage | ListRoomsMessage; + +export interface RoomSummary { + roomId: string; + gamemodeId: string; + playerCount: number; +} + +export interface RoomStateBroadcast { + type: 'room_state'; + roomId: string; + gamemodeId: string; + players: string[]; +} + +export interface RoomListMessage { + type: 'room_list'; + rooms: RoomSummary[]; +} + +export type RoomServerMessage = RoomStateBroadcast | RoomListMessage; + +function validateRoomSummary(value: unknown, path = 'rooms[]'): ValidationResult { + if (!isRecord(value)) return fail(`${path} must be an object`); + const roomId = readString(value, 'roomId', `${path}.roomId`); + if (!roomId.ok) return roomId; + const gamemodeId = readString(value, 'gamemodeId', `${path}.gamemodeId`); + if (!gamemodeId.ok) return gamemodeId; + const playerCount = readNumber(value, 'playerCount', `${path}.playerCount`); + if (!playerCount.ok) return playerCount; + return ok({ roomId: roomId.value, gamemodeId: gamemodeId.value, playerCount: playerCount.value }); +} + +const validateStringItem: Validator = (value, path = 'item') => { + return typeof value === 'string' ? ok(value) : fail(`${path} must be a string`); +}; + +export function validateRoomClientMessage(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('room message must be an object'); + const type = readEnum(value, 'type', roomClientMessageTypes); + if (!type.ok) return type; + if (type.value === 'list_rooms') return ok({ type: 'list_rooms' }); + const roomId = readString(value, 'roomId'); + if (!roomId.ok) return roomId; + const playerId = readString(value, 'playerId'); + if (!playerId.ok) return playerId; + if (type.value === 'create_room') { + const gamemodeId = readString(value, 'gamemodeId'); + if (!gamemodeId.ok) return gamemodeId; + return ok({ type: 'create_room', roomId: roomId.value, gamemodeId: gamemodeId.value, playerId: playerId.value }); + } + return ok({ type: type.value, roomId: roomId.value, playerId: playerId.value }); +} + +export function validateRoomServerMessage(value: unknown): ValidationResult { + if (!isRecord(value)) return fail('room server message must be an object'); + if (value.type === 'room_list') { + const rooms = readArray(value, 'rooms', validateRoomSummary); + if (!rooms.ok) return rooms; + return ok({ type: 'room_list', rooms: rooms.value }); + } + if (value.type === 'room_state') { + const roomId = readString(value, 'roomId'); + if (!roomId.ok) return roomId; + const gamemodeId = readString(value, 'gamemodeId'); + if (!gamemodeId.ok) return gamemodeId; + const players = readArray(value, 'players', validateStringItem); + if (!players.ok) return players; + return ok({ type: 'room_state', roomId: roomId.value, gamemodeId: gamemodeId.value, players: players.value }); + } + return fail('type must be room_state or room_list'); +} diff --git a/src/contracts/services.test.ts b/src/contracts/services.test.ts new file mode 100644 index 0000000..8b17d43 --- /dev/null +++ b/src/contracts/services.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { InsufficientFundsError, SqliteCurrencyInventoryService, SqliteEventLedgerStorage } from './services.ts'; + +const dbPaths: string[] = []; + +function tempDbPath(name: string): string { + const path = join(tmpdir(), `lunarpup-${name}-${crypto.randomUUID()}.db`); + dbPaths.push(path, `${path}-shm`, `${path}-wal`); + return path; +} + +afterEach(() => { + for (const path of dbPaths.splice(0)) { + if (existsSync(path)) rmSync(path, { force: true }); + } +}); + +describe('sqlite event ledger', () => { + test('appends typed events and queries by type and time range', () => { + const ledger = new SqliteEventLedgerStorage({ path: tempDbPath('ledger') }); + ledger.append({ type: 'currency_granted', entityId: 'player-1', timestamp: '2026-07-09T10:00:00.000Z', payload: { amount: 10 } }); + ledger.append({ type: 'item_granted', entityId: 'player-1', timestamp: '2026-07-09T11:00:00.000Z', payload: { cosmeticId: 'board-1' } }); + ledger.append({ type: 'currency_granted', entityId: 'player-2', timestamp: '2026-07-09T12:00:00.000Z', payload: { amount: 5 } }); + + const currencyEvents = ledger.query({ type: 'currency_granted', from: '2026-07-09T09:30:00.000Z', to: '2026-07-09T11:30:00.000Z' }); + expect(currencyEvents).toHaveLength(1); + expect(currencyEvents[0]?.entityId).toBe('player-1'); + expect(currencyEvents[0]?.payload).toEqual({ amount: 10 }); + }); +}); + +describe('sqlite currency inventory service', () => { + test('grants, spends, rejects insufficient funds, and lists owned cosmetics', () => { + const path = tempDbPath('inventory'); + const ledger = new SqliteEventLedgerStorage({ path }); + const service = new SqliteCurrencyInventoryService({ path, ledger }); + + expect(service.getBalance('player-1')).toBe(0); + expect(service.grant('player-1', 100, 'test grant')).toBe(100); + expect(service.spend('player-1', 35, 'test spend')).toBe(65); + expect(() => service.spend('player-1', 100, 'too much')).toThrow(InsufficientFundsError); + + service.grantOwnedItem('player-1', 'moon-board', 'test item'); + service.grantOwnedItem('player-1', 'moon-board', 'duplicate item'); + service.grantOwnedItem('player-1', 'comet-trail', 'test item'); + expect(service.listOwnedItems('player-1')).toEqual(['comet-trail', 'moon-board']); + + const events = ledger.query({ entityId: 'player-1' }); + expect(events.map(event => event.type)).toEqual(['currency_granted', 'currency_spent', 'item_granted', 'item_granted']); + }); +}); diff --git a/src/contracts/services.ts b/src/contracts/services.ts new file mode 100644 index 0000000..077c8d5 --- /dev/null +++ b/src/contracts/services.ts @@ -0,0 +1,167 @@ +import { Database } from 'bun:sqlite'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +export class InsufficientFundsError extends Error { + constructor(public readonly accountId: string, public readonly balance: number, public readonly required: number) { + super(`insufficient funds for ${accountId}: balance ${balance}, required ${required}`); + this.name = 'InsufficientFundsError'; + } +} + +export interface CurrencyInventoryService { + getBalance(accountId: string): Promise | number; + grant(accountId: string, amount: number, reason: string): Promise | number; + spend(accountId: string, amount: number, reason: string): Promise | number; + listOwnedItems(accountId: string): Promise | string[]; + grantOwnedItem(accountId: string, cosmeticId: string, reason: string): Promise | void; +} + +export interface LedgerEvent { + id?: number; + type: TType; + entityId?: string; + timestamp: string; + payload: TPayload; +} + +export interface LedgerQuery { + type?: string; + from?: string; + to?: string; + entityId?: string; +} + +export interface EventLedgerStorage { + append(event: Omit, 'id'>): Promise> | LedgerEvent; + query(query: LedgerQuery): Promise | LedgerEvent[]; +} + +export interface SqliteBackendOptions { + path?: string; +} + +const defaultDbPath = 'data/lunarpup.db'; + +function openDatabase(path: string): Database { + mkdirSync(dirname(path), { recursive: true }); + const db = new Database(path); + db.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS ledger_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + entity_id TEXT, + timestamp TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ledger_type_time ON ledger_events(type, timestamp); + CREATE INDEX IF NOT EXISTS idx_ledger_entity_time ON ledger_events(entity_id, timestamp); + CREATE TABLE IF NOT EXISTS balances ( + account_id TEXT PRIMARY KEY, + balance INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS owned_items ( + account_id TEXT NOT NULL, + cosmetic_id TEXT NOT NULL, + granted_at TEXT NOT NULL, + PRIMARY KEY (account_id, cosmetic_id) + ); + `); + return db; +} + +export class SqliteEventLedgerStorage implements EventLedgerStorage { + readonly db: Database; + + constructor(options: SqliteBackendOptions = {}) { + this.db = openDatabase(options.path ?? defaultDbPath); + } + + append(event: Omit, 'id'>): LedgerEvent { + const timestamp = event.timestamp || new Date().toISOString(); + const result = this.db.query('INSERT INTO ledger_events (type, entity_id, timestamp, payload_json) VALUES ($type, $entityId, $timestamp, $payload)').run({ + $type: event.type, + $entityId: event.entityId ?? null, + $timestamp: timestamp, + $payload: JSON.stringify(event.payload), + }); + return { ...event, timestamp, id: Number(result.lastInsertRowid) }; + } + + query(query: LedgerQuery = {}): LedgerEvent[] { + const clauses: string[] = []; + const params: Record = {}; + if (query.type) { + clauses.push('type = $type'); + params.$type = query.type; + } + if (query.entityId) { + clauses.push('entity_id = $entityId'); + params.$entityId = query.entityId; + } + if (query.from) { + clauses.push('timestamp >= $from'); + params.$from = query.from; + } + if (query.to) { + clauses.push('timestamp <= $to'); + params.$to = query.to; + } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const rows = this.db.query(`SELECT id, type, entity_id, timestamp, payload_json FROM ledger_events ${where} ORDER BY timestamp ASC, id ASC`).all(params) as Array<{ id: number; type: string; entity_id: string | null; timestamp: string; payload_json: string }>; + return rows.map(row => ({ id: row.id, type: row.type, entityId: row.entity_id ?? undefined, timestamp: row.timestamp, payload: JSON.parse(row.payload_json) })); + } +} + +export class SqliteCurrencyInventoryService implements CurrencyInventoryService { + readonly db: Database; + readonly ledger: EventLedgerStorage; + + constructor(options: SqliteBackendOptions & { ledger?: EventLedgerStorage; db?: Database } = {}) { + this.db = options.db ?? openDatabase(options.path ?? defaultDbPath); + this.ledger = options.ledger ?? new SqliteEventLedgerStorage({ path: options.path ?? defaultDbPath }); + } + + getBalance(accountId: string): number { + const row = this.db.query('SELECT balance FROM balances WHERE account_id = $accountId').get({ $accountId: accountId }) as { balance: number } | null; + return row?.balance ?? 0; + } + + grant(accountId: string, amount: number, reason: string): number { + if (!Number.isInteger(amount) || amount <= 0) throw new Error('grant amount must be a positive integer'); + this.db.query(` + INSERT INTO balances (account_id, balance) VALUES ($accountId, $amount) + ON CONFLICT(account_id) DO UPDATE SET balance = balance + excluded.balance + `).run({ $accountId: accountId, $amount: amount }); + const balance = this.getBalance(accountId); + this.ledger.append({ type: 'currency_granted', entityId: accountId, timestamp: new Date().toISOString(), payload: { amount, reason, balance } }); + return balance; + } + + spend(accountId: string, amount: number, reason: string): number { + if (!Number.isInteger(amount) || amount <= 0) throw new Error('spend amount must be a positive integer'); + const balance = this.getBalance(accountId); + if (balance < amount) throw new InsufficientFundsError(accountId, balance, amount); + this.db.query('UPDATE balances SET balance = balance - $amount WHERE account_id = $accountId').run({ $accountId: accountId, $amount: amount }); + const nextBalance = balance - amount; + this.ledger.append({ type: 'currency_spent', entityId: accountId, timestamp: new Date().toISOString(), payload: { amount, reason, balance: nextBalance } }); + return nextBalance; + } + + listOwnedItems(accountId: string): string[] { + const rows = this.db.query('SELECT cosmetic_id FROM owned_items WHERE account_id = $accountId ORDER BY cosmetic_id ASC').all({ $accountId: accountId }) as Array<{ cosmetic_id: string }>; + return rows.map(row => row.cosmetic_id); + } + + grantOwnedItem(accountId: string, cosmeticId: string, reason: string): void { + const result = this.db.query('INSERT OR IGNORE INTO owned_items (account_id, cosmetic_id, granted_at) VALUES ($accountId, $cosmeticId, $grantedAt)').run({ + $accountId: accountId, + $cosmeticId: cosmeticId, + $grantedAt: new Date().toISOString(), + }); + if (result.changes > 0) { + this.ledger.append({ type: 'item_granted', entityId: accountId, timestamp: new Date().toISOString(), payload: { cosmeticId, reason } }); + } + } +} diff --git a/src/contracts/validators.ts b/src/contracts/validators.ts new file mode 100644 index 0000000..b79a91e --- /dev/null +++ b/src/contracts/validators.ts @@ -0,0 +1,62 @@ +export type ValidationResult = { ok: true; value: T } | { ok: false; error: string }; +export type Validator = (value: unknown, path?: string) => ValidationResult; + +export function ok(value: T): ValidationResult { return { ok: true, value }; } +export function fail(error: string): ValidationResult { return { ok: false, error }; } + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function readString(obj: Record, key: string, path = key): ValidationResult { + const value = obj[key]; + return typeof value === 'string' && value.length > 0 ? ok(value) : fail(`${path} must be a non-empty string`); +} + +export function readOptionalString(obj: Record, key: string): ValidationResult { + const value = obj[key]; + return value === undefined || typeof value === 'string' ? ok(value) : fail(`${key} must be a string`); +} + +export function readNumber(obj: Record, key: string, path = key): ValidationResult { + const value = obj[key]; + return typeof value === 'number' && Number.isFinite(value) ? ok(value) : fail(`${path} must be a finite number`); +} + +export function readBoolean(obj: Record, key: string, path = key): ValidationResult { + const value = obj[key]; + return typeof value === 'boolean' ? ok(value) : fail(`${path} must be a boolean`); +} + +export function readEnum(obj: Record, key: string, values: T, path = key): ValidationResult { + const value = obj[key]; + return typeof value === 'string' && values.includes(value) ? ok(value as T[number]) : fail(`${path} must be one of ${values.join(', ')}`); +} + +export function readArray(obj: Record, key: string, item: Validator, path = key): ValidationResult { + const value = obj[key]; + if (!Array.isArray(value)) return fail(`${path} must be an array`); + const out: T[] = []; + for (let i = 0; i < value.length; i++) { + const parsed = item(value[i], `${path}[${i}]`); + if (!parsed.ok) return parsed; + out.push(parsed.value); + } + return ok(out); +} + +export function readRecordOfStrings(obj: Record, key: string, path = key): ValidationResult> { + const value = obj[key]; + if (!isRecord(value)) return fail(`${path} must be an object`); + const out: Record = {}; + for (const [entryKey, entryValue] of Object.entries(value)) { + if (typeof entryValue !== 'string' || entryValue.length === 0) return fail(`${path}.${entryKey} must be a non-empty string`); + out[entryKey] = entryValue; + } + return ok(out); +} + +export function assertValid(result: ValidationResult): T { + if (!result.ok) throw new Error(result.error); + return result.value; +} diff --git a/src/game/bootstrap.ts b/src/game/bootstrap.ts index 1650e36..e66c97f 100644 --- a/src/game/bootstrap.ts +++ b/src/game/bootstrap.ts @@ -21,6 +21,7 @@ export async function bootstrap() { { setupMinimap }, { setupChatUI }, { setupUpdateNotice }, + { setupAgentHud }, ] = await Promise.all([ import('./scene.ts'), import('./terrain.ts'), @@ -34,6 +35,7 @@ export async function bootstrap() { import('../ui/minimap.ts'), import('../ui/chat.ts'), import('../ui/updateNotice.ts'), + import('../ui/agentHud.ts'), ]); initScene(container); @@ -54,6 +56,7 @@ export async function bootstrap() { setupMinimap(); setupChatUI(mpConfig.enabled, mpConfig.name); setupUpdateNotice(); + setupAgentHud(); if (mpConfig.enabled) { if (mpConfig.transport === 'ws' && !mpConfig.wsUrl) { diff --git a/src/game/loop.ts b/src/game/loop.ts index 2568b64..ead5779 100644 --- a/src/game/loop.ts +++ b/src/game/loop.ts @@ -24,6 +24,37 @@ import { updateMinimap } from '../ui/minimap.ts'; import { updateRemotePlayers } from './remotePlayers.ts'; import { buildLocalSnapshot } from './multiplayer.ts'; import { finishTrick, startTrick, updateTrick } from './tricks.ts'; +import type { Gamemode, GamemodeRuntimeState } from '../contracts/gamemode.ts'; + +export interface GameLoopState { + playerGroup: typeof playerGroup; + physics: typeof physics; + scene: typeof scene; + skateboard: typeof skateboard; +} + +export type UpdateHook = (dt: number, state: GameLoopState) => void; + +const updateHooks = new Set(); +let currentGamemode: { gamemode: Gamemode; state: GamemodeRuntimeState } | null = null; + +export function registerUpdateHook(fn: UpdateHook): () => void { + updateHooks.add(fn); + return () => updateHooks.delete(fn); +} + +export function setCurrentGamemode(gamemode: Gamemode | null, state?: GamemodeRuntimeState): void { + if (!gamemode) { + currentGamemode = null; + return; + } + if (!state) throw new Error('state is required when setting a gamemode'); + currentGamemode = { gamemode, state }; +} + +export function getCurrentGamemode(): Gamemode | null { + return currentGamemode?.gamemode ?? null; +} export function setupCameraControls() { const canvas = renderer.domElement; @@ -196,6 +227,13 @@ export function startGameLoop() { updateRemotePlayers(dt); updateMinimap(); + const loopState: GameLoopState = { playerGroup, physics, scene, skateboard }; + for (const hook of updateHooks) hook(dt, loopState); + if (currentGamemode) { + currentGamemode.state.elapsedMs += dt * 1000; + void currentGamemode.gamemode.tick(dt, currentGamemode.state); + } + if (multiplayerClient?.isConnected) { multiplayerClient.sendState(buildLocalSnapshot( playerGroup, diff --git a/src/game/player.ts b/src/game/player.ts index 2e8311a..b175cd9 100644 --- a/src/game/player.ts +++ b/src/game/player.ts @@ -17,7 +17,7 @@ export interface VoxelDogParts { tail: THREE.Mesh; } -export function createVoxelDog(dogColor: number = PLAYER_COLORS[0], deckColor = 0xff5555): VoxelDogParts { +export function createVoxelDog(dogColor: number = PLAYER_COLORS[0] ?? 0xffb703, deckColor: number = 0xff5555): VoxelDogParts { const group = new THREE.Group(); const skateboard = new THREE.Group(); diff --git a/src/server.ts b/src/server.ts index 41f5182..7745c92 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,174 +1,35 @@ -import type { ServerWebSocket } from 'bun'; -import { - DEFAULT_ROOM, - DEFAULT_WS_PORT, - PLAYER_COLORS, - parseClientMessage, - type ClientMessage, - type PlayerSnapshot, - type ServerMessage, -} from './net/protocol.ts'; +import { DEFAULT_WS_PORT } from './net/protocol.ts'; +import { createInitialConnection, registerMultiplayerModule, removePlayer, type PlayerConnection } from './server/multiplayer.ts'; +import { registerAgentEventsModule } from './server/agentEvents.ts'; +import { ModularRouter } from './server/router.ts'; -interface PlayerConnection { - id: string; - name: string; - color: number; - room: string; - ws?: ServerWebSocket; - state: Omit; -} - -interface Room { - players: Map; - usedColors: Set; -} - -const rooms = new Map(); - -function getRoom(roomId: string): Room { - let room = rooms.get(roomId); - if (!room) { - room = { players: new Map(), usedColors: new Set() }; - rooms.set(roomId, room); - } - return room; -} - -function pickColor(room: Room): number { - for (const color of PLAYER_COLORS) { - if (!room.usedColors.has(color)) return color; - } - return PLAYER_COLORS[Math.floor(Math.random() * PLAYER_COLORS.length)]!; -} - -function snapshotFrom(conn: PlayerConnection): PlayerSnapshot { - return { id: conn.id, name: conn.name, color: conn.color, ...conn.state }; -} - -function defaultState(): Omit { - return { - x: 0, y: 0, z: 0, - qx: 0, qy: 0, qz: 0, qw: 1, - heading: 0, speed: 0, isGrounded: true, - boardTiltX: 0, boardTiltZ: 0, - }; -} - -function pendingConnection(): PlayerConnection { - return { - id: '', - name: '', - color: PLAYER_COLORS[0], - room: '', - state: defaultState(), - }; -} - -function send(ws: ServerWebSocket, msg: ServerMessage) { - ws.send(JSON.stringify(msg)); -} - -function broadcast(room: Room, msg: ServerMessage, exceptId?: string) { - for (const [id, conn] of room.players) { - if (id !== exceptId && conn.ws) send(conn.ws, msg); - } -} - -function removePlayer(conn: PlayerConnection) { - const room = rooms.get(conn.room); - if (!room) return; - - room.players.delete(conn.id); - room.usedColors.delete(conn.color); - broadcast(room, { type: 'player_left', id: conn.id }); - - if (room.players.size === 0) { - rooms.delete(conn.room); - } -} - -function handleJoin(ws: ServerWebSocket, msg: Extract) { - const roomId = msg.room.trim() || DEFAULT_ROOM; - const room = getRoom(roomId); - const id = crypto.randomUUID(); - const color = pickColor(room); - const name = msg.name.trim().slice(0, 24) || `Pup${Math.floor(Math.random() * 900 + 100)}`; - - const conn: PlayerConnection = { - id, - name, - color, - room: roomId, - ws, - state: defaultState(), - }; - - ws.data = conn; - room.players.set(id, conn); - room.usedColors.add(color); - - const existing = [...room.players.values()] - .filter(p => p.id !== id) - .map(snapshotFrom); - - send(ws, { type: 'welcome', id, color, room: roomId, players: existing }); - broadcast(room, { type: 'player_joined', player: snapshotFrom(conn) }, id); - - console.log(`[+] ${name} joined room "${roomId}" (${room.players.size} players)`); -} - -function handleState(conn: PlayerConnection, msg: Extract) { - conn.state = msg.state; - const room = rooms.get(conn.room); - if (!room) return; - broadcast(room, { type: 'state', id: conn.id, state: msg.state }, conn.id); -} - -function handleChat(conn: PlayerConnection, msg: Extract) { - const text = msg.text.trim().slice(0, 200); - if (!text) return; - const room = rooms.get(conn.room); - if (!room) return; - broadcast(room, { - type: 'chat', - id: conn.id, - name: conn.name, - text, - ts: Date.now(), - }); +export function createServerRouter(): ModularRouter { + const router = new ModularRouter(); + registerMultiplayerModule(router); + registerAgentEventsModule(router); + return router; } +const router = createServerRouter(); const port = Number(process.env.PORT) || DEFAULT_WS_PORT; const server = Bun.serve({ port, - fetch(req, server) { - if (server.upgrade(req, { data: pendingConnection() })) return undefined; - return new Response('Lunar Pup multiplayer WebSocket server', { - headers: { 'Content-Type': 'text/plain' }, + async fetch(req, server) { + const handled = await router.handleHttp(req, { + server, + upgrade: (data) => server.upgrade(req, { data: data ?? (undefined as unknown as PlayerConnection) }), }); + if (handled) return handled; + if (server.upgrade(req, { data: undefined as unknown as PlayerConnection })) return undefined; + return new Response('Not found', { status: 404 }); }, websocket: { open(ws) { - ws.data.ws = ws; + ws.data = createInitialConnection(ws); }, message(ws, message) { - const parsed = parseClientMessage(message); - if (!parsed) return; - - if (parsed.type === 'join') { - if (ws.data.id) return; - handleJoin(ws, parsed); - return; - } - - if (!ws.data.id) return; - - if (parsed.type === 'state') { - handleState(ws.data, parsed); - } else if (parsed.type === 'chat') { - handleChat(ws.data, parsed); - } + router.dispatchWebSocket(ws, message); }, close(ws) { if (!ws.data.id) return; diff --git a/src/server/agentEvents.test.ts b/src/server/agentEvents.test.ts new file mode 100644 index 0000000..12be5c3 --- /dev/null +++ b/src/server/agentEvents.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'bun:test'; +import type { Server, ServerWebSocket } from 'bun'; +import type { AgentEvent } from '../contracts/agentEvents.ts'; +import type { EventLedgerStorage, LedgerEvent, LedgerQuery } from '../contracts/services.ts'; +import { registerAgentEventsModule } from './agentEvents.ts'; +import type { PlayerConnection } from './multiplayer.ts'; +import { ModularRouter } from './router.ts'; + +class MemoryLedger implements EventLedgerStorage { + readonly events: LedgerEvent[] = []; + + append(event: Omit, 'id'>): LedgerEvent { + const saved = { id: this.events.length + 1, ...event } as LedgerEvent; + this.events.push(saved as LedgerEvent); + return saved; + } + + query(_query: LedgerQuery): LedgerEvent[] { + return this.events; + } +} + +function validEvent(overrides: Partial = {}): AgentEvent { + return { + type: 'agent_status', + harness: 'claude-code', + sessionId: 'session-1', + project: 'lunarpup', + message: 'running tests', + timestamp: '2026-07-09T12:00:00.000Z', + ...overrides, + }; +} + +function request(body: unknown, token = 'secret'): Request { + return new Request('http://localhost/agent/event', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); +} + +describe('agent events module', () => { + test('validates auth and event body before accepting endpoint posts', async () => { + const ledger = new MemoryLedger(); + const router = new ModularRouter(); + const published: string[] = []; + registerAgentEventsModule(router, { ledger, token: 'secret' }); + const server = { publish: (_topic: string, payload: string) => published.push(payload) }; + const typedServer = server as unknown as Server; + + const unauthorized = await router.handleHttp(request(validEvent(), 'wrong'), { server: typedServer, upgrade: () => false }); + expect(unauthorized?.status).toBe(401); + + const invalid = await router.handleHttp(request({ ...validEvent(), type: 'bad' }), { server: typedServer, upgrade: () => false }); + expect(invalid?.status).toBe(400); + + const accepted = await router.handleHttp(request(validEvent()), { server: typedServer, upgrade: () => false }); + expect(accepted?.status).toBe(202); + expect(ledger.events).toHaveLength(1); + expect(published).toHaveLength(1); + }); + + test('appends accepted events to the ledger with session entity id', async () => { + const ledger = new MemoryLedger(); + const router = new ModularRouter(); + registerAgentEventsModule(router, { ledger, token: 'secret' }); + const server = { publish: () => 1 }; + const typedServer = server as unknown as Server; + const event = validEvent({ type: 'agent_needs_input', sessionId: 'session-42', message: 'Approve deploy?' }); + + await router.handleHttp(request(event), { server: typedServer, upgrade: () => false }); + + expect(ledger.events).toHaveLength(1); + expect(ledger.events[0]).toMatchObject({ + type: 'agent_needs_input', + entityId: 'session-42', + timestamp: event.timestamp, + payload: event, + }); + }); + + test('subscribes websocket clients and broadcasts accepted events on agent-events channel', async () => { + const ledger = new MemoryLedger(); + const router = new ModularRouter(); + const broadcasts: Array<{ topic: string; payload: string }> = []; + registerAgentEventsModule(router, { ledger, token: 'secret' }); + const server = { publish: (topic: string, payload: string) => broadcasts.push({ topic, payload }) }; + const typedServer = server as unknown as Server; + const subscribed: string[] = []; + const sent: string[] = []; + const ws = { + subscribe: (topic: string) => subscribed.push(topic), + send: (payload: string) => sent.push(payload), + }; + const typedWs = ws as unknown as ServerWebSocket; + + expect(router.dispatchWebSocket(typedWs, { channel: 'agent-events', type: 'subscribe' })).toBe(true); + await router.handleHttp(request(validEvent({ type: 'agent_done' })), { server: typedServer, upgrade: () => false }); + + expect(subscribed).toEqual(['agent-events']); + expect(JSON.parse(sent[0] ?? '{}')).toEqual({ channel: 'agent-events', type: 'subscribed' }); + expect(broadcasts).toHaveLength(1); + expect(broadcasts[0]?.topic).toBe('agent-events'); + expect(JSON.parse(broadcasts[0]?.payload ?? '{}')).toEqual({ channel: 'agent-events', event: validEvent({ type: 'agent_done' }) }); + }); +}); diff --git a/src/server/agentEvents.ts b/src/server/agentEvents.ts new file mode 100644 index 0000000..247a75a --- /dev/null +++ b/src/server/agentEvents.ts @@ -0,0 +1,63 @@ +import type { Server, ServerWebSocket } from 'bun'; +import { validateAgentEvent, type AgentEvent } from '../contracts/agentEvents.ts'; +import { SqliteEventLedgerStorage, type EventLedgerStorage } from '../contracts/services.ts'; +import type { PlayerConnection } from './multiplayer.ts'; +import type { ModularRouter } from './router.ts'; + +const AGENT_EVENTS_TOPIC = 'agent-events'; + +export interface AgentEventsModuleOptions { + ledger?: EventLedgerStorage; + token?: string; +} + +export interface AgentEventBroadcast { + channel: 'agent-events'; + event: AgentEvent; +} + + +async function parseJson(request: Request): Promise { + try { + return await request.json(); + } catch { + return undefined; + } +} + +async function appendAndBroadcast(server: Server, ledger: EventLedgerStorage, event: AgentEvent): Promise { + await ledger.append({ + type: event.type, + entityId: event.sessionId, + timestamp: event.timestamp, + payload: event, + }); + server.publish(AGENT_EVENTS_TOPIC, JSON.stringify({ channel: AGENT_EVENTS_TOPIC, event } satisfies AgentEventBroadcast)); +} + +export function registerAgentEventsModule(router: ModularRouter, options: AgentEventsModuleOptions = {}): void { + const ledger = options.ledger ?? new SqliteEventLedgerStorage(); + + router.registerHttp('POST', '/agent/event', async (request, context) => { + const token = options.token ?? process.env.AGENT_EVENT_TOKEN; + const requestBearer = request.headers.get('authorization'); + const [scheme, bearer] = requestBearer?.split(/\s+/, 2) ?? []; + const requestSecret = scheme?.toLowerCase() === 'bearer' && bearer ? bearer : request.headers.get('x-agent-event-token'); + if (!token || requestSecret !== token) { + return new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } }); + } + + const parsed = validateAgentEvent(await parseJson(request)); + if (!parsed.ok) { + return new Response(JSON.stringify({ error: parsed.error }), { status: 400, headers: { 'Content-Type': 'application/json' } }); + } + + await appendAndBroadcast(context.server, ledger, parsed.value); + return new Response(JSON.stringify({ ok: true }), { status: 202, headers: { 'Content-Type': 'application/json' } }); + }); + + router.registerWebSocket(AGENT_EVENTS_TOPIC, (ws: ServerWebSocket) => { + ws.subscribe(AGENT_EVENTS_TOPIC); + ws.send(JSON.stringify({ channel: AGENT_EVENTS_TOPIC, type: 'subscribed' })); + }); +} diff --git a/src/server/multiplayer.ts b/src/server/multiplayer.ts new file mode 100644 index 0000000..f2af9fc --- /dev/null +++ b/src/server/multiplayer.ts @@ -0,0 +1,146 @@ +import type { ServerWebSocket } from 'bun'; +import { + DEFAULT_ROOM, + PLAYER_COLORS, + parseClientMessage, + type ClientMessage, + type PlayerSnapshot, + type ServerMessage, +} from '../net/protocol.ts'; +import type { ModularRouter } from './router.ts'; + +export interface PlayerConnection { + id: string; + name: string; + color: number; + room: string; + ws: ServerWebSocket; + state: Omit; +} + +interface Room { + players: Map; + usedColors: Set; + gamemodeId: string; +} + +const rooms = new Map(); + +function getRoom(roomId: string): Room { + let room = rooms.get(roomId); + if (!room) { + room = { players: new Map(), usedColors: new Set(), gamemodeId: 'free-skate' }; + rooms.set(roomId, room); + } + return room; +} + +function pickColor(room: Room): number { + for (const color of PLAYER_COLORS) { + if (!room.usedColors.has(color)) return color; + } + return PLAYER_COLORS[Math.floor(Math.random() * PLAYER_COLORS.length)] ?? PLAYER_COLORS[0]; +} + +function snapshotFrom(conn: PlayerConnection): PlayerSnapshot { + return { id: conn.id, name: conn.name, color: conn.color, ...conn.state }; +} + +export function defaultPlayerState(): Omit { + return { + x: 0, y: 0, z: 0, + qx: 0, qy: 0, qz: 0, qw: 1, + heading: 0, speed: 0, isGrounded: true, + boardTiltX: 0, boardTiltZ: 0, + }; +} + +function send(ws: ServerWebSocket, msg: ServerMessage): void { + ws.send(JSON.stringify(msg)); +} + +function broadcast(room: Room, msg: ServerMessage, exceptId?: string): void { + for (const [id, conn] of room.players) { + if (id !== exceptId) send(conn.ws, msg); + } +} + +export function createInitialConnection(ws: ServerWebSocket): PlayerConnection { + return { + id: '', + name: '', + color: PLAYER_COLORS[0], + room: '', + ws, + state: defaultPlayerState(), + }; +} + +export function removePlayer(conn: PlayerConnection): void { + const room = rooms.get(conn.room); + if (!room) return; + room.players.delete(conn.id); + room.usedColors.delete(conn.color); + broadcast(room, { type: 'player_left', id: conn.id }); + if (room.players.size === 0) rooms.delete(conn.room); +} + +function handleJoin(ws: ServerWebSocket, msg: Extract): void { + const roomId = msg.room.trim() || DEFAULT_ROOM; + const room = getRoom(roomId); + const id = crypto.randomUUID(); + const color = pickColor(room); + const name = msg.name.trim().slice(0, 24) || `Pup${Math.floor(Math.random() * 900 + 100)}`; + const conn: PlayerConnection = { id, name, color, room: roomId, ws, state: defaultPlayerState() }; + ws.data = conn; + room.players.set(id, conn); + room.usedColors.add(color); + const existing = [...room.players.values()].filter(player => player.id !== id).map(snapshotFrom); + send(ws, { type: 'welcome', id, color, room: roomId, players: existing }); + broadcast(room, { type: 'player_joined', player: snapshotFrom(conn) }, id); + console.log(`[+] ${name} joined room "${roomId}" (${room.players.size} players, gamemode ${room.gamemodeId})`); +} + +function handleState(conn: PlayerConnection, msg: Extract): void { + conn.state = msg.state; + const room = rooms.get(conn.room); + if (!room) return; + broadcast(room, { type: 'state', id: conn.id, state: msg.state }, conn.id); +} + +function handleChat(conn: PlayerConnection, msg: Extract): void { + const text = msg.text.trim().slice(0, 200); + if (!text) return; + const room = rooms.get(conn.room); + if (!room) return; + broadcast(room, { + type: 'chat', + id: conn.id, + name: conn.name, + text, + ts: Date.now(), + }); +} + +export function registerMultiplayerModule(router: ModularRouter): void { + router.registerHttp('GET', '/', () => new Response('Lunar Pup multiplayer WebSocket server', { + headers: { 'Content-Type': 'text/plain' }, + })); + + router.registerWebSocket('multiplayer', (ws, payload) => { + const parsed = typeof payload === 'string' || payload instanceof Buffer ? parseClientMessage(payload) : parseClientMessage(JSON.stringify(payload)); + if (!parsed) return; + if (parsed.type === 'join') { + if (ws.data.id) return; + handleJoin(ws, parsed); + return; + } + if (!ws.data.id) return; + if (parsed.type === 'state') handleState(ws.data, parsed); + else if (parsed.type === 'chat') handleChat(ws.data, parsed); + }); +} + +export function getRoomSummaries(): Array<{ roomId: string; gamemodeId: string; playerCount: number }> { + return [...rooms.entries()].map(([roomId, room]) => ({ roomId, gamemodeId: room.gamemodeId, playerCount: room.players.size })); +} diff --git a/src/server/router.test.ts b/src/server/router.test.ts new file mode 100644 index 0000000..83f44a5 --- /dev/null +++ b/src/server/router.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; +import type { ServerWebSocket } from 'bun'; +import { ModularRouter } from './router.ts'; + +interface TestConnection { id: string } + +describe('ModularRouter', () => { + test('dispatches registered HTTP routes by method and path', async () => { + const router = new ModularRouter(); + router.registerHttp('GET', '/health', () => new Response('ok', { status: 204 })); + + const response = await router.handleHttp(new Request('http://localhost/health'), { + server: {} as never, + upgrade: () => false, + }); + expect(response?.status).toBe(204); + expect(await router.handleHttp(new Request('http://localhost/missing'), { server: {} as never, upgrade: () => false })).toBeUndefined(); + }); + + test('dispatches explicit WebSocket channels', () => { + const router = new ModularRouter(); + const seen: unknown[] = []; + router.registerWebSocket('agent', (_ws, payload) => seen.push(payload)); + + const handled = router.dispatchWebSocket({} as ServerWebSocket, { channel: 'agent', type: 'agent_status' }); + expect(handled).toBe(true); + expect(seen).toHaveLength(1); + }); + + test('defaults legacy messages without channel to multiplayer', () => { + const router = new ModularRouter(); + let messageType = ''; + router.registerWebSocket('multiplayer', (_ws, payload) => { + if (typeof payload === 'object' && payload && 'type' in payload) messageType = String(payload.type); + }); + + expect(router.dispatchWebSocket({} as ServerWebSocket, { type: 'join', room: 'lunar-park', name: 'Pup' })).toBe(true); + expect(messageType).toBe('join'); + expect(router.dispatchWebSocket({} as ServerWebSocket, { channel: 'unknown' })).toBe(false); + }); +}); diff --git a/src/server/router.ts b/src/server/router.ts new file mode 100644 index 0000000..4742c5f --- /dev/null +++ b/src/server/router.ts @@ -0,0 +1,54 @@ +import type { Server, ServerWebSocket } from 'bun'; + +export interface HttpRouteContext { + server: Server; + upgrade: (data?: TConnection) => boolean; +} + +export type HttpRouteHandler = (request: Request, context: HttpRouteContext) => Response | Promise | undefined | Promise; +export type WebSocketHandler = (ws: ServerWebSocket, payload: unknown) => void; + +export interface WebSocketEnvelope { + channel?: string; + type?: string; + [key: string]: unknown; +} + +export class ModularRouter { + private readonly httpRoutes: Array<{ method: string; path: string; handler: HttpRouteHandler }> = []; + private readonly wsHandlers = new Map>(); + + registerHttp(method: string, path: string, handler: HttpRouteHandler): void { + this.httpRoutes.push({ method: method.toUpperCase(), path, handler }); + } + + registerWebSocket(channel: string, handler: WebSocketHandler): void { + this.wsHandlers.set(channel, handler); + } + + async handleHttp(request: Request, context: HttpRouteContext): Promise { + const url = new URL(request.url); + for (const route of this.httpRoutes) { + if (route.method === request.method.toUpperCase() && route.path === url.pathname) { + return await route.handler(request, context); + } + } + return undefined; + } + + dispatchWebSocket(ws: ServerWebSocket, payload: unknown): boolean { + const channel = this.channelFor(payload); + const handler = this.wsHandlers.get(channel); + if (!handler) return false; + handler(ws, payload); + return true; + } + + private channelFor(payload: unknown): string { + if (payload && typeof payload === 'object' && 'channel' in payload) { + const channel = (payload as WebSocketEnvelope).channel; + if (typeof channel === 'string' && channel.length > 0) return channel; + } + return 'multiplayer'; + } +} diff --git a/src/styles.css b/src/styles.css index 5c82ba3..fb0999b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -367,3 +367,149 @@ h1 { #update-notice button:hover { background: rgba(160,196,255,0.32); } + +#agent-hud { + position: fixed; + top: 20px; + left: 20px; + z-index: 20; + width: min(320px, calc(100vw - 40px)); + max-height: 42vh; + overflow: hidden; + border: 1px solid rgba(255,255,255,0.14); + border-radius: 14px; + background: rgba(10, 16, 28, 0.72); + color: #f8fbff; + box-shadow: 0 18px 48px rgba(0,0,0,0.32); + backdrop-filter: blur(12px); +} + +.agent-header { + padding: 12px 14px 10px; + border-bottom: 1px solid rgba(255,255,255,0.1); +} + +.agent-header h2 { + margin: 0 0 6px; + font-size: 15px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.agent-status { + font-size: 12px; + color: rgba(255,255,255,0.68); +} + +.agent-session-list { + display: grid; + gap: 8px; + max-height: calc(42vh - 62px); + overflow-y: auto; + padding: 10px; +} + +.agent-empty { + padding: 12px; + border: 1px dashed rgba(255,255,255,0.16); + border-radius: 10px; + color: rgba(255,255,255,0.62); + font-size: 12px; +} + +.agent-row { + padding: 10px; + border: 1px solid rgba(255,255,255,0.1); + border-left: 3px solid #a0c4ff; + border-radius: 10px; + background: rgba(255,255,255,0.06); +} + +.agent-row.agent-needs_input { + border-left-color: #ffd166; + background: rgba(255, 209, 102, 0.12); +} + +.agent-row.agent-done { + border-left-color: #80ff72; + opacity: 0.82; +} + +.agent-row-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.agent-row-top strong { + min-width: 0; + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-pill { + flex: none; + border-radius: 999px; + padding: 2px 7px; + background: rgba(255,255,255,0.12); + color: rgba(255,255,255,0.82); + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.agent-message { + margin-bottom: 6px; + color: rgba(255,255,255,0.9); + font-size: 12px; + line-height: 1.35; +} + +.agent-meta { + overflow: hidden; + color: rgba(255,255,255,0.54); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-screen-pulse { + pointer-events: none; + position: fixed; + inset: 0; + z-index: 19; + border: 8px solid rgba(255, 209, 102, 0.78); + box-shadow: inset 0 0 80px rgba(255, 209, 102, 0.36); +} + +@media (prefers-reduced-motion: no-preference) { + .agent-screen-pulse { + animation: agent-screen-pulse 340ms ease-out forwards; + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-screen-pulse { + opacity: 0; + } +} + +@keyframes agent-screen-pulse { + 0% { + opacity: 0; + transform: scale(1.02); + } + 18% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(1); + } +} diff --git a/src/ui/agentHud.ts b/src/ui/agentHud.ts new file mode 100644 index 0000000..f1c0f21 --- /dev/null +++ b/src/ui/agentHud.ts @@ -0,0 +1,207 @@ +import type { AgentEvent, AgentEventType } from '../contracts/agentEvents.ts'; +import { getWsUrl } from '../net/protocol.ts'; + +type AgentStatus = 'running' | 'needs_input' | 'done'; + +interface AgentSessionView { + harness: string; + sessionId: string; + project: string; + message: string; + timestamp: string; + status: AgentStatus; +} + +interface AgentEventBroadcastMessage { + channel: 'agent-events'; + event: AgentEvent; +} + +const statusByType: Record = { + agent_session_start: 'running', + agent_status: 'running', + agent_needs_input: 'needs_input', + agent_done: 'done', +}; + +const sessions = new Map(); +let panelEl: HTMLDivElement | null = null; +let listEl: HTMLDivElement | null = null; +let statusEl: HTMLDivElement | null = null; +let notificationPermissionRequested = false; +let audioContext: AudioContext | null = null; +let socket: WebSocket | null = null; + +function isAgentEventBroadcastMessage(value: unknown): value is AgentEventBroadcastMessage { + if (!value || typeof value !== 'object') return false; + if (!('channel' in value) || value.channel !== 'agent-events') return false; + if (!('event' in value) || !value.event || typeof value.event !== 'object') return false; + return 'type' in value.event && 'sessionId' in value.event; +} + +function formatTime(timestamp: string): string { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? timestamp : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function renderSessions(): void { + if (!listEl || !statusEl) return; + const ordered = [...sessions.values()].sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + statusEl.textContent = ordered.length === 0 ? 'Waiting for agent events' : `${ordered.filter(session => session.status !== 'done').length} active · ${ordered.length} total`; + listEl.replaceChildren(); + + if (ordered.length === 0) { + const empty = document.createElement('div'); + empty.className = 'agent-empty'; + empty.textContent = 'No harness sessions yet.'; + listEl.appendChild(empty); + return; + } + + for (const session of ordered) { + const row = document.createElement('article'); + row.className = `agent-row agent-${session.status}`; + row.innerHTML = ` +
+ + +
+
+
+ `; + const title = row.querySelector('strong'); + const pill = row.querySelector('.agent-pill'); + const message = row.querySelector('.agent-message'); + const meta = row.querySelector('.agent-meta'); + if (title) title.textContent = session.project; + if (pill) pill.textContent = session.status.replace('_', ' '); + if (message) message.textContent = session.message; + if (meta) meta.textContent = `${session.harness} · ${session.sessionId} · ${formatTime(session.timestamp)}`; + listEl.appendChild(row); + } +} + +async function requestNotificationPermission(): Promise { + if (notificationPermissionRequested || !('Notification' in window)) return; + notificationPermissionRequested = true; + if (Notification.permission === 'default') await Notification.requestPermission(); +} + +function armNotificationPermissionRequest(): void { + const request = () => void requestNotificationPermission(); + window.addEventListener('pointerdown', request, { once: true }); + window.addEventListener('keydown', request, { once: true }); +} + +function sendNotification(event: AgentEvent): void { + if (!('Notification' in window) || Notification.permission !== 'granted') return; + new Notification(`${event.project} needs input`, { + body: event.message, + tag: `agent-${event.sessionId}`, + silent: true, + }); +} + +function playDogBark(): void { + const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext; + if (!AudioContextCtor) return; + audioContext ??= new AudioContextCtor(); + const startedAt = audioContext.currentTime; + + const noiseLength = Math.max(1, Math.floor(audioContext.sampleRate * 0.11)); + const noiseBuffer = audioContext.createBuffer(1, noiseLength, audioContext.sampleRate); + const samples = noiseBuffer.getChannelData(0); + for (let i = 0; i < samples.length; i += 1) samples[i] = Math.random() * 2 - 1; + + const noise = audioContext.createBufferSource(); + const noiseGain = audioContext.createGain(); + noise.buffer = noiseBuffer; + noiseGain.gain.setValueAtTime(0.0001, startedAt); + noiseGain.gain.exponentialRampToValueAtTime(0.34, startedAt + 0.015); + noiseGain.gain.exponentialRampToValueAtTime(0.0001, startedAt + 0.12); + noise.connect(noiseGain).connect(audioContext.destination); + noise.start(startedAt); + noise.stop(startedAt + 0.13); + + const oscillator = audioContext.createOscillator(); + const oscillatorGain = audioContext.createGain(); + oscillator.type = 'square'; + oscillator.frequency.setValueAtTime(220, startedAt); + oscillator.frequency.exponentialRampToValueAtTime(95, startedAt + 0.09); + oscillatorGain.gain.setValueAtTime(0.0001, startedAt); + oscillatorGain.gain.exponentialRampToValueAtTime(0.22, startedAt + 0.012); + oscillatorGain.gain.exponentialRampToValueAtTime(0.0001, startedAt + 0.1); + oscillator.connect(oscillatorGain).connect(audioContext.destination); + oscillator.start(startedAt); + oscillator.stop(startedAt + 0.11); +} + +function pulseScreen(): void { + const pulse = document.createElement('div'); + pulse.className = 'agent-screen-pulse'; + document.body.appendChild(pulse); + window.setTimeout(() => pulse.remove(), 360); +} + +function handleAgentEvent(event: AgentEvent): void { + sessions.set(event.sessionId, { + harness: event.harness, + sessionId: event.sessionId, + project: event.project, + message: event.message, + timestamp: event.timestamp, + status: statusByType[event.type], + }); + renderSessions(); + + if (event.type === 'agent_needs_input') { + pulseScreen(); + playDogBark(); + sendNotification(event); + } +} + +function connectAgentEvents(): void { + const wsUrl = getWsUrl(); + if (!wsUrl || socket) return; + socket = new WebSocket(wsUrl); + socket.addEventListener('open', () => { + socket?.send(JSON.stringify({ channel: 'agent-events', type: 'subscribe' })); + }); + socket.addEventListener('message', message => { + try { + const data = JSON.parse(String(message.data)); + if (isAgentEventBroadcastMessage(data)) handleAgentEvent(data.event); + } catch { + // Ignore malformed messages on the shared game WebSocket. + } + }); + socket.addEventListener('close', () => { + socket = null; + window.setTimeout(connectAgentEvents, 2000); + }); +} + +export function setupAgentHud(): void { + panelEl = document.createElement('div'); + panelEl.id = 'agent-hud'; + panelEl.innerHTML = ` +
+

Agent harness

+
Waiting for agent events
+
+
+ `; + document.body.appendChild(panelEl); + statusEl = panelEl.querySelector('#agent-status'); + listEl = panelEl.querySelector('#agent-session-list'); + renderSessions(); + armNotificationPermissionRequest(); + connectAgentEvents(); +} + +declare global { + interface Window { + webkitAudioContext?: typeof AudioContext; + } +}