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:
2025-12-30 01:04:52 +00:00
parent d6c4a4ae25
commit 1688ca0d23
16 changed files with 1036 additions and 3 deletions
+11 -2
View File
@@ -7,10 +7,19 @@
"build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api",
"dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/game-api --watch",
"lint": "node -e \"console.log('lint not configured')\"",
"test": "node -e \"console.log('test not configured')\"",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"tsdown": "^0.18.3"
"tsdown": "^0.18.3",
"vite-tsconfig-paths": "^6.0.0",
"vitest": "^4.0.16"
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@sammo-ts/infra": "workspace:*",
"@trpc/server": "^11.4.4",
"fastify": "^5.3.3",
"zod": "^4.2.1"
}
}
+42
View File
@@ -0,0 +1,42 @@
export interface GameApiConfig {
host: string;
port: number;
trpcPath: string;
profile: string;
scenario: string;
profileName: string;
daemonRequestTimeoutMs: number;
}
const parseNumber = (value: string | undefined, fallback: number, label: string): number => {
if (!value) {
return fallback;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) {
throw new Error(`${label} must be a number.`);
}
return parsed;
};
export const resolveGameApiConfigFromEnv = (
env: NodeJS.ProcessEnv = process.env
): GameApiConfig => {
const profile = env.PROFILE ?? env.SERVER_PROFILE ?? 'che';
const scenario = env.SCENARIO ?? 'default';
const profileName = `${profile}:${scenario}`;
return {
host: env.GAME_API_HOST ?? '0.0.0.0',
port: parseNumber(env.GAME_API_PORT, 14000, 'GAME_API_PORT'),
trpcPath: env.TRPC_PATH ?? '/trpc',
profile,
scenario,
profileName,
daemonRequestTimeoutMs: parseNumber(
env.DAEMON_REQUEST_TIMEOUT_MS,
5000,
'DAEMON_REQUEST_TIMEOUT_MS'
),
};
};
+41
View File
@@ -0,0 +1,41 @@
import type { TurnDaemonTransport } from './daemon/transport.js';
export interface GameProfile {
id: string;
scenario: string;
name: string;
}
export interface WorldStateRow {
scenarioCode: string;
currentYear: number;
currentMonth: number;
tickSeconds: number;
config: unknown;
meta: unknown;
updatedAt: Date;
}
export interface DatabaseClient {
worldState: {
findFirst(args?: unknown): Promise<WorldStateRow | null>;
};
}
export interface GameApiContext {
db: DatabaseClient;
turnDaemon: TurnDaemonTransport;
profile: GameProfile;
}
export const createGameApiContext = (options: {
db: DatabaseClient;
turnDaemon: TurnDaemonTransport;
profile: GameProfile;
}): GameApiContext => {
return {
db: options.db,
turnDaemon: options.turnDaemon,
profile: options.profile,
};
};
@@ -0,0 +1,44 @@
import { randomUUID } from 'node:crypto';
import type {
TurnDaemonCommand,
TurnDaemonCommandEnvelope,
TurnDaemonStatus,
} from './types.js';
import type { TurnDaemonTransport } from './transport.js';
const buildDefaultStatus = (): TurnDaemonStatus => ({
state: 'idle',
running: false,
paused: false,
queueDepth: 0,
});
// 턴 데몬 통신을 메모리 큐로 흉내 내는 테스트용 전송기.
export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
public readonly commands: TurnDaemonCommandEnvelope[] = [];
private status: TurnDaemonStatus;
constructor(initialStatus: TurnDaemonStatus = buildDefaultStatus()) {
this.status = initialStatus;
}
// 테스트용: 메모리 큐에 명령을 저장하고 requestId를 반환한다.
async sendCommand(command: TurnDaemonCommand): Promise<string> {
const requestId = command.type === 'getStatus' ? command.requestId : randomUUID();
this.commands.push({
requestId,
sentAt: new Date().toISOString(),
command,
});
return requestId;
}
async requestStatus(): Promise<TurnDaemonStatus> {
return this.status;
}
setStatus(status: TurnDaemonStatus): void {
this.status = status;
}
}
+110
View File
@@ -0,0 +1,110 @@
import { randomUUID } from 'node:crypto';
import type { RedisClientType } from 'redis';
import type { TurnDaemonStreamKeys } from './streamKeys.js';
import type { TurnDaemonTransport } from './transport.js';
import type {
TurnDaemonCommand,
TurnDaemonCommandEnvelope,
TurnDaemonEventEnvelope,
TurnDaemonStatus,
} from './types.js';
interface RedisTurnDaemonTransportOptions {
keys: TurnDaemonStreamKeys;
requestTimeoutMs: number;
}
type RedisStreamReadResponse = Array<{
name: string;
messages: Array<{ id: string; message: Record<string, string> }>;
}>;
const buildCommandEnvelope = (command: TurnDaemonCommand): TurnDaemonCommandEnvelope => {
const requestId = command.type === 'getStatus' ? command.requestId : randomUUID();
return {
requestId,
sentAt: new Date().toISOString(),
command,
};
};
const parseEventEnvelope = (raw: string): TurnDaemonEventEnvelope | null => {
try {
const parsed = JSON.parse(raw) as Partial<TurnDaemonEventEnvelope>;
if (!parsed || typeof parsed !== 'object') {
return null;
}
if (!parsed.event || typeof parsed.event !== 'object') {
return null;
}
if (typeof parsed.sentAt !== 'string') {
return null;
}
return parsed as TurnDaemonEventEnvelope;
} catch {
return null;
}
};
// 턴 데몬 제어 스트림을 Redis로 구현한 전송기.
export class RedisTurnDaemonTransport implements TurnDaemonTransport {
private readonly client: RedisClientType;
private readonly keys: TurnDaemonStreamKeys;
private readonly requestTimeoutMs: number;
constructor(client: RedisClientType, options: RedisTurnDaemonTransportOptions) {
this.client = client;
this.keys = options.keys;
this.requestTimeoutMs = options.requestTimeoutMs;
}
// Redis 스트림에 명령을 기록해서 턴 데몬에게 전달한다.
async sendCommand(command: TurnDaemonCommand): Promise<string> {
const envelope = buildCommandEnvelope(command);
await this.client.xAdd(this.keys.commandStream, '*', {
payload: JSON.stringify(envelope),
});
return envelope.requestId;
}
async requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null> {
const requestId = randomUUID();
await this.sendCommand({ type: 'getStatus', requestId });
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
let lastId = '$';
while (Date.now() < deadline) {
const remaining = Math.max(1, deadline - Date.now());
const response = (await this.client.xRead(
{ key: this.keys.eventStream, id: lastId },
{ BLOCK: remaining, COUNT: 10 }
)) as RedisStreamReadResponse | null;
if (!response) {
return null;
}
for (const stream of response) {
for (const message of stream.messages) {
lastId = message.id;
const payload = message.message.payload;
if (!payload) {
continue;
}
const envelope = parseEventEnvelope(payload);
if (!envelope) {
continue;
}
if (envelope.event.type === 'status' && envelope.requestId === requestId) {
return envelope.event.status;
}
}
}
}
return null;
}
}
+11
View File
@@ -0,0 +1,11 @@
export interface TurnDaemonStreamKeys {
commandStream: string;
eventStream: string;
}
export const buildTurnDaemonStreamKeys = (profileName: string): TurnDaemonStreamKeys => {
return {
commandStream: `sammo:${profileName}:turn-daemon:commands`,
eventStream: `sammo:${profileName}:turn-daemon:events`,
};
};
+6
View File
@@ -0,0 +1,6 @@
import type { TurnDaemonCommand, TurnDaemonStatus } from './types.js';
export interface TurnDaemonTransport {
sendCommand(command: TurnDaemonCommand): Promise<string>;
requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null>;
}
+64
View File
@@ -0,0 +1,64 @@
export type TurnDaemonState = 'idle' | 'running' | 'flushing' | 'paused' | 'stopping';
export type RunReason = 'schedule' | 'manual' | 'poke';
export interface TurnRunBudget {
budgetMs: number;
maxGenerals: number;
catchUpCap: number;
}
export interface TurnCheckpoint {
turnTime: string;
generalId?: number;
year: number;
month: number;
}
export interface TurnRunResult {
lastTurnTime: string;
processedGenerals: number;
processedTurns: number;
durationMs: number;
partial: boolean;
checkpoint?: TurnCheckpoint;
}
export interface TurnDaemonStatus {
state: TurnDaemonState;
running: boolean;
paused: boolean;
lastRunAt?: string;
lastDurationMs?: number;
lastTurnTime?: string;
nextTurnTime?: string;
pendingReason?: RunReason;
queueDepth: number;
checkpoint?: TurnCheckpoint;
}
// 턴 데몬 제어 요청은 Redis 스트림으로 전달한다.
export type TurnDaemonCommand =
| { type: 'run'; reason: RunReason; targetTime?: string; budget?: TurnRunBudget }
| { type: 'pause'; reason?: string }
| { type: 'resume'; reason?: string }
| { type: 'getStatus'; requestId: string };
// 턴 데몬 이벤트는 상태/실행 결과를 API 서버에 알려준다.
export type TurnDaemonEvent =
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
| { type: 'runStarted'; at: string; reason: RunReason }
| { type: 'runCompleted'; at: string; result: TurnRunResult }
| { type: 'runFailed'; at: string; error: string };
export interface TurnDaemonCommandEnvelope {
requestId: string;
sentAt: string;
command: TurnDaemonCommand;
}
export interface TurnDaemonEventEnvelope {
requestId?: string;
sentAt: string;
event: TurnDaemonEvent;
}
+28 -1
View File
@@ -1 +1,28 @@
export {};
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runGameApiServer } from './server.js';
export * from './config.js';
export * from './context.js';
export * from './router.js';
export * from './server.js';
export * from './daemon/types.js';
export * from './daemon/streamKeys.js';
export * from './daemon/transport.js';
export * from './daemon/inMemoryTransport.js';
export * from './daemon/redisTransport.js';
const isMain = (): boolean => {
if (!process.argv[1]) {
return false;
}
return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
};
if (isMain()) {
runGameApiServer().catch((error) => {
console.error('[game-api] failed to start', error);
process.exitCode = 1;
});
}
+94
View File
@@ -0,0 +1,94 @@
import { z } from 'zod';
import type { WorldStateRow } from './context.js';
import { procedure, router } from './trpc.js';
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
const zTurnRunBudget = z.object({
budgetMs: z.number().int().positive(),
maxGenerals: z.number().int().positive(),
catchUpCap: z.number().int().positive(),
});
const toWorldStateSnapshot = (row: WorldStateRow) => ({
scenarioCode: row.scenarioCode,
currentYear: row.currentYear,
currentMonth: row.currentMonth,
tickSeconds: row.tickSeconds,
config: row.config,
meta: row.meta,
updatedAt: row.updatedAt.toISOString(),
});
export const appRouter = router({
health: router({
ping: procedure.query(({ ctx }) => ({
ok: true,
profile: ctx.profile.name,
now: new Date().toISOString(),
})),
}),
world: router({
getState: procedure.query(async ({ ctx }) => {
const state = await ctx.db.worldState.findFirst();
return state ? toWorldStateSnapshot(state) : null;
}),
}),
turnDaemon: router({
run: procedure
.input(
z.object({
reason: zRunReason,
targetTime: z.string().min(1).optional(),
budget: zTurnRunBudget.optional(),
})
)
.mutation(async ({ ctx, input }) => {
const requestId = await ctx.turnDaemon.sendCommand({
type: 'run',
reason: input.reason,
targetTime: input.targetTime,
budget: input.budget,
});
return { accepted: true, requestId };
}),
pause: procedure
.input(
z.object({
reason: z.string().min(1).optional(),
}).optional()
)
.mutation(async ({ ctx, input }) => {
const requestId = await ctx.turnDaemon.sendCommand({
type: 'pause',
reason: input?.reason,
});
return { accepted: true, requestId };
}),
resume: procedure
.input(
z.object({
reason: z.string().min(1).optional(),
}).optional()
)
.mutation(async ({ ctx, input }) => {
const requestId = await ctx.turnDaemon.sendCommand({
type: 'resume',
reason: input?.reason,
});
return { accepted: true, requestId };
}),
status: procedure
.input(
z.object({
timeoutMs: z.number().int().positive().optional(),
}).optional()
)
.query(async ({ ctx, input }) => {
return ctx.turnDaemon.requestStatus(input?.timeoutMs);
}),
}),
});
export type AppRouter = typeof appRouter;
+78
View File
@@ -0,0 +1,78 @@
import fastify from 'fastify';
import cors from '@fastify/cors';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import {
createPostgresConnector,
createRedisConnector,
resolvePostgresConfigFromEnv,
resolveRedisConfigFromEnv,
} from '@sammo-ts/infra';
import { resolveGameApiConfigFromEnv } from './config.js';
import { createGameApiContext } from './context.js';
import { buildTurnDaemonStreamKeys } from './daemon/streamKeys.js';
import { RedisTurnDaemonTransport } from './daemon/redisTransport.js';
import { appRouter } from './router.js';
export const createGameApiServer = async () => {
const config = resolveGameApiConfigFromEnv();
const postgres = createPostgresConnector(resolvePostgresConfigFromEnv());
const redis = createRedisConnector(resolveRedisConfigFromEnv());
await postgres.connect();
await redis.connect();
const turnDaemon = new RedisTurnDaemonTransport(redis.client, {
keys: buildTurnDaemonStreamKeys(config.profileName),
requestTimeoutMs: config.daemonRequestTimeoutMs,
});
const app = fastify({
logger: true,
});
await app.register(cors, {
origin: true,
credentials: true,
});
await app.register(fastifyTRPCPlugin, {
prefix: config.trpcPath,
trpcOptions: {
router: appRouter,
createContext: () =>
createGameApiContext({
db: postgres.prisma,
turnDaemon,
profile: {
id: config.profile,
scenario: config.scenario,
name: config.profileName,
},
}),
},
});
app.get('/healthz', async () => ({
ok: true,
profile: config.profileName,
}));
app.addHook('onClose', async () => {
await redis.disconnect();
await postgres.disconnect();
});
return {
app,
config,
};
};
export const runGameApiServer = async (): Promise<void> => {
const { app, config } = await createGameApiServer();
await app.listen({
host: config.host,
port: config.port,
});
};
+8
View File
@@ -0,0 +1,8 @@
import { initTRPC } from '@trpc/server';
import type { GameApiContext } from './context.js';
const t = initTRPC.context<GameApiContext>().create();
export const router = t.router;
export const procedure = t.procedure;
+75
View File
@@ -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);
});
});
+11
View File
@@ -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');
});
});
+17
View File
@@ -0,0 +1,17 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [
tsconfigPaths({
projects: [path.resolve(__dirname, '../../tsconfig.paths.json')],
}),
],
test: {
environment: 'node',
globals: true,
include: ['test/**/*.test.ts'],
},
});