feat: add trunk decorations, resize superlatives panel, add LLM plan docs

- Add trunk decoration layer to GameMap and mapGenerator
- Load trunk.png asset in BootScene
- Increase superlatives panel size and font sizes for readability
- Simplify mapGenerator (remove procedural terrain features)
- Add LLM integration planning documents
- Remove stale day-night cycle worktree plans
- Update local Claude settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 17:09:19 +00:00
parent baeaafd372
commit 6f459fa88a
11 changed files with 1499 additions and 598 deletions
+6 -1
View File
@@ -7,7 +7,12 @@
"Bash(mkdir:*)",
"Bash(netstat:*)",
"Bash(taskkill:*)",
"Bash(node:*)"
"Bash(node:*)",
"Skill(commit-commands:commit-push-pr)",
"Bash(git remote:*)",
"Bash(git push:*)",
"Bash(__NEW_LINE_79326c8da8d642d3__ echo:*)",
"Bash(git:*)"
]
}
}
@@ -1,81 +0,0 @@
# Day/Night Cycle Design
## Overview
A visual day/night cycle tied to NPC energy drain rate. One full day equals the time for energy to drain from 100 to 0. Night lasts half as long as day. Sunset and sunrise are directional gradient transitions with warm glow effects.
## Timing
- Day length = `100 / ENERGY_DECAY_PER_TICK / TICK_RATE` seconds (~333s at current values)
- Night length = day / `DAY_NIGHT_RATIO` (~167s)
- Full cycle = ~500s (~8.3 min)
- 1 game hour = cycle / 18 hours (~27.75s)
- Cycle starts at dawn on server boot
## Constants (shared, tunable via future admin panel)
- `ENERGY_DECAY_PER_TICK = 0.03` (already exists)
- `DAY_NIGHT_RATIO = 2` — day is 2x night
- `NIGHT_DARKNESS = 0.3` — max overlay opacity (30%)
- `SUNSET_DURATION_HOURS = 1` — game hours for sunset transition
- `SUNRISE_DURATION_HOURS = 1` — game hours for sunrise transition
## Cycle Phases
Normalized time 0.0 to 1.0 over full cycle:
```
0.0 0.611 0.667 0.889 0.944 1.0
| DAY | SUNSET | FULL NIGHT | SUNRISE | DAWN |
| (11 hrs) | (1 hr) | (4 hrs) | (1 hr) | (wraps) |
```
- **Day** (hours 0-11): No overlay.
- **Sunset** (hours 11-12): Darkness gradient fades in right-to-left. Warm glow on left edge.
- **Full Night** (hours 12-16): Static dark overlay at NIGHT_DARKNESS opacity.
- **Sunrise** (hours 16-17): Darkness gradient fades out right-to-left. Golden glow on right edge.
- **Dawn** (hour 17 = wraps to 0): Clear sky.
12 hours day, 6 hours night (1 sunset + 4 full night + 1 sunrise).
## Sun Direction
Sun is conceptually in the west (left side of screen):
- Sunset: sun disappears left, darkness sweeps right-to-left
- Sunrise: sun appears right, darkness lifts right-to-left
## Server-Side
- GameLoop tracks elapsed ticks, computes `gameTime` as float 0.0-1.0
- `gameTime` added to existing state broadcast (every 3 ticks)
- Server is authoritative on current time
## Client-Side Rendering
Single `Phaser.GameObjects.Graphics` with `.setScrollFactor(0)`, depth 999.
**Darkness overlay:** ~50 vertical strips spanning screen height. Each strip's alpha interpolated based on phase:
- Sunset: right strips darken first, left last. All reach NIGHT_DARKNESS by end.
- Full night: uniform NIGHT_DARKNESS. Color: dark blue-black (0x0a0a2a).
- Sunrise: right strips lighten first, left last. All reach 0 by end.
**Glow effect:** ~20 strips along relevant edge:
- Sunset glow (left edge): pink/orange/purple, ~15-20% screen width, bell curve intensity
- Sunrise glow (right edge): golden/amber, same width, same bell curve
Redraw every frame during transitions. Static phases only redraw on resize.
**Update:** Scene `update()` reads server-broadcast gameTime, determines phase, redraws overlay. Client interpolates between broadcast values for smooth transitions.
## Data Flow
```
Server GameLoop Network Client GameScene
- tracks elapsed ticks --> state broadcast --> receives gameTime
- computes gameTime (includes gameTime calculates phase
(0.0-1.0) every 3 ticks) redraws overlay
```
## Rollback Plan
If the Graphics overlay approach has performance issues, remove the overlay system. The design is isolated to a single graphics object and update hook.
@@ -1,369 +0,0 @@
# Day/Night Cycle Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add a visual day/night cycle with directional sunset/sunrise transitions, tied to NPC energy drain timing.
**Architecture:** Server tracks game time as a normalized float (0-1) and broadcasts it with state updates. Client renders a camera-fixed Graphics overlay with gradient darkness strips and warm glow effects during transitions.
**Tech Stack:** TypeScript, Phaser 3.80 (Graphics API), Socket.IO
---
### Task 1: Add Day/Night Constants
**Files:**
- Modify: `shared/src/constants.ts:12-17`
**Step 1: Add constants after existing NPC needs block**
Add these constants after line 17 in `shared/src/constants.ts`:
```typescript
// Day/night cycle
export const DAY_NIGHT_RATIO = 2; // day is 2x as long as night
export const DAY_HOURS = 12; // game hours of daytime
export const NIGHT_HOURS = DAY_HOURS / DAY_NIGHT_RATIO; // 6 hours of night
export const TOTAL_HOURS = DAY_HOURS + NIGHT_HOURS; // 18 hours total cycle
export const SUNSET_DURATION_HOURS = 1; // game hours for sunset transition
export const SUNRISE_DURATION_HOURS = 1; // game hours for sunrise transition
export const NIGHT_DARKNESS = 0.3; // max overlay opacity (0-1)
```
**Step 2: Commit**
```bash
git add shared/src/constants.ts
git commit -m "feat: add day/night cycle constants"
```
---
### Task 2: Add gameTime to StateUpdate type
**Files:**
- Modify: `shared/src/types.ts:145-148`
**Step 1: Add gameTime field to StateUpdate interface**
Change the `StateUpdate` interface at line 145 of `shared/src/types.ts`:
```typescript
export interface StateUpdate {
entities: EntityState[];
tick: number;
gameTime: number; // 0.0-1.0 normalized position in day/night cycle
}
```
**Step 2: Commit**
```bash
git add shared/src/types.ts
git commit -m "feat: add gameTime to StateUpdate type"
```
---
### Task 3: Track and broadcast game time on server
**Files:**
- Modify: `server/src/game/GameLoop.ts:1,17,71,87-89`
- Modify: `server/src/network/stateSerializer.ts:87-90`
**Step 1: Import day/night constants in GameLoop**
At line 1 of `server/src/game/GameLoop.ts`, add imports:
```typescript
import { TICK_RATE, BROADCAST_EVERY_N_TICKS, ENERGY_DECAY_PER_TICK, DAY_NIGHT_RATIO } from '@dflike/shared';
```
**Step 2: Add getGameTime() method to GameLoop**
After the `getTick()` method (line 87-89), add:
```typescript
getGameTime(): number {
// Day length in ticks = time for energy to drain from 100 to 0
const dayTicks = 100 / ENERGY_DECAY_PER_TICK;
const nightTicks = dayTicks / DAY_NIGHT_RATIO;
const cycleTicks = dayTicks + nightTicks;
return (this.tick % cycleTicks) / cycleTicks;
}
```
**Step 3: Add gameTime to serializeStateUpdate**
In `server/src/network/stateSerializer.ts`, change the `serializeStateUpdate` function signature to accept `gameTime`:
```typescript
export function serializeStateUpdate(world: World, tick: number, gameTime: number): StateUpdate {
const entities = world.query('position').map(id => serializeEntity(world, id));
return { entities, tick, gameTime };
}
```
**Step 4: Pass gameTime from SocketServer broadcast**
In `server/src/network/SocketServer.ts` line 26, update the call:
```typescript
const update = serializeStateUpdate(this.gameLoop.world, this.gameLoop.getTick(), this.gameLoop.getGameTime());
```
**Step 5: Commit**
```bash
git add server/src/game/GameLoop.ts server/src/network/stateSerializer.ts server/src/network/SocketServer.ts
git commit -m "feat: server tracks and broadcasts game time"
```
---
### Task 4: Add day/night overlay to GameScene
**Files:**
- Modify: `client/src/scenes/GameScene.ts`
This is the main visual implementation. Add a Graphics overlay that renders darkness and glow based on the server's gameTime.
**Step 1: Add day/night state properties**
Add these properties to the `GameScene` class (after `highlightedSpriteId` at line 35):
```typescript
private dayNightOverlay!: Phaser.GameObjects.Graphics;
private currentGameTime = 0;
private targetGameTime = 0;
private lastPhaseKey = '';
```
**Step 2: Import day/night constants**
Update the import from `@dflike/shared` at line 9:
```typescript
import { TILE_SIZE, SPRITE_FRAME_WIDTH, SPRITE_FRAME_HEIGHT, SPRITE_COLS, Direction, Terrain, TILESET_TILE_SIZE, TILESET_SCALE, DAY_HOURS, NIGHT_HOURS, TOTAL_HOURS, SUNSET_DURATION_HOURS, SUNRISE_DURATION_HOURS, NIGHT_DARKNESS } from '@dflike/shared';
```
**Step 3: Create the overlay in create()**
After the camera setup (after line 65 `this.cameras.main.setBounds(...)`), add:
```typescript
// Day/night overlay
this.dayNightOverlay = this.add.graphics();
this.dayNightOverlay.setScrollFactor(0);
this.dayNightOverlay.setDepth(999);
```
**Step 4: Add the day/night rendering methods**
Add these methods to the GameScene class:
```typescript
private getDayNightPhase(gameTime: number): { phase: 'day' | 'sunset' | 'night' | 'sunrise'; progress: number } {
const hour = gameTime * TOTAL_HOURS;
const sunsetStart = DAY_HOURS - SUNSET_DURATION_HOURS; // hour 11
const sunsetEnd = DAY_HOURS; // hour 12
const sunriseStart = TOTAL_HOURS - SUNRISE_DURATION_HOURS; // hour 17
const sunriseEnd = TOTAL_HOURS; // hour 18 (wraps to 0)
if (hour < sunsetStart) {
return { phase: 'day', progress: 0 };
} else if (hour < sunsetEnd) {
return { phase: 'sunset', progress: (hour - sunsetStart) / SUNSET_DURATION_HOURS };
} else if (hour < sunriseStart) {
return { phase: 'night', progress: 1 };
} else {
return { phase: 'sunrise', progress: (hour - sunriseStart) / SUNRISE_DURATION_HOURS };
}
}
private renderDayNightOverlay(): void {
const { phase, progress } = this.getDayNightPhase(this.currentGameTime);
const phaseKey = `${phase}-${Math.round(progress * 100)}`;
// Skip redraw if nothing changed
if (phaseKey === this.lastPhaseKey) return;
this.lastPhaseKey = phaseKey;
this.dayNightOverlay.clear();
if (phase === 'day') return;
const w = this.cameras.main.width;
const h = this.cameras.main.height;
const stripCount = 50;
const stripWidth = Math.ceil(w / stripCount);
// Darkness overlay
for (let i = 0; i < stripCount; i++) {
const x = i * stripWidth;
// t goes 0 (left) to 1 (right)
const t = i / (stripCount - 1);
let alpha: number;
if (phase === 'night') {
alpha = NIGHT_DARKNESS;
} else if (phase === 'sunset') {
// Right side darkens first: right strips reach full darkness earlier
// At progress=0, alpha=0 everywhere. At progress=1, alpha=NIGHT_DARKNESS everywhere.
// Each strip's individual progress is shifted: right strips lead.
const stripProgress = Math.max(0, Math.min(1, progress * 2 - (1 - t)));
alpha = stripProgress * NIGHT_DARKNESS;
} else {
// Sunrise: right side lightens first
const stripProgress = Math.max(0, Math.min(1, progress * 2 - (1 - t)));
alpha = (1 - stripProgress) * NIGHT_DARKNESS;
}
if (alpha > 0.001) {
this.dayNightOverlay.fillStyle(0x0a0a2a, alpha);
this.dayNightOverlay.fillRect(x, 0, stripWidth + 1, h);
}
}
// Glow effect during transitions
if (phase === 'sunset' || phase === 'sunrise') {
this.renderGlow(w, h, phase, progress);
}
}
private renderGlow(w: number, h: number, phase: 'sunset' | 'sunrise', progress: number): void {
const glowStripCount = 20;
const glowWidth = w * 0.18; // 18% of screen
const glowStripW = glowWidth / glowStripCount;
// Bell curve: peaks at progress=0.5, zero at 0 and 1
const bellIntensity = Math.sin(progress * Math.PI);
const maxGlowAlpha = 0.15 * bellIntensity;
if (maxGlowAlpha < 0.005) return;
// Sunset glow colors (left edge): warm pink/orange/purple
// Sunrise glow colors (right edge): golden/amber
const isRight = phase === 'sunrise';
for (let i = 0; i < glowStripCount; i++) {
// Fade from edge inward
const edgeFade = 1 - (i / glowStripCount);
const alpha = maxGlowAlpha * edgeFade * edgeFade; // quadratic falloff
if (alpha < 0.005) continue;
let x: number;
if (isRight) {
x = w - glowWidth + i * glowStripW;
} else {
x = (glowStripCount - 1 - i) * glowStripW;
}
// Blend colors across the glow width
const colorT = i / glowStripCount;
let color: number;
if (phase === 'sunset') {
// Left edge: purple -> pink -> orange (outer to inner)
if (colorT < 0.33) color = 0x9933aa; // purple
else if (colorT < 0.66) color = 0xff6688; // pink
else color = 0xff9944; // orange
} else {
// Right edge: deep gold -> amber -> pale gold (outer to inner)
if (colorT < 0.33) color = 0xffaa22; // deep gold
else if (colorT < 0.66) color = 0xffcc44; // amber
else color = 0xffdd88; // pale gold
}
this.dayNightOverlay.fillStyle(color, alpha);
this.dayNightOverlay.fillRect(x, 0, glowStripW + 1, h);
}
}
```
**Step 5: Store gameTime from state updates**
In `handleStateUpdate` (line 443), add at the top of the method:
```typescript
this.targetGameTime = update.gameTime;
```
**Step 6: Update the overlay each frame**
In the `update` method (line 518-520), add interpolation and rendering:
```typescript
update(_time: number, delta: number): void {
// Interpolate game time toward server value
const lerpSpeed = 0.1;
const diff = this.targetGameTime - this.currentGameTime;
// Handle wraparound (0.99 -> 0.01 should go forward, not backward)
if (Math.abs(diff) > 0.5) {
this.currentGameTime = this.targetGameTime;
} else {
this.currentGameTime += diff * lerpSpeed;
}
// Keep in 0-1 range
if (this.currentGameTime >= 1) this.currentGameTime -= 1;
if (this.currentGameTime < 0) this.currentGameTime += 1;
this.renderDayNightOverlay();
this.handleCameraAndMovement(delta);
}
```
**Step 7: Commit**
```bash
git add client/src/scenes/GameScene.ts
git commit -m "feat: add day/night visual overlay with sunset/sunrise transitions"
```
---
### Task 5: Manual testing and tuning
**Step 1: Start the server and client**
```bash
cd server && npm run dev &
cd client && npm run dev &
```
**Step 2: Verify the cycle works**
Open the game in browser. With the current energy drain rate, the full cycle is ~500 seconds (~8.3 minutes). To see a sunset quickly, either:
- Wait ~5 minutes for sunset to begin, OR
- Temporarily set `ENERGY_DECAY_PER_TICK = 0.5` in constants to speed up the cycle (~30s full cycle) for testing
**Step 3: Check these behaviors:**
- Day phase: no overlay visible
- Sunset: darkness sweeps in from right to left, warm glow appears on left edge
- Night: uniform dark blue overlay at ~30% opacity, still easy to see NPCs
- Sunrise: darkness lifts from right to left, golden glow on right edge
- Transitions are smooth (no flickering or stuttering)
- Overlay stays fixed to camera (doesn't move when panning)
- Overlay covers full screen when resizing browser window
**Step 4: Revert any test constant changes, commit if tuning adjustments were made**
---
### Task 6: Final cleanup commit
**Step 1: Run type checking**
```bash
cd shared && npx tsc --noEmit
cd server && npx tsc --noEmit
cd client && npx tsc --noEmit
```
**Step 2: Fix any type errors, commit**
```bash
git add -A
git commit -m "feat: complete day/night cycle system"
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+1
View File
@@ -14,6 +14,7 @@ export class BootScene extends Phaser.Scene {
this.load.image('lpc_watergrass', 'assets/lpc/watergrass.png');
this.load.image('lpc_dirt', 'assets/lpc/dirt.png');
this.load.image('lpc_treetop', 'assets/lpc/treetop.png');
this.load.image('lpc_trunk', 'assets/lpc/trunk.png');
}
create(): void {
+10 -10
View File
@@ -45,7 +45,7 @@ export class SuperlativesPanel {
// Main panel
this.panel = document.createElement('div');
this.panel.style.cssText = `
width: 240px;
width: 340px;
background: #1a1a2e;
border: 3px solid #e0d0b0;
border-left: none;
@@ -68,7 +68,7 @@ export class SuperlativesPanel {
header.style.cssText = `
text-align: center;
color: #e0d0b0;
font-size: 8px;
font-size: 16px;
padding: 4px 0 8px 0;
border-bottom: 1px solid #8878a8;
margin-bottom: 8px;
@@ -85,7 +85,7 @@ export class SuperlativesPanel {
const label = document.createElement('div');
label.style.cssText = `
color: #8878a8;
font-size: 6px;
font-size: 12px;
margin-bottom: 2px;
text-transform: uppercase;
`;
@@ -101,7 +101,7 @@ export class SuperlativesPanel {
const nameEl = document.createElement('span');
nameEl.style.cssText = `
color: #58d858;
font-size: 7px;
font-size: 14px;
cursor: pointer;
text-decoration: none;
`;
@@ -128,7 +128,7 @@ export class SuperlativesPanel {
const valueEl = document.createElement('span');
valueEl.style.cssText = `
color: #a0a0c0;
font-size: 6px;
font-size: 12px;
margin-left: 4px;
flex-shrink: 0;
`;
@@ -149,8 +149,8 @@ export class SuperlativesPanel {
// Tab
this.tab = document.createElement('div');
this.tab.style.cssText = `
width: 24px;
height: 48px;
width: 32px;
height: 56px;
background: #1a1a2e;
border: 3px solid #e0d0b0;
border-left: none;
@@ -166,7 +166,7 @@ export class SuperlativesPanel {
const tabInner = document.createElement('div');
tabInner.style.cssText = `
color: #e0d0b0;
font-size: 10px;
font-size: 20px;
`;
tabInner.textContent = 'S';
this.tab.appendChild(tabInner);
@@ -185,7 +185,7 @@ export class SuperlativesPanel {
this.container.appendChild(this.tab);
// Start collapsed — shift left by panel width
this.container.style.transform = 'translateY(-50%) translateX(-240px)';
this.container.style.transform = 'translateY(-50%) translateX(-340px)';
document.body.appendChild(this.container);
}
@@ -206,7 +206,7 @@ export class SuperlativesPanel {
collapse(): void {
this.expanded = false;
this.container.style.transform = 'translateY(-50%) translateX(-240px)';
this.container.style.transform = 'translateY(-50%) translateX(-340px)';
document.dispatchEvent(new CustomEvent('superlatives-panel-closed'));
}
@@ -0,0 +1,120 @@
# LLM Integration Roadmap
**Goal:** Integrate LLM capabilities (via OpenRouter.ai) into the NPC simulation to create emergent storytelling that pushes beyond what Dwarf Fortress achieves with template-driven text.
**Model:** `arcee-ai/trinity-large-preview:free` (131k context, free tier via OpenRouter)
---
## Tier 1: Foundation + Quick Wins (Low Infrastructure)
### 1.1 OpenRouter API Client + Prompt Template System
- [x] OpenRouter API client with rate limiting and error handling
- [x] Prompt template system with variable substitution
- [x] Async generation queue (don't block game ticks)
- [x] Server-side `.env` config for API key
- [x] **Status:** COMPLETE
### 1.2 NPC Backstory Generation
- [x] Generate 1-2 sentence backstory at spawn using stats as context
- [x] Store backstory as new ECS component
- [x] Surface backstory in NPC info panel (client)
- [x] **Status:** COMPLETE
### 1.3 Social Interaction Narration
- [ ] When social outcome fires, queue LLM narration request
- [ ] Use NPC stats, relationship tier, and recent history as prompt context
- [ ] Deliver narration text to client for display
- [ ] **Status:** NOT STARTED
### 1.4 NPC Inner Monologue / Thought Bubbles
- [ ] Periodically generate thoughts based on needs, relationships, recent events
- [ ] Display in NPC info panel and/or as floating text
- [ ] Prioritize generation for NPCs the player is following
- [ ] **Status:** NOT STARTED
---
## Tier 2: New Systems Required (Medium Infrastructure)
### 2.1 NPC Event Memory System
- [ ] Per-NPC event log (last N significant events)
- [ ] Events: social outcomes, need crises, goal changes, relationship tier changes
- [ ] Memory feeds into prompt context for all LLM features
- [ ] **Status:** NOT STARTED
### 2.2 LLM-Driven Goal Selection
- [ ] Expanded action/behavior system (craft, explore, seek company, avoid, investigate)
- [ ] Location/POI system (named places worth visiting)
- [ ] LLM picks from structured action menu based on personality + context
- [ ] **Status:** NOT STARTED
### 2.3 Emergent Gossip / Information Spread
- [ ] NPCs share information during social interactions
- [ ] Knowledge tracking (who knows what)
- [ ] Information distortion based on stats (INT=accuracy, SOC=spread, EMP=coloring)
- [ ] **Status:** NOT STARTED
### 2.4 Dynamic Relationship Narratives
- [ ] Relationship event changelog (why did value change?)
- [ ] LLM summarizes relationship arc for info panel
- [ ] **Status:** NOT STARTED
---
## Tier 3: World-Level Integration (Higher Infrastructure)
### 3.1 World Event Generation
- [ ] World state aggregation (population mood, resource levels)
- [ ] Weather/season system
- [ ] LLM generates contextual world events from aggregate state
- [ ] Events apply global modifiers affecting all NPCs
- [ ] **Status:** NOT STARTED
### 3.2 Emergent Faction Formation
- [ ] Relationship graph cluster analysis
- [ ] LLM names and characterizes emergent groups
- [ ] Inter-group dynamics
- [ ] **Status:** NOT STARTED
### 3.3 Procedural Story Arcs
- [ ] LLM identifies interesting NPC situations
- [ ] Story arc state machine with resolution mechanics
- [ ] Consequence system
- [ ] **Status:** NOT STARTED
### 3.4 NPC Dialogue with Players
- [ ] Conversation UI
- [ ] NPC context assembly for in-character responses
- [ ] Chat history management
- [ ] **Status:** NOT STARTED
---
## Cross-Cutting Infrastructure
| Component | Built In | Used By |
|-----------|----------|---------|
| OpenRouter API client | 1.1 | All |
| Rate limiting + request queue | 1.1 | All |
| Prompt template system | 1.1 | All |
| `.env` config | 1.1 | All |
| NPC event memory | 2.1 | 1.3, 1.4, 2.2, 2.3, 2.4, 3.3 |
| Async text delivery to client | 1.3 | 1.3, 1.4, 2.4 |
| Expanded action system | 2.2 | 2.2 |
| Location/POI naming | 2.2 | 2.2, 3.1 |
| Knowledge graph | 2.3 | 2.3, 3.2 |
| World state aggregation | 3.1 | 3.1, 3.2 |
| Story state machine | 3.3 | 3.3 |
| Conversation UI | 3.4 | 3.4 |
---
## Design Principles
1. **Never block game ticks** — All LLM calls are async, queued, and non-blocking
2. **Degrade gracefully** — Game works perfectly without LLM; LLM adds flavor
3. **Cache aggressively** — Backstories are permanent, thoughts update infrequently
4. **Prioritize what players see** — Generate for followed/visible NPCs first
5. **Keep prompts compact** — Free models have rate limits; minimize token usage
6. **Structured output where possible** — For decisions (goal selection), use constrained output
@@ -0,0 +1,402 @@
# NPC Backstory Generation Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Generate a 1-2 sentence backstory for each NPC at spawn time using the LLM service, store it as an ECS component, and display it in the NPC info panel.
**Architecture:** Backstory generation is async and fire-and-forget — the NPC spawns immediately with an empty backstory, and the LLM fills it in when the response arrives. A helper function formats NPC stats into a prompt-friendly string. The backstory flows through the existing EntityState wire protocol to the client's NPC info panel.
**Tech Stack:** LLM service (server/src/llm/), shared types, Phaser client UI
---
### Task 1: Add backstory field to shared types
**Files:**
- Modify: `shared/src/types.ts`
**Step 1: Add backstory to EntityState**
In `shared/src/types.ts`, add an optional `backstory` field to the `EntityState` interface (after `name`):
```typescript
// In EntityState interface, after line 117 (name field):
backstory?: string;
```
**Step 2: Rebuild shared types**
Run: `npx -w shared tsc`
Expected: compiles without errors
**Step 3: Commit**
```bash
git add shared/src/types.ts
git commit -m "feat: add backstory field to EntityState wire protocol"
```
---
### Task 2: Add backstory component to spawner and serializer
**Files:**
- Modify: `server/src/game/spawner.ts`
- Modify: `server/src/network/stateSerializer.ts`
- Modify: `server/src/game/__tests__/spawner.test.ts`
**Step 1: Add test for backstory component in spawner**
Add to the existing "creates all required components" test in `server/src/game/__tests__/spawner.test.ts`:
```typescript
// Inside the 'creates all required components' test, add:
expect(world.getComponent(entity, 'backstory')).toBe('');
```
**Step 2: Run test to verify it fails**
Run: `npm -w server run test -- --run src/game/__tests__/spawner.test.ts`
Expected: FAIL — backstory component not found
**Step 3: Add backstory component to spawner**
In `server/src/game/spawner.ts`, add after the relationships line (line 46):
```typescript
world.addComponent<string>(entity, 'backstory', '');
```
**Step 4: Add backstory to serializer**
In `server/src/network/stateSerializer.ts`, add inside the return object of `serializeEntity()` (after line 61, the `name` field):
```typescript
backstory: world.getComponent<string>(entityId, 'backstory') || undefined,
```
**Step 5: Run tests to verify they pass**
Run: `npm -w server run test`
Expected: All tests PASS
**Step 6: Commit**
```bash
git add server/src/game/spawner.ts server/src/network/stateSerializer.ts server/src/game/__tests__/spawner.test.ts
git commit -m "feat: add backstory ECS component and wire protocol serialization"
```
---
### Task 3: Create backstory generation helper
**Files:**
- Create: `server/src/llm/backstoryGenerator.ts`
- Test: `server/src/llm/__tests__/backstoryGenerator.test.ts`
**Step 1: Write the failing test**
```typescript
// server/src/llm/__tests__/backstoryGenerator.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { World } from '../../ecs/World.js';
import { formatStatsForPrompt, generateBackstory } from '../backstoryGenerator.js';
import type { Stats } from '@dflike/shared';
import type { LlmService } from '../llmService.js';
describe('formatStatsForPrompt', () => {
it('formats stats into a readable string', () => {
const stats: Stats = {
strength: 15, dexterity: 12, constitution: 14,
intelligence: 8, perception: 10,
sociability: 6, courage: 16, curiosity: 11,
empathy: 7, temperament: 13,
};
const result = formatStatsForPrompt(stats);
expect(result).toBe(
'STR:15, DEX:12, CON:14, INT:8, PER:10, SOC:6, COU:16, CUR:11, EMP:7, TMP:13'
);
});
});
describe('generateBackstory', () => {
it('calls LLM service with correct template and variables', async () => {
const world = new World();
const entity = world.createEntity();
world.addComponent(entity, 'name', 'Brynn');
world.addComponent(entity, 'backstory', '');
world.addComponent<Stats>(entity, 'stats', {
strength: 15, dexterity: 12, constitution: 14,
intelligence: 8, perception: 10,
sociability: 6, courage: 16, curiosity: 11,
empathy: 7, temperament: 13,
});
const mockService: LlmService = {
generate: vi.fn().mockResolvedValue('A brave warrior from the north.'),
queueDepth: () => 0,
clear: () => {},
};
await generateBackstory(world, entity, mockService);
expect(mockService.generate).toHaveBeenCalledWith('backstory', {
npcName: 'Brynn',
stats: 'STR:15, DEX:12, CON:14, INT:8, PER:10, SOC:6, COU:16, CUR:11, EMP:7, TMP:13',
});
expect(world.getComponent<string>(entity, 'backstory')).toBe(
'A brave warrior from the north.'
);
});
it('leaves backstory empty when LLM returns null', async () => {
const world = new World();
const entity = world.createEntity();
world.addComponent(entity, 'name', 'Thom');
world.addComponent(entity, 'backstory', '');
world.addComponent<Stats>(entity, 'stats', {
strength: 10, dexterity: 10, constitution: 10,
intelligence: 10, perception: 10,
sociability: 10, courage: 10, curiosity: 10,
empathy: 10, temperament: 10,
});
const mockService: LlmService = {
generate: vi.fn().mockResolvedValue(null),
queueDepth: () => 0,
clear: () => {},
};
await generateBackstory(world, entity, mockService);
expect(world.getComponent<string>(entity, 'backstory')).toBe('');
});
it('does not crash if entity is missing components', async () => {
const world = new World();
const entity = world.createEntity();
// No name, stats, or backstory component
const mockService: LlmService = {
generate: vi.fn().mockResolvedValue('text'),
queueDepth: () => 0,
clear: () => {},
};
// Should not throw
await generateBackstory(world, entity, mockService);
expect(mockService.generate).not.toHaveBeenCalled();
});
});
```
**Step 2: Run test to verify it fails**
Run: `npm -w server run test -- --run src/llm/__tests__/backstoryGenerator.test.ts`
Expected: FAIL — module not found
**Step 3: Write minimal implementation**
```typescript
// server/src/llm/backstoryGenerator.ts
import type { Stats } from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { LlmService } from './llmService.js';
const STAT_KEYS: (keyof Stats)[] = [
'strength', 'dexterity', 'constitution', 'intelligence', 'perception',
'sociability', 'courage', 'curiosity', 'empathy', 'temperament',
];
const STAT_ABBREVS: Record<keyof Stats, string> = {
strength: 'STR', dexterity: 'DEX', constitution: 'CON',
intelligence: 'INT', perception: 'PER',
sociability: 'SOC', courage: 'COU', curiosity: 'CUR',
empathy: 'EMP', temperament: 'TMP',
};
export function formatStatsForPrompt(stats: Stats): string {
return STAT_KEYS.map(k => `${STAT_ABBREVS[k]}:${Math.floor(stats[k])}`).join(', ');
}
export async function generateBackstory(
world: World,
entityId: number,
llmService: LlmService,
): Promise<void> {
const name = world.getComponent<string>(entityId, 'name');
const stats = world.getComponent<Stats>(entityId, 'stats');
if (!name || !stats) return;
const result = await llmService.generate('backstory', {
npcName: name,
stats: formatStatsForPrompt(stats),
});
if (result) {
world.addComponent<string>(entityId, 'backstory', result);
}
}
```
**Step 4: Run test to verify it passes**
Run: `npm -w server run test -- --run src/llm/__tests__/backstoryGenerator.test.ts`
Expected: PASS (4 tests)
**Step 5: Commit**
```bash
git add server/src/llm/backstoryGenerator.ts server/src/llm/__tests__/backstoryGenerator.test.ts
git commit -m "feat: add backstory generation helper with stat formatting"
```
---
### Task 4: Trigger backstory generation on NPC spawn
**Files:**
- Modify: `server/src/game/GameLoop.ts`
- Modify: `server/src/network/SocketServer.ts`
**Step 1: Add backstory generation to GameLoop's spawn flow**
The GameLoop spawns initial NPCs in the constructor. We need to trigger backstory generation for each. Add a helper method to GameLoop:
In `server/src/game/GameLoop.ts`, add import and method:
```typescript
// Add import at top:
import { generateBackstory } from '../llm/backstoryGenerator.js';
// Add method to GameLoop class:
generateNpcBackstory(entityId: number): void {
generateBackstory(this.world, entityId, this.llmService);
}
```
Update `spawnInitialNPCs` to trigger backstory generation:
```typescript
private spawnInitialNPCs(count: number): void {
for (let i = 0; i < count; i++) {
const entity = spawnNPC(this.world, this.map);
this.generateNpcBackstory(entity);
}
}
```
**Step 2: Add backstory generation to SocketServer spawn handler**
In `server/src/network/SocketServer.ts`, update the `spawn-npc` handler (around line 73):
```typescript
// Change:
spawnNPC(world, map, target);
// To:
const npcEntity = spawnNPC(world, map, target);
this.gameLoop.generateNpcBackstory(npcEntity);
```
**Step 3: Run all tests to verify nothing breaks**
Run: `npm -w server run test`
Expected: All tests PASS (LLM is a no-op without API key, so existing tests are unaffected)
**Step 4: Commit**
```bash
git add server/src/game/GameLoop.ts server/src/network/SocketServer.ts
git commit -m "feat: trigger backstory generation on NPC spawn"
```
---
### Task 5: Display backstory in NPC info panel
**Files:**
- Modify: `client/src/ui/NpcInfoPanel.ts`
**Step 1: Add backstory element to the panel**
In `NpcInfoPanel.ts`, add a class property for the backstory element:
```typescript
// Add to class properties (around line 38):
private backstoryEl: HTMLDivElement;
```
In the constructor, create and insert the backstory element after the activity element (after line 217, before the separator):
```typescript
// Backstory text (shows below activity, before separator)
this.backstoryEl = document.createElement('div');
this.backstoryEl.style.cssText = `
font-size: 14px;
color: ${EB.textSecondary};
text-align: center;
padding: 2px 8px 4px;
font-family: 'Press Start 2P', monospace;
line-height: 1.6;
font-style: italic;
min-height: 0;
transition: min-height 0.3s ease, opacity 0.3s ease;
opacity: 0;
`;
this.statusContent.appendChild(this.backstoryEl);
```
**Step 2: Update backstory in updateInfo()**
In the `updateInfo()` method (around line 293), add after the activity update:
```typescript
// Update backstory
const backstory = entity.backstory;
if (backstory && backstory.length > 0) {
this.backstoryEl.textContent = `"${backstory}"`;
this.backstoryEl.style.opacity = '1';
this.backstoryEl.style.minHeight = '40px';
} else {
this.backstoryEl.textContent = '';
this.backstoryEl.style.opacity = '0';
this.backstoryEl.style.minHeight = '0';
}
```
**Step 3: Manual test**
1. Start server with API key: `OPENROUTER_API_KEY=your-key npm -w server run dev`
2. Start client: `npm -w client run dev`
3. Open browser, enter follow mode (TAB), cycle to an NPC
4. Wait a few seconds for backstory to generate
5. Backstory should appear below the activity label in italics
**Step 4: Commit**
```bash
git add client/src/ui/NpcInfoPanel.ts
git commit -m "feat: display NPC backstory in info panel"
```
---
## Summary
After completing all 5 tasks:
| Change | Purpose |
|--------|---------|
| `shared/src/types.ts` | Add `backstory?: string` to EntityState |
| `server/src/game/spawner.ts` | Initialize empty backstory component |
| `server/src/network/stateSerializer.ts` | Serialize backstory to wire protocol |
| `server/src/llm/backstoryGenerator.ts` | Format stats + call LLM service |
| `server/src/game/GameLoop.ts` | Trigger backstory generation on spawn |
| `server/src/network/SocketServer.ts` | Trigger backstory on player-spawned NPCs |
| `client/src/ui/NpcInfoPanel.ts` | Display backstory in follow-mode panel |
**New tests:** 4 (formatStatsForPrompt + 3 generateBackstory scenarios)
**Graceful degradation:** No API key = empty backstory = nothing shown in panel. Zero impact on existing gameplay.
@@ -0,0 +1,944 @@
# OpenRouter API Client + Prompt Template System
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build the foundation layer for all LLM features — an OpenRouter API client with rate limiting, a prompt template system, and an async generation queue that never blocks game ticks.
**Architecture:** Server-side module that wraps OpenRouter's chat completions API. A prompt template system allows defining reusable prompts with variable substitution. An async queue manages requests with rate limiting and priority. All components are pure modules (no ECS coupling) so they can be used by any system.
**Tech Stack:** Node.js fetch API (built-in), dotenv-free `.env` reading via `process.env`, vitest for tests.
---
### Task 1: Create LLM config module
**Files:**
- Create: `server/src/config/llmConfig.ts`
- Test: `server/src/config/__tests__/llmConfig.test.ts`
**Step 1: Write the failing test**
```typescript
// server/src/config/__tests__/llmConfig.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { getLlmConfig } from '../llmConfig.js';
describe('llmConfig', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('returns default config values', () => {
const config = getLlmConfig();
expect(config.model).toBe('arcee-ai/trinity-large-preview:free');
expect(config.maxTokens).toBe(200);
expect(config.temperature).toBe(0.8);
expect(config.requestsPerMinute).toBe(10);
expect(config.timeoutMs).toBe(15000);
expect(config.enabled).toBe(false);
});
it('reads API key from environment', () => {
process.env.OPENROUTER_API_KEY = 'test-key-123';
const config = getLlmConfig();
expect(config.apiKey).toBe('test-key-123');
expect(config.enabled).toBe(true);
});
it('is disabled when no API key is set', () => {
delete process.env.OPENROUTER_API_KEY;
const config = getLlmConfig();
expect(config.apiKey).toBe('');
expect(config.enabled).toBe(false);
});
it('allows model override via environment', () => {
process.env.OPENROUTER_API_KEY = 'key';
process.env.LLM_MODEL = 'custom/model:free';
const config = getLlmConfig();
expect(config.model).toBe('custom/model:free');
});
});
```
**Step 2: Run test to verify it fails**
Run: `npm -w server run test -- --run src/config/__tests__/llmConfig.test.ts`
Expected: FAIL — module not found
**Step 3: Write minimal implementation**
```typescript
// server/src/config/llmConfig.ts
export interface LlmConfig {
apiKey: string;
model: string;
maxTokens: number;
temperature: number;
requestsPerMinute: number;
timeoutMs: number;
enabled: boolean;
}
export function getLlmConfig(): LlmConfig {
const apiKey = process.env.OPENROUTER_API_KEY ?? '';
return {
apiKey,
model: process.env.LLM_MODEL ?? 'arcee-ai/trinity-large-preview:free',
maxTokens: 200,
temperature: 0.8,
requestsPerMinute: 10,
timeoutMs: 15000,
enabled: apiKey.length > 0,
};
}
```
**Step 4: Run test to verify it passes**
Run: `npm -w server run test -- --run src/config/__tests__/llmConfig.test.ts`
Expected: PASS (4 tests)
**Step 5: Commit**
```bash
git add server/src/config/llmConfig.ts server/src/config/__tests__/llmConfig.test.ts
git commit -m "feat(llm): add LLM config module with env-based API key"
```
---
### Task 2: Create prompt template system
**Files:**
- Create: `server/src/llm/promptTemplate.ts`
- Test: `server/src/llm/__tests__/promptTemplate.test.ts`
**Step 1: Write the failing test**
```typescript
// server/src/llm/__tests__/promptTemplate.test.ts
import { describe, it, expect } from 'vitest';
import { PromptTemplate, renderTemplate } from '../promptTemplate.js';
describe('promptTemplate', () => {
it('renders a template with variable substitution', () => {
const template: PromptTemplate = {
name: 'test',
systemPrompt: 'You are a narrator for {{setting}}.',
userPrompt: '{{npcName}} is feeling {{mood}}. Describe their thoughts.',
};
const result = renderTemplate(template, {
setting: 'a medieval village',
npcName: 'Elara',
mood: 'anxious',
});
expect(result.system).toBe('You are a narrator for a medieval village.');
expect(result.user).toBe('Elara is feeling anxious. Describe their thoughts.');
});
it('leaves unmatched variables as-is', () => {
const template: PromptTemplate = {
name: 'test',
systemPrompt: 'Hello {{name}}, welcome to {{place}}.',
userPrompt: '',
};
const result = renderTemplate(template, { name: 'Brynn' });
expect(result.system).toBe('Hello Brynn, welcome to {{place}}.');
});
it('handles empty variables object', () => {
const template: PromptTemplate = {
name: 'test',
systemPrompt: 'No vars here.',
userPrompt: 'Also none.',
};
const result = renderTemplate(template, {});
expect(result.system).toBe('No vars here.');
expect(result.user).toBe('Also none.');
});
it('replaces multiple occurrences of same variable', () => {
const template: PromptTemplate = {
name: 'test',
systemPrompt: '{{name}} meets {{name}}.',
userPrompt: '',
};
const result = renderTemplate(template, { name: 'Thom' });
expect(result.system).toBe('Thom meets Thom.');
});
it('handles multiline templates', () => {
const template: PromptTemplate = {
name: 'test',
systemPrompt: 'Line 1: {{a}}\nLine 2: {{b}}',
userPrompt: '{{c}}',
};
const result = renderTemplate(template, { a: 'x', b: 'y', c: 'z' });
expect(result.system).toBe('Line 1: x\nLine 2: y');
expect(result.user).toBe('z');
});
});
```
**Step 2: Run test to verify it fails**
Run: `npm -w server run test -- --run src/llm/__tests__/promptTemplate.test.ts`
Expected: FAIL — module not found
**Step 3: Write minimal implementation**
```typescript
// server/src/llm/promptTemplate.ts
export interface PromptTemplate {
name: string;
systemPrompt: string;
userPrompt: string;
}
export interface RenderedPrompt {
system: string;
user: string;
}
export function renderTemplate(
template: PromptTemplate,
variables: Record<string, string>,
): RenderedPrompt {
let system = template.systemPrompt;
let user = template.userPrompt;
for (const [key, value] of Object.entries(variables)) {
const pattern = `{{${key}}}`;
system = system.replaceAll(pattern, value);
user = user.replaceAll(pattern, value);
}
return { system, user };
}
```
**Step 4: Run test to verify it passes**
Run: `npm -w server run test -- --run src/llm/__tests__/promptTemplate.test.ts`
Expected: PASS (5 tests)
**Step 5: Commit**
```bash
git add server/src/llm/promptTemplate.ts server/src/llm/__tests__/promptTemplate.test.ts
git commit -m "feat(llm): add prompt template system with variable substitution"
```
---
### Task 3: Create OpenRouter API client
**Files:**
- Create: `server/src/llm/openRouterClient.ts`
- Test: `server/src/llm/__tests__/openRouterClient.test.ts`
**Step 1: Write the failing test**
```typescript
// server/src/llm/__tests__/openRouterClient.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createOpenRouterClient } from '../openRouterClient.js';
import type { LlmConfig } from '../../config/llmConfig.js';
const mockConfig: LlmConfig = {
apiKey: 'test-key',
model: 'arcee-ai/trinity-large-preview:free',
maxTokens: 200,
temperature: 0.8,
requestsPerMinute: 60,
timeoutMs: 5000,
enabled: true,
};
describe('openRouterClient', () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('sends correct request to OpenRouter API', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'Hello world' } }],
}),
});
const client = createOpenRouterClient(mockConfig);
const result = await client.complete({
system: 'You are helpful.',
user: 'Say hello.',
});
expect(result).toBe('Hello world');
expect(globalThis.fetch).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/chat/completions',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer test-key',
'Content-Type': 'application/json',
}),
}),
);
const body = JSON.parse(
(globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body,
);
expect(body.model).toBe('arcee-ai/trinity-large-preview:free');
expect(body.max_tokens).toBe(200);
expect(body.temperature).toBe(0.8);
expect(body.messages).toEqual([
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'Say hello.' },
]);
});
it('returns null on HTTP error', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
statusText: 'Too Many Requests',
});
const client = createOpenRouterClient(mockConfig);
const result = await client.complete({
system: 'sys',
user: 'usr',
});
expect(result).toBeNull();
});
it('returns null on network error', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
const client = createOpenRouterClient(mockConfig);
const result = await client.complete({
system: 'sys',
user: 'usr',
});
expect(result).toBeNull();
});
it('returns null when response has no choices', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ choices: [] }),
});
const client = createOpenRouterClient(mockConfig);
const result = await client.complete({
system: 'sys',
user: 'usr',
});
expect(result).toBeNull();
});
it('allows per-request maxTokens and temperature override', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'ok' } }],
}),
});
const client = createOpenRouterClient(mockConfig);
await client.complete({
system: 'sys',
user: 'usr',
maxTokens: 50,
temperature: 0.2,
});
const body = JSON.parse(
(globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body,
);
expect(body.max_tokens).toBe(50);
expect(body.temperature).toBe(0.2);
});
});
```
**Step 2: Run test to verify it fails**
Run: `npm -w server run test -- --run src/llm/__tests__/openRouterClient.test.ts`
Expected: FAIL — module not found
**Step 3: Write minimal implementation**
```typescript
// server/src/llm/openRouterClient.ts
import type { LlmConfig } from '../config/llmConfig.js';
import type { RenderedPrompt } from './promptTemplate.js';
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
export interface CompletionRequest extends RenderedPrompt {
maxTokens?: number;
temperature?: number;
}
export interface OpenRouterClient {
complete(request: CompletionRequest): Promise<string | null>;
}
export function createOpenRouterClient(config: LlmConfig): OpenRouterClient {
return {
async complete(request: CompletionRequest): Promise<string | null> {
try {
const response = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: config.model,
max_tokens: request.maxTokens ?? config.maxTokens,
temperature: request.temperature ?? config.temperature,
messages: [
{ role: 'system', content: request.system },
{ role: 'user', content: request.user },
],
}),
});
if (!response.ok) {
console.warn(`OpenRouter API error: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
const content = data?.choices?.[0]?.message?.content;
return content ?? null;
} catch (error) {
console.warn('OpenRouter request failed:', (error as Error).message);
return null;
}
},
};
}
```
**Step 4: Run test to verify it passes**
Run: `npm -w server run test -- --run src/llm/__tests__/openRouterClient.test.ts`
Expected: PASS (5 tests)
**Step 5: Commit**
```bash
git add server/src/llm/openRouterClient.ts server/src/llm/__tests__/openRouterClient.test.ts
git commit -m "feat(llm): add OpenRouter API client with error handling"
```
---
### Task 4: Create rate-limited generation queue
**Files:**
- Create: `server/src/llm/generationQueue.ts`
- Test: `server/src/llm/__tests__/generationQueue.test.ts`
**Step 1: Write the failing test**
```typescript
// server/src/llm/__tests__/generationQueue.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createGenerationQueue } from '../generationQueue.js';
import type { OpenRouterClient } from '../openRouterClient.js';
function mockClient(response: string | null = 'generated text'): OpenRouterClient {
return {
complete: vi.fn().mockResolvedValue(response),
};
}
describe('generationQueue', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('processes a queued request', async () => {
const client = mockClient('result');
const queue = createGenerationQueue(client, { requestsPerMinute: 60 });
const promise = queue.enqueue({
system: 'sys',
user: 'usr',
});
// Advance past the rate limit interval
await vi.advanceTimersByTimeAsync(100);
const result = await promise;
expect(result).toBe('result');
expect(client.complete).toHaveBeenCalledOnce();
});
it('processes requests in order', async () => {
const results: string[] = [];
const client: OpenRouterClient = {
complete: vi.fn()
.mockResolvedValueOnce('first')
.mockResolvedValueOnce('second'),
};
const queue = createGenerationQueue(client, { requestsPerMinute: 60 });
const p1 = queue.enqueue({ system: 's', user: 'u1' }).then(r => { if (r) results.push(r); });
const p2 = queue.enqueue({ system: 's', user: 'u2' }).then(r => { if (r) results.push(r); });
await vi.advanceTimersByTimeAsync(2000);
await Promise.all([p1, p2]);
expect(results).toEqual(['first', 'second']);
});
it('respects rate limiting', async () => {
const client = mockClient('ok');
// 6 per minute = 1 per 10 seconds
const queue = createGenerationQueue(client, { requestsPerMinute: 6 });
queue.enqueue({ system: 's', user: '1' });
queue.enqueue({ system: 's', user: '2' });
queue.enqueue({ system: 's', user: '3' });
// After 1 second, only 1 should have been processed
await vi.advanceTimersByTimeAsync(1000);
expect(client.complete).toHaveBeenCalledTimes(1);
// After 11 seconds, 2 should be processed
await vi.advanceTimersByTimeAsync(10000);
expect(client.complete).toHaveBeenCalledTimes(2);
});
it('returns null when client fails', async () => {
const client = mockClient(null);
const queue = createGenerationQueue(client, { requestsPerMinute: 60 });
const promise = queue.enqueue({ system: 's', user: 'u' });
await vi.advanceTimersByTimeAsync(100);
const result = await promise;
expect(result).toBeNull();
});
it('reports queue depth', async () => {
const client = mockClient('ok');
const queue = createGenerationQueue(client, { requestsPerMinute: 6 });
expect(queue.depth()).toBe(0);
queue.enqueue({ system: 's', user: '1' });
queue.enqueue({ system: 's', user: '2' });
// First processes immediately, second is queued
await vi.advanceTimersByTimeAsync(100);
expect(queue.depth()).toBe(1);
});
it('can be cleared', async () => {
const client = mockClient('ok');
const queue = createGenerationQueue(client, { requestsPerMinute: 6 });
const p1 = queue.enqueue({ system: 's', user: '1' });
const p2 = queue.enqueue({ system: 's', user: '2' });
const p3 = queue.enqueue({ system: 's', user: '3' });
// Process first
await vi.advanceTimersByTimeAsync(100);
// Clear remaining
queue.clear();
expect(queue.depth()).toBe(0);
// Cleared items resolve as null
await vi.advanceTimersByTimeAsync(30000);
expect(await p2).toBeNull();
expect(await p3).toBeNull();
});
});
```
**Step 2: Run test to verify it fails**
Run: `npm -w server run test -- --run src/llm/__tests__/generationQueue.test.ts`
Expected: FAIL — module not found
**Step 3: Write minimal implementation**
```typescript
// server/src/llm/generationQueue.ts
import type { OpenRouterClient, CompletionRequest } from './openRouterClient.js';
interface QueueItem {
request: CompletionRequest;
resolve: (value: string | null) => void;
}
export interface GenerationQueue {
enqueue(request: CompletionRequest): Promise<string | null>;
depth(): number;
clear(): void;
}
export function createGenerationQueue(
client: OpenRouterClient,
options: { requestsPerMinute: number },
): GenerationQueue {
const queue: QueueItem[] = [];
const intervalMs = Math.ceil(60000 / options.requestsPerMinute);
let processing = false;
function scheduleNext(): void {
if (processing || queue.length === 0) return;
processing = true;
const item = queue.shift()!;
client.complete(item.request).then(result => {
item.resolve(result);
processing = false;
if (queue.length > 0) {
setTimeout(() => scheduleNext(), intervalMs);
}
});
}
return {
enqueue(request: CompletionRequest): Promise<string | null> {
return new Promise(resolve => {
queue.push({ request, resolve });
scheduleNext();
});
},
depth(): number {
return queue.length;
},
clear(): void {
const remaining = queue.splice(0);
for (const item of remaining) {
item.resolve(null);
}
},
};
}
```
**Step 4: Run test to verify it passes**
Run: `npm -w server run test -- --run src/llm/__tests__/generationQueue.test.ts`
Expected: PASS (6 tests)
**Step 5: Commit**
```bash
git add server/src/llm/generationQueue.ts server/src/llm/__tests__/generationQueue.test.ts
git commit -m "feat(llm): add rate-limited generation queue"
```
---
### Task 5: Create LLM service facade + built-in prompt templates
**Files:**
- Create: `server/src/llm/llmService.ts`
- Create: `server/src/llm/templates.ts`
- Test: `server/src/llm/__tests__/llmService.test.ts`
**Step 1: Write the failing test**
```typescript
// server/src/llm/__tests__/llmService.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createLlmService } from '../llmService.js';
describe('llmService', () => {
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
originalFetch = globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.OPENROUTER_API_KEY;
});
it('returns null when LLM is disabled (no API key)', async () => {
delete process.env.OPENROUTER_API_KEY;
const service = createLlmService();
const result = await service.generate('backstory', { npcName: 'Test' });
expect(result).toBeNull();
});
it('generates text using a named template', async () => {
process.env.OPENROUTER_API_KEY = 'test-key';
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'A brave warrior from the north.' } }],
}),
});
const service = createLlmService();
const result = await service.generate('backstory', {
npcName: 'Brynn',
stats: 'STR:15, DEX:12, CON:14, INT:8, PER:10, SOC:6, COU:16, CUR:11, EMP:7, TMP:13',
});
expect(result).toBe('A brave warrior from the north.');
});
it('exposes queue depth and clear', async () => {
process.env.OPENROUTER_API_KEY = 'test-key';
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'ok' } }],
}),
});
const service = createLlmService();
expect(service.queueDepth()).toBe(0);
service.clear();
});
});
```
**Step 2: Run test to verify it fails**
Run: `npm -w server run test -- --run src/llm/__tests__/llmService.test.ts`
Expected: FAIL — module not found
**Step 3: Write templates**
```typescript
// server/src/llm/templates.ts
import type { PromptTemplate } from './promptTemplate.js';
export const templates: Record<string, PromptTemplate> = {
backstory: {
name: 'backstory',
systemPrompt:
'You are a narrator for a medieval fantasy village simulation. ' +
'Write brief, evocative NPC backstories. Keep responses to 1-2 sentences. ' +
'Do not use cliches. Ground details in daily village life.',
userPrompt:
'Generate a short backstory for an NPC named {{npcName}}.\n' +
'Their stats: {{stats}}\n' +
'The backstory should reflect their personality stats. ' +
'High sociability means outgoing, low means reclusive. ' +
'High courage means bold, low means timid. ' +
'High empathy means caring, low means self-focused. ' +
'High temperament means volatile, low means calm.',
},
socialNarration: {
name: 'socialNarration',
systemPrompt:
'You are a narrator for a medieval fantasy village simulation. ' +
'Describe NPC social interactions in 1 sentence. ' +
'Be specific and grounded. No purple prose.',
userPrompt:
'{{npc1Name}} ({{npc1Personality}}) had a {{outcome}} interaction with ' +
'{{npc2Name}} ({{npc2Personality}}). ' +
'They are currently {{relationship}} (sentiment: {{sentiment}}/100).\n' +
'Describe what happened in one vivid sentence.',
},
innerMonologue: {
name: 'innerMonologue',
systemPrompt:
'You are voicing the inner thoughts of an NPC in a medieval village simulation. ' +
'Write a single brief thought (1 sentence) in first person. ' +
'Reflect their personality and current situation. Be natural, not dramatic.',
userPrompt:
'NPC: {{npcName}} ({{personality}})\n' +
'Current state: {{currentState}}\n' +
'Recent events: {{recentEvents}}\n' +
'What is {{npcName}} thinking right now?',
},
};
```
**Step 4: Write LLM service facade**
```typescript
// server/src/llm/llmService.ts
import { getLlmConfig } from '../config/llmConfig.js';
import { createOpenRouterClient } from './openRouterClient.js';
import { createGenerationQueue } from './generationQueue.js';
import { renderTemplate } from './promptTemplate.js';
import { templates } from './templates.js';
import type { GenerationQueue } from './generationQueue.js';
export interface LlmService {
generate(templateName: string, variables: Record<string, string>): Promise<string | null>;
queueDepth(): number;
clear(): void;
}
export function createLlmService(): LlmService {
const config = getLlmConfig();
if (!config.enabled) {
return {
generate: async () => null,
queueDepth: () => 0,
clear: () => {},
};
}
const client = createOpenRouterClient(config);
const queue = createGenerationQueue(client, {
requestsPerMinute: config.requestsPerMinute,
});
return {
async generate(
templateName: string,
variables: Record<string, string>,
): Promise<string | null> {
const template = templates[templateName];
if (!template) {
console.warn(`Unknown LLM template: ${templateName}`);
return null;
}
const rendered = renderTemplate(template, variables);
return queue.enqueue(rendered);
},
queueDepth(): number {
return queue.depth();
},
clear(): void {
queue.clear();
},
};
}
```
**Step 5: Run test to verify it passes**
Run: `npm -w server run test -- --run src/llm/__tests__/llmService.test.ts`
Expected: PASS (3 tests)
**Step 6: Commit**
```bash
git add server/src/llm/llmService.ts server/src/llm/templates.ts server/src/llm/__tests__/llmService.test.ts
git commit -m "feat(llm): add LLM service facade with built-in prompt templates"
```
---
### Task 6: Wire LLM service into GameLoop
**Files:**
- Modify: `server/src/game/GameLoop.ts`
- Test: verify existing tests still pass + manual smoke test
**Step 1: Read GameLoop.ts to understand current structure**
Run: Read `server/src/game/GameLoop.ts`
**Step 2: Add LLM service initialization**
Add to GameLoop constructor (after world/map setup):
```typescript
import { createLlmService, type LlmService } from '../llm/llmService.js';
// In constructor:
this.llmService = createLlmService();
// Add property:
readonly llmService: LlmService;
```
The service is a no-op when disabled (no API key), so this is safe to add unconditionally.
**Step 3: Run all tests to verify nothing breaks**
Run: `npm -w server run test`
Expected: All 115+ tests PASS
**Step 4: Commit**
```bash
git add server/src/game/GameLoop.ts
git commit -m "feat(llm): wire LLM service into GameLoop"
```
---
### Task 7: Create server .env.example and document setup
**Files:**
- Create: `server/.env.example`
**Step 1: Create .env.example**
```bash
# server/.env.example
# OpenRouter API key — get one at https://openrouter.ai/keys
# LLM features are disabled when this is not set.
OPENROUTER_API_KEY=
# Optional: override the default model
# LLM_MODEL=arcee-ai/trinity-large-preview:free
```
**Step 2: Verify .gitignore excludes .env**
Check that `server/.env` is gitignored (it should be via standard patterns, but verify).
**Step 3: Commit**
```bash
git add server/.env.example
git commit -m "docs(llm): add .env.example for OpenRouter API key setup"
```
---
## Summary
After completing all 7 tasks you will have:
| Module | Purpose |
|--------|---------|
| `config/llmConfig.ts` | Env-based config (API key, model, rate limits) |
| `llm/promptTemplate.ts` | Template rendering with `{{variable}}` substitution |
| `llm/openRouterClient.ts` | OpenRouter API wrapper (fetch-based, error handling) |
| `llm/generationQueue.ts` | Rate-limited async queue (never blocks game ticks) |
| `llm/templates.ts` | Built-in prompt templates (backstory, social, monologue) |
| `llm/llmService.ts` | Facade: template name + variables → queued generation |
| `GameLoop.ts` | LLM service available to all systems via `gameLoop.llmService` |
**Total new tests:** ~23 tests across 4 test files
**No existing tests modified.**
**LLM is a no-op when disabled** — zero impact on existing gameplay.
+3 -2
View File
@@ -10,8 +10,9 @@ export class GameMap {
readonly height: number;
private obstacles: Set<string> = new Set();
private pois: PointOfInterest[] = [];
terrain: number[] = []; // flat array of Terrain values
decorations: number[] = []; // flat array of decoration tile indices (-1 = none)
terrain: number[] = []; // flat array of Terrain values
decorations: number[] = []; // flat array of canopy tile indices (-1 = none)
trunkDecorations: number[] = []; // flat array of trunk tile indices (-1 = none)
constructor(width = WORLD_WIDTH, height = WORLD_HEIGHT) {
this.width = width;
+13 -135
View File
@@ -2,7 +2,8 @@ import { WORLD_WIDTH, WORLD_HEIGHT, Terrain } from '@dflike/shared';
export interface GeneratedMap {
terrain: number[]; // flat array, Terrain values
decorations: number[]; // flat array, tile index for decoration layer (-1 = none)
decorations: number[]; // flat array, canopy tile index (-1 = none)
trunkDecorations: number[]; // flat array, trunk tile index (-1 = none)
obstacles: Set<string>; // "x,y" keys for non-walkable tiles
foodPositions: { x: number; y: number }[];
restPositions: { x: number; y: number }[];
@@ -22,144 +23,21 @@ function createRng(seed: number) {
export function generateMap(
width = WORLD_WIDTH,
height = WORLD_HEIGHT,
seed?: number,
_seed?: number,
): GeneratedMap {
const rng = createRng(seed ?? (Date.now() & 0xFFFFFFFF));
const terrain = new Array<number>(width * height).fill(Terrain.GRASS);
const decorations = new Array<number>(width * height).fill(-1);
const trunkDecorations = new Array<number>(width * height).fill(-1);
const obstacles = new Set<string>();
const idx = (x: number, y: number) => y * width + x;
const inBounds = (x: number, y: number) => x >= 0 && y >= 0 && x < width && y < height;
const setTerrain = (x: number, y: number, t: number) => {
if (inBounds(x, y)) terrain[idx(x, y)] = t;
};
const getTerrain = (x: number, y: number) => {
if (!inBounds(x, y)) return -1;
return terrain[idx(x, y)];
};
const foodPositions = [
{ x: Math.floor(width * 0.25), y: Math.floor(height * 0.25) },
{ x: Math.floor(width * 0.75), y: Math.floor(height * 0.75) },
];
const restPositions = [
{ x: Math.floor(width * 0.75), y: Math.floor(height * 0.25) },
{ x: Math.floor(width * 0.25), y: Math.floor(height * 0.75) },
];
// --- River ---
// Meander from top to bottom using sine + noise
const riverStartX = Math.floor(width * 0.3 + rng() * width * 0.4);
const riverFreq = 0.08 + rng() * 0.06;
const riverAmp = 6 + rng() * 6;
const riverPhase = rng() * Math.PI * 2;
const riverWidth = 2 + Math.floor(rng() * 2); // 2-3 tiles wide
for (let y = 0; y < height; y++) {
const centerX = Math.round(
riverStartX
+ Math.sin(y * riverFreq + riverPhase) * riverAmp
+ Math.sin(y * riverFreq * 2.3 + riverPhase * 1.7) * (riverAmp * 0.3)
);
for (let dx = -riverWidth; dx <= riverWidth; dx++) {
const x = centerX + dx;
// Slight randomness on edges
if (Math.abs(dx) === riverWidth && rng() < 0.3) continue;
setTerrain(x, y, Terrain.WATER);
}
}
// --- Ponds ---
const pondCount = 2 + Math.floor(rng() * 2); // 2-3 ponds
for (let p = 0; p < pondCount; p++) {
let px: number, py: number;
let attempts = 0;
// Find a spot that's grass (not already water)
do {
px = 4 + Math.floor(rng() * (width - 8));
py = 4 + Math.floor(rng() * (height - 8));
attempts++;
} while (getTerrain(px, py) === Terrain.WATER && attempts < 50);
const radius = 2 + Math.floor(rng() * 2); // radius 2-3
for (let dy = -radius - 1; dy <= radius + 1; dy++) {
for (let dx = -radius - 1; dx <= radius + 1; dx++) {
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= radius + rng() * 0.8 - 0.4) {
setTerrain(px + dx, py + dy, Terrain.WATER);
}
}
}
}
// --- Dirt patches ---
const dirtCount = 3 + Math.floor(rng() * 3); // 3-5 patches
for (let d = 0; d < dirtCount; d++) {
const dx = 3 + Math.floor(rng() * (width - 6));
const dy = 3 + Math.floor(rng() * (height - 6));
const radius = 2 + Math.floor(rng() * 3); // radius 2-4
for (let oy = -radius - 1; oy <= radius + 1; oy++) {
for (let ox = -radius - 1; ox <= radius + 1; ox++) {
const dist = Math.sqrt(ox * ox + oy * oy);
if (dist <= radius + rng() * 1.0 - 0.5) {
const tx = dx + ox;
const ty = dy + oy;
// Only overwrite grass
if (getTerrain(tx, ty) === Terrain.GRASS) {
setTerrain(tx, ty, Terrain.DIRT);
}
}
}
}
}
// --- Trees (decorations on grass tiles) ---
// Tree tile indices reference treetop.png (6 cols x 7 rows)
// Pick recognizable canopy center tiles from each of the 4 trees:
// Deciduous 1 center: (1,1) = 7 Deciduous 2 center: (4,1) = 10
// Conifer 1 center: (1,5) = 31 Conifer 2 center: (4,5) = 34
const TREE_TILES = [7, 10, 31, 34];
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
if (getTerrain(x, y) !== Terrain.GRASS) continue;
// Check if adjacent to water (trees don't grow right at water's edge)
let nearWater = false;
for (let ny = -1; ny <= 1; ny++) {
for (let nx = -1; nx <= 1; nx++) {
if (getTerrain(x + nx, y + ny) === Terrain.WATER) nearWater = true;
}
}
const chance = nearWater ? 0.02 : 0.10;
if (rng() < chance) {
decorations[idx(x, y)] = TREE_TILES[Math.floor(rng() * TREE_TILES.length)];
obstacles.add(`${x},${y}`);
}
}
}
// Mark water tiles as obstacles
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (terrain[idx(x, y)] === Terrain.WATER) {
obstacles.add(`${x},${y}`);
}
}
}
// --- Place POIs on accessible grass tiles ---
const findGrassSpot = (): { x: number; y: number } => {
let x: number, y: number;
let attempts = 0;
do {
x = 3 + Math.floor(rng() * (width - 6));
y = 3 + Math.floor(rng() * (height - 6));
attempts++;
} while (
(getTerrain(x, y) !== Terrain.GRASS || obstacles.has(`${x},${y}`))
&& attempts < 200
);
return { x, y };
};
const foodPositions = [findGrassSpot(), findGrassSpot()];
const restPositions = [findGrassSpot(), findGrassSpot()];
return { terrain, decorations, obstacles, foodPositions, restPositions };
return { terrain, decorations, trunkDecorations, obstacles, foodPositions, restPositions };
}