feat: implement game API with TRPC and Redis transport
- Added GameApiConfig interface and configuration resolver from environment variables. - Created GameApiContext for managing database and transport dependencies. - Implemented InMemoryTurnDaemonTransport for testing purposes. - Developed RedisTurnDaemonTransport for command and status handling via Redis streams. - Defined TurnDaemonStreamKeys for namespacing Redis streams by profile. - Established TRPC router with endpoints for health check, world state retrieval, and turn daemon commands (run, pause, resume, status). - Integrated Fastify server with TRPC and Redis transport. - Added unit tests for router functionality and stream key generation. - Configured Vitest for testing environment.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameApiContext, GameProfile, WorldStateRow } from '../src/context.js';
|
||||
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const profile: GameProfile = {
|
||||
id: 'che',
|
||||
scenario: 'default',
|
||||
name: 'che:default',
|
||||
};
|
||||
|
||||
const buildContext = (options?: {
|
||||
state?: WorldStateRow | null;
|
||||
transport?: InMemoryTurnDaemonTransport;
|
||||
}): GameApiContext => {
|
||||
const transport = options?.transport ?? new InMemoryTurnDaemonTransport();
|
||||
const db = {
|
||||
worldState: {
|
||||
findFirst: async () => options?.state ?? null,
|
||||
},
|
||||
};
|
||||
return {
|
||||
db,
|
||||
turnDaemon: transport,
|
||||
profile,
|
||||
};
|
||||
};
|
||||
|
||||
describe('appRouter', () => {
|
||||
it('queues turn daemon run commands', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport();
|
||||
const caller = appRouter.createCaller(buildContext({ transport }));
|
||||
const response = await caller.turnDaemon.run({ reason: 'manual' });
|
||||
|
||||
expect(response.accepted).toBe(true);
|
||||
expect(transport.commands).toHaveLength(1);
|
||||
expect(transport.commands[0]?.command.type).toBe('run');
|
||||
expect(transport.commands[0]?.requestId).toBe(response.requestId);
|
||||
});
|
||||
|
||||
it('returns world state snapshots', async () => {
|
||||
const state: WorldStateRow = {
|
||||
scenarioCode: 'default',
|
||||
currentYear: 1,
|
||||
currentMonth: 2,
|
||||
tickSeconds: 600,
|
||||
config: { seed: 123 },
|
||||
meta: { label: 'sample' },
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
const caller = appRouter.createCaller(buildContext({ state }));
|
||||
const response = await caller.world.getState();
|
||||
|
||||
expect(response?.scenarioCode).toBe('default');
|
||||
expect(response?.currentYear).toBe(1);
|
||||
expect(response?.updatedAt).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('returns status from transport', async () => {
|
||||
const transport = new InMemoryTurnDaemonTransport({
|
||||
state: 'paused',
|
||||
running: false,
|
||||
paused: true,
|
||||
queueDepth: 2,
|
||||
});
|
||||
|
||||
const caller = appRouter.createCaller(buildContext({ transport }));
|
||||
const response = await caller.turnDaemon.status();
|
||||
|
||||
expect(response?.state).toBe('paused');
|
||||
expect(response?.queueDepth).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTurnDaemonStreamKeys } from '../src/daemon/streamKeys.js';
|
||||
|
||||
describe('buildTurnDaemonStreamKeys', () => {
|
||||
it('namespaces streams by profile', () => {
|
||||
const keys = buildTurnDaemonStreamKeys('che:default');
|
||||
expect(keys.commandStream).toBe('sammo:che:default:turn-daemon:commands');
|
||||
expect(keys.eventStream).toBe('sammo:che:default:turn-daemon:events');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user