diff --git a/app/game-api/src/battleSim/inMemoryTransport.ts b/app/game-api/src/battleSim/inMemoryTransport.ts index 6ad068d..efec98c 100644 --- a/app/game-api/src/battleSim/inMemoryTransport.ts +++ b/app/game-api/src/battleSim/inMemoryTransport.ts @@ -4,16 +4,20 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportRes import { processBattleSimJob } from './processor.js'; export class InMemoryBattleSimTransport { - private readonly results = new Map(); + private readonly results = new Map(); - public async simulate(payload: BattleSimJobPayload): Promise { + public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise { const jobId = crypto.randomUUID(); const result = processBattleSimJob(payload); - this.results.set(jobId, result); + this.results.set(jobId, { requesterUserId, payload: result }); return { status: 'completed', jobId, payload: result }; } - public async getSimulationResult(jobId: string): Promise { - return this.results.get(jobId) ?? null; + public async getSimulationResult(jobId: string, requesterUserId: string): Promise { + const result = this.results.get(jobId); + if (!result || result.requesterUserId !== requesterUserId) { + return null; + } + return result.payload; } } diff --git a/app/game-api/src/battleSim/redisTransport.ts b/app/game-api/src/battleSim/redisTransport.ts index db7436e..295fcd2 100644 --- a/app/game-api/src/battleSim/redisTransport.ts +++ b/app/game-api/src/battleSim/redisTransport.ts @@ -44,16 +44,16 @@ export class RedisBattleSimTransport { this.resultTtlSeconds = options.resultTtlSeconds; } - private buildResultKey(jobId: string): string { - return `${this.keys.resultKeyPrefix}${jobId}`; + private buildResultKey(jobId: string, requesterUserId: string): string { + return `${this.keys.resultKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`; } - private buildNotifyKey(jobId: string): string { - return `${this.keys.notifyKeyPrefix}${jobId}`; + private buildNotifyKey(jobId: string, requesterUserId: string): string { + return `${this.keys.notifyKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`; } - private async readResult(jobId: string): Promise { - const raw = await this.client.get(this.buildResultKey(jobId)); + private async readResult(jobId: string, requesterUserId: string): Promise { + const raw = await this.client.get(this.buildResultKey(jobId, requesterUserId)); if (!raw) { return null; } @@ -64,44 +64,49 @@ export class RedisBattleSimTransport { } } - private async waitForResult(jobId: string, timeoutMs: number): Promise { - const existing = await this.readResult(jobId); + private async waitForResult( + jobId: string, + requesterUserId: string, + timeoutMs: number + ): Promise { + const existing = await this.readResult(jobId, requesterUserId); if (existing) { return existing; } - const notifyKey = this.buildNotifyKey(jobId); + const notifyKey = this.buildNotifyKey(jobId, requesterUserId); const timeoutSec = toTimeoutSeconds(timeoutMs); const signal = await this.client.blPop(notifyKey, timeoutSec); if (!parseBlPopValue(signal)) { return null; } - return this.readResult(jobId); + return this.readResult(jobId, requesterUserId); } - public async simulate(payload: BattleSimJobPayload): Promise { + public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise { const jobId = crypto.randomUUID(); const job = { jobId, + requesterUserId, requestedAt: new Date().toISOString(), payload, }; await this.client.rPush(this.keys.queueKey, JSON.stringify(job)); - const result = await this.waitForResult(jobId, this.requestTimeoutMs); + const result = await this.waitForResult(jobId, requesterUserId, this.requestTimeoutMs); if (result) { return { status: 'completed', jobId, payload: result }; } return { status: 'queued', jobId }; } - public async getSimulationResult(jobId: string): Promise { - return this.readResult(jobId); + public async getSimulationResult(jobId: string, requesterUserId: string): Promise { + return this.readResult(jobId, requesterUserId); } - public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise { - const resultKey = this.buildResultKey(jobId); - const notifyKey = this.buildNotifyKey(jobId); + public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise { + const resultKey = this.buildResultKey(jobId, requesterUserId); + const notifyKey = this.buildNotifyKey(jobId, requesterUserId); await this.client.set(resultKey, JSON.stringify(payload), { EX: this.resultTtlSeconds, }); diff --git a/app/game-api/src/battleSim/transport.ts b/app/game-api/src/battleSim/transport.ts index cd6592a..0932b81 100644 --- a/app/game-api/src/battleSim/transport.ts +++ b/app/game-api/src/battleSim/transport.ts @@ -1,6 +1,6 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js'; export interface BattleSimTransport { - simulate(payload: BattleSimJobPayload): Promise; - getSimulationResult(jobId: string): Promise; + simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise; + getSimulationResult(jobId: string, requesterUserId: string): Promise; } diff --git a/app/game-api/src/battleSim/types.ts b/app/game-api/src/battleSim/types.ts index 6850b20..41d522b 100644 --- a/app/game-api/src/battleSim/types.ts +++ b/app/game-api/src/battleSim/types.ts @@ -134,6 +134,7 @@ export interface BattleSimResultPayload { export interface BattleSimJob { jobId: string; + requesterUserId: string; requestedAt: string; payload: BattleSimJobPayload; } diff --git a/app/game-api/src/battleSim/worker.ts b/app/game-api/src/battleSim/worker.ts index 6829051..6c5a2ba 100644 --- a/app/game-api/src/battleSim/worker.ts +++ b/app/game-api/src/battleSim/worker.ts @@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => { return result.element ?? null; }; -export const runBattleSimWorker = async (): Promise => { +export interface BattleSimWorkerOptions { + signal?: AbortSignal; +} + +export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}): Promise => { const config = resolveGameApiConfigFromEnv(); const redis = createRedisConnector(resolveRedisConfigFromEnv()); await redis.connect(); @@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise => { resultTtlSeconds: config.battleSimResultTtlSeconds, }); - const handleExit = async () => { - await redis.disconnect(); + let stopped = options.signal?.aborted ?? false; + const handleExit = () => { + stopped = true; + }; + const handleAbort = () => { + stopped = true; }; process.on('SIGINT', handleExit); process.on('SIGTERM', handleExit); + options.signal?.addEventListener('abort', handleAbort, { once: true }); - while (true) { - const item = await redis.client.blPop(keys.queueKey, 0); - const raw = parseBlPopValue(item); - if (!raw) { - continue; - } + try { + while (!stopped) { + // A finite block lets SIGTERM and test AbortSignal stop the worker without + // leaving a Redis operation or a detached lifecycle process behind. + const item = await redis.client.blPop(keys.queueKey, 1); + const raw = parseBlPopValue(item); + if (!raw) { + continue; + } - let job: BattleSimJob | null = null; - try { - job = JSON.parse(raw) as BattleSimJob; - } catch { - continue; - } + let job: BattleSimJob | null = null; + try { + job = JSON.parse(raw) as BattleSimJob; + } catch { + continue; + } - try { - const result = processBattleSimJob(job.payload); - await transport.pushResult(job.jobId, result); - } catch (error) { - const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류'; - await transport.pushResult(job.jobId, { - result: false, - reason, - }); + try { + const result = processBattleSimJob(job.payload); + await transport.pushResult(job.jobId, job.requesterUserId, result); + } catch (error) { + const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류'; + await transport.pushResult(job.jobId, job.requesterUserId, { + result: false, + reason, + }); + } } + } finally { + process.off('SIGINT', handleExit); + process.off('SIGTERM', handleExit); + options.signal?.removeEventListener('abort', handleAbort); + await redis.disconnect(); } }; diff --git a/app/game-api/src/router/battle/index.ts b/app/game-api/src/router/battle/index.ts index 172e595..f77da37 100644 --- a/app/game-api/src/router/battle/index.ts +++ b/app/game-api/src/router/battle/index.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import { getDexLevel } from '@sammo-ts/logic'; -import { authedProcedure, procedure, router } from '../../trpc.js'; +import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js'; import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js'; import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js'; import { @@ -30,6 +30,14 @@ const normalizeOptionalKey = (value: string | null): string | null => { return value; }; +const getAuthenticatedUserId = (auth: { user: { id: string } } | null): string => { + const userId = auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }); + } + return userId; +}; + const resolveExpLevel = (meta: Record, experience: number): number => { const expLevel = meta.explevel ?? meta.expLevel; if (typeof expLevel === 'number' && Number.isFinite(expLevel)) { @@ -48,7 +56,7 @@ const resolveDexValue = (meta: Record, key: string): number => }; export const battleRouter = router({ - simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => { + simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst(); if (!worldState) { throw new TRPCError({ @@ -58,10 +66,10 @@ export const battleRouter = router({ } const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id); - return ctx.battleSim.simulate(payload); + return ctx.battleSim.simulate(payload, getAuthenticatedUserId(ctx.auth)); }), - getSimulation: procedure.input(zBattleSimJobId).query(async ({ ctx, input }) => { - const result = await ctx.battleSim.getSimulationResult(input.jobId); + getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => { + const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth)); if (!result) { return { status: 'queued', jobId: input.jobId }; } diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index a8ce3e9..4a3e8dc 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -7,6 +7,21 @@ import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundar const t = initTRPC.context().create(); +const requireAuthMiddleware = t.middleware(({ ctx, next }) => { + if (!ctx.auth) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Unauthorized', + }); + } + return next({ + ctx: { + ...ctx, + auth: ctx.auth, + }, + }); +}); + const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { if (type !== 'mutation' || !ctx.db.$transaction) { return next(); @@ -46,17 +61,8 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { export const router = t.router; export const procedure = t.procedure.use(inputEventMiddleware); -export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => { - if (!ctx.auth) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'Unauthorized', - }); - } - return next({ - ctx: { - ...ctx, - auth: ctx.auth, - }, - }); -}); +export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware); + +// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과 +// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다. +export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index d0d0f49..3d7192d 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -19,19 +19,30 @@ const profile: GameProfile = { class QueuedBattleSimTransport implements BattleSimTransport { public simulateCalls = 0; public lastPayload: BattleSimJobPayload | null = null; + public lastRequesterUserId: string | null = null; + private readonly owners = new Map(); private readonly results = new Map(); - async simulate(payload: BattleSimJobPayload) { + async simulate(payload: BattleSimJobPayload, requesterUserId: string) { this.simulateCalls += 1; this.lastPayload = payload; - return { status: 'queued', jobId: 'job-1' } as const; + this.lastRequesterUserId = requesterUserId; + const jobId = `job-${this.simulateCalls}`; + this.owners.set(jobId, requesterUserId); + return { status: 'queued', jobId } as const; } - async getSimulationResult(jobId: string) { + async getSimulationResult(jobId: string, requesterUserId: string) { + if (this.owners.get(jobId) !== requesterUserId) { + return null; + } return this.results.get(jobId) ?? null; } - pushResult(jobId: string, payload: BattleSimResultPayload) { + pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload) { + if (this.owners.get(jobId) !== requesterUserId) { + throw new Error('requester mismatch'); + } this.results.set(jobId, payload); } } @@ -194,8 +205,13 @@ const buildBattleRequest = () => ({ }, }); -const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => { - const db = { +const buildContext = (options: { + state: WorldStateRow; + battleSim: BattleSimTransport; + userId?: string | null; + db?: Partial; +}): GameApiContext => { + const db = options.db ?? { worldState: { findFirst: async () => options.state, }, @@ -207,20 +223,23 @@ const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTrans }, profile.name ); - const auth: GameSessionTokenPayload = { - version: 1, - profile: profile.name, - issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), - expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), - sessionId: 'session-1', - user: { - id: 'user-1', - username: 'tester', - displayName: 'Tester', - roles: [], - }, - sanctions: {}, - }; + const auth: GameSessionTokenPayload | null = + options.userId === null + ? null + : { + version: 1, + profile: profile.name, + issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), + expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), + sessionId: 'session-1', + user: { + id: options.userId ?? 'user-1', + username: 'tester', + displayName: 'Tester', + roles: [], + }, + sanctions: {}, + }; return { db: db as unknown as DatabaseClient, turnDaemon: new InMemoryTurnDaemonTransport(), @@ -255,14 +274,204 @@ describe('battle router orchestration', () => { const response = await caller.battle.simulate(buildBattleRequest()); expect(response.status).toBe('queued'); expect(battleSim.simulateCalls).toBe(1); + expect(battleSim.lastRequesterUserId).toBe('user-1'); const queued = await caller.battle.getSimulation({ jobId: response.jobId }); expect(queued.status).toBe('queued'); - battleSim.pushResult(response.jobId, { result: true, reason: 'success', avgWar: 1 }); + battleSim.pushResult(response.jobId, 'user-1', { result: true, reason: 'success', avgWar: 1 }); const completed = await caller.battle.getSimulation({ jobId: response.jobId }); expect(completed.status).toBe('completed'); expect(completed.payload?.result).toBe(true); }); + + it('requires login, allows a user without a general, and does not open an input-event transaction', async () => { + const battleSim = new QueuedBattleSimTransport(); + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + let transactionCalls = 0; + const db = { + worldState: { findFirst: async () => state }, + $transaction: async () => { + transactionCalls += 1; + throw new Error('simulation must not create an input event transaction'); + }, + } as unknown as DatabaseClient; + + const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db })); + await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + + const noGeneralUser = appRouter.createCaller( + buildContext({ state, battleSim, userId: 'user-without-general', db }) + ); + await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({ + status: 'queued', + }); + expect(transactionCalls).toBe(0); + expect(battleSim.lastRequesterUserId).toBe('user-without-general'); + }); + + it('does not expose queued results across authenticated users', async () => { + const battleSim = new QueuedBattleSimTransport(); + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + const owner = appRouter.createCaller(buildContext({ state, battleSim, userId: 'owner-user' })); + const other = appRouter.createCaller(buildContext({ state, battleSim, userId: 'other-user' })); + const response = await owner.battle.simulate(buildBattleRequest()); + battleSim.pushResult(response.jobId, 'owner-user', { result: true, reason: 'success', avgWar: 7 }); + + await expect(owner.battle.getSimulation({ jobId: response.jobId })).resolves.toMatchObject({ + status: 'completed', + payload: { avgWar: 7 }, + }); + await expect(other.battle.getSimulation({ jobId: response.jobId })).resolves.toEqual({ + status: 'queued', + jobId: response.jobId, + }); + }); +}); + +describe('battle simulator general import permissions', () => { + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + + const buildGeneral = (overrides: Record) => ({ + id: 1, + userId: 'same-nation-user', + name: '관전자', + npcState: 0, + nationId: 1, + leadership: 70, + strength: 71, + intel: 72, + officerLevel: 1, + injury: 0, + rice: 9000, + crew: 5000, + crewTypeId: 100, + atmos: 100, + train: 100, + experience: 400, + horseCode: null, + weaponCode: null, + bookCode: null, + itemCode: null, + personalCode: null, + special2Code: null, + meta: {}, + ...overrides, + }); + + const actor = buildGeneral({ id: 1, userId: 'same-nation-user', nationId: 1 }); + const ally = buildGeneral({ + id: 2, + userId: 'ally-user', + name: '아군 장수', + nationId: 1, + officerLevel: 4, + rice: 4321, + crew: 3210, + train: 97, + atmos: 96, + horseCode: 'che_적토마', + weaponCode: 'che_의천검', + bookCode: 'che_손자병법', + itemCode: 'che_옥새', + meta: { + dex1: 10000, + rank_warnum: 33, + rank_killnum: 22, + rank_killcrew: 1111, + }, + }); + const foreignActor = buildGeneral({ id: 3, userId: 'foreign-user', nationId: 2 }); + const generals = [actor, ally, foreignActor]; + const db = { + worldState: { findFirst: async () => state }, + general: { + findFirst: async ({ where }: { where: { userId: string } }) => + generals.find((general) => general.userId === where.userId) ?? null, + findUnique: async ({ where }: { where: { id: number } }) => + generals.find((general) => general.id === where.id) ?? null, + }, + } as unknown as DatabaseClient; + + it('returns full ally details to the same nation but redacts them for another nation', async () => { + const battleSim = new QueuedBattleSimTransport(); + const sameNation = appRouter.createCaller(buildContext({ state, battleSim, userId: 'same-nation-user', db })); + const foreign = appRouter.createCaller(buildContext({ state, battleSim, userId: 'foreign-user', db })); + + const visible = await sameNation.battle.getGeneralDetail({ generalId: ally.id }); + expect(visible.general).toMatchObject({ + name: '아군 장수', + officer_level: 4, + horse: 'che_적토마', + crew: 3210, + rice: 4321, + train: 97, + atmos: 96, + warnum: 33, + killnum: 22, + killcrew: 1111, + }); + + const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id }); + expect(redacted.general).toMatchObject({ + name: '아군 장수', + officer_level: 1, + horse: null, + weapon: null, + book: null, + item: null, + crew: 0, + rice: 10000, + dex1: 0, + warnum: 0, + killnum: 0, + killcrew: 0, + }); + }); + + it('requires a game general only for server-side general import', async () => { + const caller = appRouter.createCaller( + buildContext({ + state, + battleSim: new QueuedBattleSimTransport(), + userId: 'user-without-general', + db, + }) + ); + + await expect(caller.battle.getGeneralDetail({ generalId: ally.id })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'General not found', + }); + }); }); diff --git a/app/game-api/test/battleSimTransport.test.ts b/app/game-api/test/battleSimTransport.test.ts new file mode 100644 index 0000000..1f0eb54 --- /dev/null +++ b/app/game-api/test/battleSimTransport.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js'; +import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js'; +import type { BattleSimJob, BattleSimJobPayload } from '../src/battleSim/types.js'; + +class FakeRedisClient { + readonly values = new Map(); + readonly lists = new Map(); + + async rPush(key: string, value: string): Promise { + const list = this.lists.get(key) ?? []; + list.push(value); + this.lists.set(key, list); + return list.length; + } + + async blPop(): Promise { + return null; + } + + async set(key: string, value: string): Promise<'OK'> { + this.values.set(key, value); + return 'OK'; + } + + async get(key: string): Promise { + return this.values.get(key) ?? null; + } + + async expire(): Promise { + return 1; + } +} + +describe('RedisBattleSimTransport requester isolation', () => { + it('records the requester on queued jobs and scopes completed results to that user', async () => { + const client = new FakeRedisClient(); + const keys = buildBattleSimQueueKeys('che:test'); + const transport = new RedisBattleSimTransport(client, { + keys, + requestTimeoutMs: 1, + resultTtlSeconds: 60, + }); + + const response = await transport.simulate({} as BattleSimJobPayload, 'user/one'); + expect(response.status).toBe('queued'); + + const queuedRaw = client.lists.get(keys.queueKey)?.[0]; + expect(queuedRaw).toBeTruthy(); + expect(JSON.parse(queuedRaw ?? '{}') as BattleSimJob).toMatchObject({ + jobId: response.jobId, + requesterUserId: 'user/one', + }); + + await transport.pushResult(response.jobId, 'user/one', { + result: true, + reason: 'success', + avgWar: 3, + }); + + await expect(transport.getSimulationResult(response.jobId, 'user/one')).resolves.toMatchObject({ + result: true, + avgWar: 3, + }); + await expect(transport.getSimulationResult(response.jobId, 'user/two')).resolves.toBeNull(); + expect(Array.from(client.values.keys()).some((key) => key.includes('user%2Fone'))).toBe(true); + }); +}); diff --git a/app/game-api/test/battleSimWorker.integration.test.ts b/app/game-api/test/battleSimWorker.integration.test.ts new file mode 100644 index 0000000..6a8ccf8 --- /dev/null +++ b/app/game-api/test/battleSimWorker.integration.test.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildBattleSimEnvironment } from '../src/battleSim/environment.js'; +import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js'; +import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js'; +import type { BattleSimRequestPayload } from '../src/battleSim/types.js'; +import { runBattleSimWorker } from '../src/battleSim/worker.js'; +import type { WorldStateRow } from '../src/context.js'; + +const liveDescribe = process.env.REDIS_URL ? describe : describe.skip; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +liveDescribe('battle simulator worker with live Redis', () => { + it('consumes an isolated queue, produces a result, and stops cleanly', { timeout: 30_000 }, async () => { + const scenario = `battle-sim-e2e-${randomUUID()}`; + const profileName = `che:${scenario}`; + const requesterUserId = 'worker-e2e-user'; + vi.stubEnv('PROFILE', 'che'); + vi.stubEnv('SCENARIO', scenario); + vi.stubEnv('GAME_TOKEN_SECRET', 'battle-sim-test-only'); + + const fixturePath = path.resolve( + process.cwd(), + '../../tools/integration-tests/fixtures/battle/basic-infantry.json' + ); + const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8')) as BattleSimRequestPayload & { + startYear: number; + }; + const { startYear, ...request } = fixture; + const worldState: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: request.year, + currentMonth: request.month, + tickSeconds: 600, + config: {}, + meta: { scenarioMeta: { startYear } }, + updatedAt: new Date(), + }; + const environment = await buildBattleSimEnvironment(worldState, 'che'); + const payload = { + ...request, + unitSet: environment.unitSet, + config: environment.config, + time: { year: request.year, month: request.month, startYear }, + }; + + const clientConnector = createRedisConnector(resolveRedisConfigFromEnv()); + await clientConnector.connect(); + const keys = buildBattleSimQueueKeys(profileName); + const transport = new RedisBattleSimTransport(clientConnector.client, { + keys, + requestTimeoutMs: 15_000, + resultTtlSeconds: 60, + }); + const abortController = new AbortController(); + const worker = runBattleSimWorker({ signal: abortController.signal }); + let jobId: string | null = null; + + try { + const result = await transport.simulate(payload, requesterUserId); + jobId = result.jobId; + expect(result.status).toBe('completed'); + if (result.status === 'completed') { + expect(result.payload).toMatchObject({ + result: true, + reason: 'success', + avgWar: 1, + }); + expect(result.payload.phase).toBeGreaterThan(0); + } + } finally { + abortController.abort(); + await worker; + if (jobId) { + const encodedRequester = encodeURIComponent(requesterUserId); + await clientConnector.client.del([ + keys.queueKey, + `${keys.resultKeyPrefix}${encodedRequester}:${jobId}`, + `${keys.notifyKeyPrefix}${encodedRequester}:${jobId}`, + ]); + } + await clientConnector.disconnect(); + } + }); +}); diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index e469052..4552131 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -817,6 +817,7 @@ export const adminRouter = router({ profileName: profile.profileName, apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }, })); diff --git a/app/gateway-api/src/lobby/profileStatusService.ts b/app/gateway-api/src/lobby/profileStatusService.ts index b65c2f5..9814831 100644 --- a/app/gateway-api/src/lobby/profileStatusService.ts +++ b/app/gateway-api/src/lobby/profileStatusService.ts @@ -26,6 +26,7 @@ export type LobbyProfileStatus = { runtime: { apiRunning: boolean; daemonRunning: boolean; + battleSimRunning: boolean; tournamentRunning: boolean; }; korName: string; @@ -69,7 +70,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi private mapProfile( row: GatewayProfileRecord, - runtimeMap: Map + runtimeMap: Map< + string, + { apiRunning: boolean; daemonRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean } + > ): LobbyProfileStatus { const meta = row.meta; return { @@ -81,6 +85,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi runtime: runtimeMap.get(row.profileName) ?? { apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }, korName: (meta.korName as string | undefined) ?? row.profile, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 273c083..804e411 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions { export interface ProfileRuntimeState { apiRunning: boolean; daemonRunning: boolean; + battleSimRunning: boolean; tournamentRunning: boolean; } @@ -66,13 +67,19 @@ export const planProfileReconcile = ( ): { shouldStart: boolean; shouldStop: boolean } => { if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') { return { - shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning), + shouldStart: !( + runtime.apiRunning && + runtime.daemonRunning && + runtime.battleSimRunning && + runtime.tournamentRunning + ), shouldStop: false, }; } return { shouldStart: false, - shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning, + shouldStop: + runtime.apiRunning || runtime.daemonRunning || runtime.battleSimRunning || runtime.tournamentRunning, }; }; @@ -273,8 +280,16 @@ const parseInstallOptions = ( }; }; -const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string => - `sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`; +const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'battle-sim' | 'tournament'): string => + `sammo:${profileName}:${ + role === 'api' + ? 'game-api' + : role === 'daemon' + ? 'turn-daemon' + : role === 'battle-sim' + ? 'battle-sim-worker' + : 'tournament-worker' + }`; const isMissingProcessError = (error: unknown): boolean => error instanceof Error && /process or namespace not found/i.test(error.message); @@ -285,11 +300,13 @@ export const buildProcessDefinitions = ( ): { api: { name: string; script: string; cwd: string; env: Record }; daemon: { name: string; script: string; cwd: string; env: Record }; + battleSim: { name: string; script: string; cwd: string; env: Record }; tournament: { name: string; script: string; cwd: string; env: Record }; } => { const baseEnv = { ...(config.baseEnv ?? {}) }; const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const battleSimName = buildProcessName(profile.profileName, 'battle-sim'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot; const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api'); @@ -327,6 +344,15 @@ export const buildProcessDefinitions = ( cwd: daemonCwd, env: daemonEnv, }, + battleSim: { + name: battleSimName, + script: apiScript, + cwd: apiCwd, + env: { + ...apiEnv, + GAME_API_ROLE: 'battle-sim-worker', + }, + }, tournament: { name: tournamentName, script: apiScript, @@ -376,11 +402,13 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map { const apiName = buildProcessName(profileName, 'api'); const daemonName = buildProcessName(profileName, 'daemon'); + const battleSimName = buildProcessName(profileName, 'battle-sim'); const tournamentName = buildProcessName(profileName, 'tournament'); return { profileName, apiRunning: processNames.get(apiName) ?? false, daemonRunning: processNames.get(daemonName) ?? false, + battleSimRunning: processNames.get(battleSimName) ?? false, tournamentRunning: processNames.get(tournamentName) ?? false, }; }); @@ -981,6 +1009,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { try { await this.processManager.start(definitions.api); await this.processManager.start(definitions.daemon); + await this.processManager.start(definitions.battleSim); await this.processManager.start(definitions.tournament); await this.repository.updateLastError(profile.profileName, null); return true; @@ -996,10 +1025,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async stopProfile(profile: GatewayProfileRecord): Promise { const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const battleSimName = buildProcessName(profile.profileName, 'battle-sim'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const existingNames = new Set((await this.processManager.list()).map((process) => process.name)); const failures: string[] = []; - for (const name of [apiName, daemonName, tournamentName]) { + for (const name of [apiName, daemonName, battleSimName, tournamentName]) { if (!existingNames.has(name)) { continue; } diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 7a47e10..787d20a 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -88,6 +88,7 @@ const createHarness = ( ? [ { name: 'sammo:che:2:game-api', status: 'online' }, { name: 'sammo:che:2:turn-daemon', status: 'online' }, + { name: 'sammo:che:2:battle-sim-worker', status: 'online' }, { name: 'sammo:che:2:tournament-worker', status: 'online' }, ] : [], @@ -138,7 +139,7 @@ const createHarness = ( }; describe('GatewayOrchestrator first-class operations', () => { - it('starts both profile processes and records success', async () => { + it('starts every profile process and records success', async () => { const harness = createHarness(buildOperation('START')); await harness.orchestrator.runOperationsNow(); @@ -147,12 +148,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.started.map((definition) => definition.name)).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); - it('stops both profile processes and records success', async () => { + it('stops every profile process and records success', async () => { const harness = createHarness(buildOperation('STOP')); await harness.orchestrator.runOperationsNow(); @@ -161,11 +163,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); @@ -190,6 +194,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); @@ -203,7 +208,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.completions).toEqual(['FAILED']); }); - it('attempts to stop both roles before reporting a partial PM2 failure', async () => { + it('attempts to stop every role before reporting a partial PM2 failure', async () => { const harness = createHarness(buildOperation('STOP'), false, true); await harness.orchestrator.runOperationsNow(); @@ -211,11 +216,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['FAILED']); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index ae92d15..431a840 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -29,6 +29,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: false, + battleSimRunning: true, tournamentRunning: true, }) ).toEqual({ shouldStart: true, shouldStop: false }); @@ -39,6 +40,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('PREOPEN', { apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: true, shouldStop: false }); @@ -49,6 +51,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: true, + battleSimRunning: true, tournamentRunning: true, }) ).toEqual({ shouldStart: false, shouldStop: false }); @@ -59,6 +62,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('STOPPED', { apiRunning: false, daemonRunning: true, + battleSimRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: true }); @@ -69,6 +73,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RESERVED', { apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: false }); @@ -95,6 +100,11 @@ describe('buildProcessDefinitions', () => { }); expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine')); expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js')); + expect(definitions.battleSim).toMatchObject({ + cwd: path.join(buildWorkspace, 'app', 'game-api'), + script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), + env: { GAME_API_ROLE: 'battle-sim-worker' }, + }); expect(definitions.tournament).toMatchObject({ cwd: path.join(buildWorkspace, 'app', 'game-api'), script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), @@ -107,6 +117,7 @@ describe('buildProcessDefinitions', () => { expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine')); + expect(definitions.battleSim.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index ba61af7..65d02c9 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({ profileName: 'che:2', apiRunning: runtimeRunning, daemonRunning: runtimeRunning, + battleSimRunning: runtimeRunning, tournamentRunning: runtimeRunning, }, }); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 565317c..b2c9fa6 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -70,6 +70,7 @@ type AdminProfile = { runtime: { apiRunning: boolean; daemonRunning: boolean; + battleSimRunning: boolean; tournamentRunning: boolean; }; buildCommitSha?: string; @@ -1352,7 +1353,8 @@ onMounted(() => {
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / - DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT: + DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / BATTLE SIM: + {{ profile.runtime.battleSimRunning ? 'ON' : 'OFF' }} / TOURNAMENT: {{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 9908d48..b39a048 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -16,7 +16,12 @@ type Profile = { buildWorkspace?: string; buildError?: string; lastError?: string; - runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }; + runtime: { + apiRunning: boolean; + daemonRunning: boolean; + battleSimRunning: boolean; + tournamentRunning: boolean; + }; }; type Scenario = { @@ -377,6 +382,14 @@ onBeforeUnmount(() => { {{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }} +
+
Battle sim worker
+
+ {{ selectedProfile.runtime.battleSimRunning ? 'RUNNING' : 'STOPPED' }} +
+
Tournament worker