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:
@@ -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 |
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user