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,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'
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user