- Add ensureDefaultStockpiles() to handle saves from before stockpile spawning - Switch backstory prompt stat formatting from abbreviations (STR, DEX) to full names (strength, dexterity) for better LLM comprehension - Add prior design/plan docs for LLM fallback and NPC sleep system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
27 KiB
LLM Model Fallback Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Automatically fall back from free to paid LLM model when OpenRouter returns 429, with per-model usage counters for observability.
Architecture: The openRouterClient returns structured rate-limit info on 429. The llmService owns model-switching state and usage counters. The generationQueue drops daily-limit gatekeeping but keeps per-minute throttling.
Tech Stack: TypeScript, vitest, OpenRouter REST API
Task 1: Update llmConfig to add fallbackModel and remove hardcoded default
Files:
- Modify:
server/src/config/llmConfig.ts - Modify:
server/src/config/__tests__/llmConfig.test.ts - Modify:
server/.env.example - Modify:
server/.env
Step 1: Write the failing tests
In server/src/config/__tests__/llmConfig.test.ts, update the existing "returns default config values" test and add new tests:
it('requires LLM_MODEL from environment (no hardcoded default)', () => {
delete process.env.LLM_MODEL;
const config = getLlmConfig();
expect(config.model).toBe('');
});
it('reads fallback model from environment', () => {
process.env.OPENROUTER_API_KEY = 'key';
process.env.LLM_MODEL = 'free/model:free';
process.env.LLM_FALLBACK_MODEL = 'openai/gpt-oss-120b';
const config = getLlmConfig();
expect(config.fallbackModel).toBe('openai/gpt-oss-120b');
});
it('fallbackModel is null when not set', () => {
delete process.env.LLM_FALLBACK_MODEL;
const config = getLlmConfig();
expect(config.fallbackModel).toBeNull();
});
Update the "returns default config values" test: change expect(config.model).toBe('arcee-ai/trinity-large-preview:free') to expect(config.model).toBe('') and remove the requestsPerDay assertion. Add expect(config.fallbackModel).toBeNull().
Step 2: Run tests to verify they fail
Run: npm -w server run test -- --run src/config/__tests__/llmConfig.test.ts
Expected: FAIL — fallbackModel property doesn't exist, model still has hardcoded default
Step 3: Implement the changes
In server/src/config/llmConfig.ts:
export interface LlmConfig {
apiKey: string;
model: string;
fallbackModel: string | null;
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 ?? '',
fallbackModel: process.env.LLM_FALLBACK_MODEL ?? null,
maxTokens: 200,
temperature: 0.8,
requestsPerMinute: 20,
timeoutMs: 15000,
enabled: apiKey.length > 0,
};
}
Update 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=
# Primary model (free tier)
LLM_MODEL=arcee-ai/trinity-large-preview:free
# Fallback model — used automatically when primary hits rate limit (429)
# Costs per-token. Remove or leave empty to disable fallback.
# LLM_FALLBACK_MODEL=openai/gpt-oss-120b
Update server/.env to add both model vars:
OPENROUTER_API_KEY=<existing key>
LLM_MODEL=arcee-ai/trinity-large-preview:free
LLM_FALLBACK_MODEL=openai/gpt-oss-120b
Step 4: Run tests to verify they pass
Run: npm -w server run test -- --run src/config/__tests__/llmConfig.test.ts
Expected: PASS
Step 5: Fix downstream compile errors
The mockConfig objects in server/src/llm/__tests__/openRouterClient.test.ts and any other test files referencing LlmConfig need requestsPerDay removed and fallbackModel added. Update all mockConfig objects to match the new interface (add fallbackModel: null, remove requestsPerDay if present).
Step 6: Run full test suite
Run: npm -w server run test -- --run
Expected: PASS (all 408+ tests)
Step 7: Commit
git add server/src/config/llmConfig.ts server/src/config/__tests__/llmConfig.test.ts server/.env.example server/.env server/src/llm/__tests__/openRouterClient.test.ts
git commit -m "feat(llm): add fallbackModel config, remove hardcoded model default"
Task 2: Update openRouterClient to return rate-limit info on 429
Files:
- Modify:
server/src/llm/openRouterClient.ts - Modify:
server/src/llm/__tests__/openRouterClient.test.ts
Step 1: Write the failing tests
Add to server/src/llm/__tests__/openRouterClient.test.ts:
it('returns rateLimited result with reset timestamp on 429', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
statusText: 'Too Many Requests',
json: () => Promise.resolve({
error: {
code: 429,
message: 'Rate limit exceeded',
metadata: {
headers: {
'X-RateLimit-Reset': '1741305600000',
},
},
},
}),
});
const client = createOpenRouterClient(mockConfig);
const result = await client.complete({
system: 'sys',
user: 'usr',
});
expect(result).toEqual({
rateLimited: true,
resetAt: 1741305600000,
});
});
it('returns rateLimited result with null resetAt when 429 has no reset header', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
statusText: 'Too Many Requests',
json: () => Promise.resolve({
error: { code: 429, message: 'Rate limit exceeded' },
}),
});
const client = createOpenRouterClient(mockConfig);
const result = await client.complete({
system: 'sys',
user: 'usr',
});
expect(result).toEqual({
rateLimited: true,
resetAt: null,
});
});
it('returns null on non-429 HTTP errors', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
});
const client = createOpenRouterClient(mockConfig);
const result = await client.complete({
system: 'sys',
user: 'usr',
});
expect(result).toBeNull();
});
Update the existing "returns null on HTTP error" test — it currently uses status 429 but expects null. Change it to use status 500 instead, or remove it since the new "non-429 HTTP errors" test covers it.
Step 2: Run tests to verify they fail
Run: npm -w server run test -- --run src/llm/__tests__/openRouterClient.test.ts
Expected: FAIL — current code returns null for all errors
Step 3: Implement the changes
In 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;
model?: string;
}
export interface RateLimitInfo {
rateLimited: true;
resetAt: number | null;
}
export type CompletionResult = string | null | RateLimitInfo;
export function isRateLimited(result: CompletionResult): result is RateLimitInfo {
return result != null && typeof result === 'object' && 'rateLimited' in result;
}
export interface OpenRouterClient {
complete(request: CompletionRequest): Promise<CompletionResult>;
}
export function createOpenRouterClient(config: LlmConfig): OpenRouterClient {
return {
async complete(request: CompletionRequest): Promise<CompletionResult> {
try {
const response = await fetch(OPENROUTER_URL, {
method: 'POST',
signal: AbortSignal.timeout(config.timeoutMs),
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: request.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) {
if (response.status === 429) {
try {
const data = await response.json();
const resetStr = data?.error?.metadata?.headers?.['X-RateLimit-Reset'];
const resetAt = resetStr ? Number(resetStr) : null;
return { rateLimited: true, resetAt };
} catch {
return { rateLimited: true, resetAt: null };
}
}
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;
}
},
};
}
Note: The model field on CompletionRequest allows the caller to override the model per-request. This will be used by llmService to send requests to the fallback model.
Step 4: Run tests to verify they pass
Run: npm -w server run test -- --run src/llm/__tests__/openRouterClient.test.ts
Expected: PASS
Step 5: Commit
git add server/src/llm/openRouterClient.ts server/src/llm/__tests__/openRouterClient.test.ts
git commit -m "feat(llm): return structured rate-limit info on 429 responses"
Task 3: Simplify generationQueue — remove daily limit gatekeeping
Files:
- Modify:
server/src/llm/generationQueue.ts - Modify:
server/src/llm/__tests__/generationQueue.test.ts
Step 1: Update the implementation
The queue no longer gatekeeps on daily limits. It only does per-minute throttling and passes through results (including RateLimitInfo) from the client. Remove dailyCount, dailyResetTime, getNextMidnight, checkDailyReset, isDailyLimitReached, and the queue-draining logic.
In server/src/llm/generationQueue.ts:
import type { OpenRouterClient, CompletionRequest, CompletionResult } from './openRouterClient.js';
interface QueueItem {
request: CompletionRequest;
resolve: (value: CompletionResult) => void;
}
export interface GenerationQueue {
enqueue(request: CompletionRequest): Promise<CompletionResult>;
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);
}).catch(() => {
item.resolve(null);
}).finally(() => {
processing = false;
if (queue.length > 0) {
setTimeout(() => scheduleNext(), intervalMs);
}
});
}
return {
enqueue(request: CompletionRequest): Promise<CompletionResult> {
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 2: Update the tests
Remove the following tests from server/src/llm/__tests__/generationQueue.test.ts:
- "exposes isDailyLimitReached as false initially"
- "resolves with null when daily limit is reached"
- "resets daily count at midnight"
- "works without requestsPerDay (no daily limit)"
Update remaining tests: remove requestsPerDay from options objects, remove references to isDailyLimitReached.
Add a test to verify rate-limit info passes through:
it('passes through rate limit info from client', async () => {
const rateLimitResult = { rateLimited: true as const, resetAt: 1741305600000 };
const client: OpenRouterClient = {
complete: vi.fn().mockResolvedValue(rateLimitResult),
};
const queue = createGenerationQueue(client, { requestsPerMinute: 60 });
const promise = queue.enqueue({ system: 's', user: 'u' });
await vi.advanceTimersByTimeAsync(100);
const result = await promise;
expect(result).toEqual(rateLimitResult);
});
Step 3: Run tests to verify they pass
Run: npm -w server run test -- --run src/llm/__tests__/generationQueue.test.ts
Expected: PASS
Step 4: Commit
git add server/src/llm/generationQueue.ts server/src/llm/__tests__/generationQueue.test.ts
git commit -m "refactor(llm): remove daily limit gatekeeping from generationQueue"
Task 4: Add usage counters module
Files:
- Create:
server/src/llm/usageCounters.ts - Create:
server/src/llm/__tests__/usageCounters.test.ts
Step 1: Write the failing tests
In server/src/llm/__tests__/usageCounters.test.ts:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createUsageCounters } from '../usageCounters.js';
describe('usageCounters', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('tracks requests per model', () => {
const counters = createUsageCounters();
counters.record('free/model');
counters.record('free/model');
counters.record('paid/model');
const stats = counters.getStats();
expect(stats['free/model'].total).toBe(2);
expect(stats['paid/model'].total).toBe(1);
});
it('resets daily counts at midnight UTC', () => {
// Set time to 23:59:59 UTC
const nearMidnight = new Date('2026-03-09T23:59:59Z');
vi.setSystemTime(nearMidnight);
const counters = createUsageCounters();
counters.record('free/model');
expect(counters.getStats()['free/model'].total).toBe(1);
// Advance past midnight UTC
vi.advanceTimersByTime(2000);
counters.record('free/model');
// After reset, count should be 1 (just the new one)
expect(counters.getStats()['free/model'].total).toBe(1);
});
it('returns empty stats when no requests recorded', () => {
const counters = createUsageCounters();
expect(counters.getStats()).toEqual({});
});
it('formats a summary string', () => {
const counters = createUsageCounters();
counters.record('free/model');
counters.record('free/model');
counters.record('paid/model');
const summary = counters.getSummary();
expect(summary).toContain('free/model: 2');
expect(summary).toContain('paid/model: 1');
});
});
Step 2: Run tests to verify they fail
Run: npm -w server run test -- --run src/llm/__tests__/usageCounters.test.ts
Expected: FAIL — module doesn't exist
Step 3: Implement
In server/src/llm/usageCounters.ts:
interface ModelStats {
total: number;
}
export interface UsageCounters {
record(model: string): void;
getStats(): Record<string, ModelStats>;
getSummary(): string;
}
export function createUsageCounters(): UsageCounters {
let counts = new Map<string, number>();
let resetTime = getNextMidnightUTC();
function getNextMidnightUTC(): number {
const now = new Date();
const midnight = new Date(Date.UTC(
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1,
0, 0, 0, 0,
));
return midnight.getTime();
}
function checkReset(): void {
if (Date.now() >= resetTime) {
counts = new Map();
resetTime = getNextMidnightUTC();
}
}
return {
record(model: string): void {
checkReset();
counts.set(model, (counts.get(model) ?? 0) + 1);
},
getStats(): Record<string, ModelStats> {
checkReset();
const result: Record<string, ModelStats> = {};
for (const [model, total] of counts) {
result[model] = { total };
}
return result;
},
getSummary(): string {
checkReset();
if (counts.size === 0) return 'No LLM requests today';
const parts: string[] = [];
for (const [model, total] of counts) {
parts.push(`${model}: ${total}`);
}
return `LLM usage today — ${parts.join(', ')}`;
},
};
}
Step 4: Run tests to verify they pass
Run: npm -w server run test -- --run src/llm/__tests__/usageCounters.test.ts
Expected: PASS
Step 5: Commit
git add server/src/llm/usageCounters.ts server/src/llm/__tests__/usageCounters.test.ts
git commit -m "feat(llm): add per-model usage counters for observability"
Task 5: Rewrite llmService with model switching and usage tracking
Files:
- Modify:
server/src/llm/llmService.ts - Modify:
server/src/llm/__tests__/llmService.test.ts
Step 1: Write the failing tests
Replace server/src/llm/__tests__/llmService.test.ts with:
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;
vi.useFakeTimers();
});
afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.OPENROUTER_API_KEY;
delete process.env.LLM_MODEL;
delete process.env.LLM_FALLBACK_MODEL;
vi.useRealTimers();
});
function setupEnv(opts?: { fallback?: string }) {
process.env.OPENROUTER_API_KEY = 'test-key';
process.env.LLM_MODEL = 'free/model:free';
if (opts?.fallback) {
process.env.LLM_FALLBACK_MODEL = opts.fallback;
}
}
function mockFetchOk(content: string) {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content } }],
}),
});
}
function mockFetch429(resetAt?: number) {
const metadata = resetAt != null
? { headers: { 'X-RateLimit-Reset': String(resetAt) } }
: undefined;
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 429,
statusText: 'Too Many Requests',
json: () => Promise.resolve({
error: { code: 429, message: 'Rate limit exceeded', metadata },
}),
});
}
it('returns null when LLM is disabled', 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 primary model', async () => {
setupEnv();
mockFetchOk('A brave warrior.');
const service = createLlmService();
const result = await service.generate('backstory', {
npcName: 'Brynn',
stats: 'STR:15',
});
await vi.advanceTimersByTimeAsync(100);
expect(result).toBe('A brave warrior.');
});
it('switches to fallback model on 429', async () => {
setupEnv({ fallback: 'paid/model' });
const resetAt = Date.now() + 3600000; // 1 hour from now
// First call hits 429
mockFetch429(resetAt);
const service = createLlmService();
const result1 = await service.generate('backstory', {
npcName: 'Brynn',
stats: 'STR:15',
});
await vi.advanceTimersByTimeAsync(100);
expect(result1).toBeNull(); // The 429 request itself returns null
// Second call should use fallback model
mockFetchOk('Fallback response.');
const result2 = await service.generate('backstory', {
npcName: 'Brynn',
stats: 'STR:15',
});
await vi.advanceTimersByTimeAsync(100);
expect(result2).toBe('Fallback response.');
// Verify the model sent was the fallback
const body = JSON.parse(
(globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body,
);
expect(body.model).toBe('paid/model');
});
it('switches back to primary model after reset time', async () => {
setupEnv({ fallback: 'paid/model' });
const resetAt = Date.now() + 5000; // 5 seconds from now
// Hit 429
mockFetch429(resetAt);
const service = createLlmService();
await service.generate('backstory', { npcName: 'X', stats: 'S:1' });
await vi.advanceTimersByTimeAsync(100);
expect(service.activeModel()).toBe('paid/model');
// Advance past reset time
await vi.advanceTimersByTimeAsync(6000);
expect(service.activeModel()).toBe('free/model:free');
});
it('returns null on 429 when no fallback model configured', async () => {
setupEnv(); // no fallback
mockFetch429(Date.now() + 3600000);
const service = createLlmService();
const result = await service.generate('backstory', {
npcName: 'X',
stats: 'S:1',
});
await vi.advanceTimersByTimeAsync(100);
expect(result).toBeNull();
});
it('tracks usage per model', async () => {
setupEnv({ fallback: 'paid/model' });
mockFetchOk('ok');
const service = createLlmService();
await service.generate('backstory', { npcName: 'X', stats: 'S:1' });
await vi.advanceTimersByTimeAsync(100);
const stats = service.usageStats();
expect(stats['free/model:free']?.total).toBe(1);
});
it('exposes queue depth and clear', async () => {
setupEnv();
mockFetchOk('ok');
const service = createLlmService();
expect(service.queueDepth()).toBe(0);
service.clear();
});
it('exposes activeModel', () => {
setupEnv({ fallback: 'paid/model' });
mockFetchOk('ok');
const service = createLlmService();
expect(service.activeModel()).toBe('free/model:free');
});
});
Step 2: Run tests to verify they fail
Run: npm -w server run test -- --run src/llm/__tests__/llmService.test.ts
Expected: FAIL — new methods don't exist, return type changes
Step 3: Implement
In server/src/llm/llmService.ts:
import { getLlmConfig } from '../config/llmConfig.js';
import { createOpenRouterClient, isRateLimited } from './openRouterClient.js';
import { createGenerationQueue } from './generationQueue.js';
import { createUsageCounters, type UsageCounters } from './usageCounters.js';
import { renderTemplate } from './promptTemplate.js';
import { templates } from './templates.js';
export interface LlmService {
generate(templateName: string, variables: Record<string, string>): Promise<string | null>;
queueDepth(): number;
clear(): void;
activeModel(): string;
usageStats(): Record<string, { total: number }>;
usageSummary(): string;
}
export function createLlmService(): LlmService {
const config = getLlmConfig();
if (!config.enabled || !config.model) {
return {
generate: async () => null,
queueDepth: () => 0,
clear: () => {},
activeModel: () => '',
usageStats: () => ({}),
usageSummary: () => 'LLM disabled',
};
}
const client = createOpenRouterClient(config);
const queue = createGenerationQueue(client, {
requestsPerMinute: config.requestsPerMinute,
});
const counters = createUsageCounters();
let currentModel = config.model;
let switchBackTimer: ReturnType<typeof setTimeout> | null = null;
function getNextMidnightUTC(): number {
const now = new Date();
return new Date(Date.UTC(
now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1,
0, 0, 0, 0,
)).getTime();
}
function switchToFallback(resetAt: number | null): void {
if (!config.fallbackModel) return;
currentModel = config.fallbackModel;
console.log(`[LLM] Switched to fallback model: ${currentModel}`);
const resetTime = resetAt ?? getNextMidnightUTC();
const delay = Math.max(0, resetTime - Date.now());
if (switchBackTimer) clearTimeout(switchBackTimer);
switchBackTimer = setTimeout(() => {
currentModel = config.model;
switchBackTimer = null;
console.log(`[LLM] Switched back to primary model: ${currentModel}`);
}, delay);
}
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);
const result = await queue.enqueue({ ...rendered, model: currentModel });
if (isRateLimited(result)) {
switchToFallback(result.resetAt);
return null;
}
if (typeof result === 'string') {
counters.record(currentModel);
}
return result;
},
queueDepth(): number {
return queue.depth();
},
clear(): void {
queue.clear();
},
activeModel(): string {
return currentModel;
},
usageStats(): Record<string, { total: number }> {
return counters.getStats();
},
usageSummary(): string {
return counters.getSummary();
},
};
}
Step 4: Run tests to verify they pass
Run: npm -w server run test -- --run src/llm/__tests__/llmService.test.ts
Expected: PASS
Step 5: Commit
git add server/src/llm/llmService.ts server/src/llm/__tests__/llmService.test.ts
git commit -m "feat(llm): add model switching on 429 and usage tracking to llmService"
Task 6: Fix downstream consumers — remove isDailyLimitReached references
Files:
- Modify: any files that reference
isDailyLimitReachedon LlmService
Step 1: Search for references
Search for isDailyLimitReached across the codebase. Expected locations:
server/src/game/GameLoop.tsor systems that check the daily limit- Any test files referencing the old interface
Step 2: Remove or replace references
The isDailyLimitReached method no longer exists on LlmService. Remove any checks or replace with the new API. Systems that previously checked this to skip LLM calls can simply call generate() — the service handles model switching internally.
Step 3: Run full test suite
Run: npm -w server run test -- --run
Expected: PASS (all tests)
Step 4: Commit
git add -u
git commit -m "refactor(llm): remove isDailyLimitReached from downstream consumers"
Task 7: Add periodic usage logging
Files:
- Modify:
server/src/llm/llmService.ts - Modify:
server/src/llm/__tests__/llmService.test.ts
Step 1: Add periodic logging
In createLlmService(), after creating the counters, add a logging interval. Log the usage summary every 100 requests (check in the generate method) and expose a destroy() method to clean up timers (for tests and graceful shutdown).
Add to the generate method, after counters.record(currentModel):
const totalRequests = Object.values(counters.getStats()).reduce((sum, s) => sum + s.total, 0);
if (totalRequests % 100 === 0) {
console.log(`[LLM] ${counters.getSummary()}`);
}
Add destroy() to the LlmService interface and implementation:
destroy(): void {
if (switchBackTimer) {
clearTimeout(switchBackTimer);
switchBackTimer = null;
}
}
Step 2: Run full test suite
Run: npm -w server run test -- --run
Expected: PASS
Step 3: Commit
git add server/src/llm/llmService.ts server/src/llm/__tests__/llmService.test.ts
git commit -m "feat(llm): add periodic usage logging and destroy cleanup"
Task 8: Integration — wire destroy into GameLoop shutdown
Files:
- Modify:
server/src/game/GameLoop.ts
Step 1: Add destroy call
In GameLoop.stop() (or wherever the game loop shuts down), call this.llmService.destroy() to clean up the switch-back timer.
Step 2: Run full test suite
Run: npm -w server run test -- --run
Expected: PASS
Step 3: Commit
git add server/src/game/GameLoop.ts
git commit -m "chore(llm): wire llmService destroy into GameLoop shutdown"