feat(battle-sim): isolate authenticated worker lifecycle

This commit is contained in:
2026-07-26 05:30:48 +00:00
parent b627fdf037
commit 61b540384c
18 changed files with 583 additions and 99 deletions
@@ -4,16 +4,20 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportRes
import { processBattleSimJob } from './processor.js';
export class InMemoryBattleSimTransport {
private readonly results = new Map<string, BattleSimResultPayload>();
private readonly results = new Map<string, { requesterUserId: string; payload: BattleSimResultPayload }>();
public async simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse> {
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
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<BattleSimResultPayload | null> {
return this.results.get(jobId) ?? null;
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
const result = this.results.get(jobId);
if (!result || result.requesterUserId !== requesterUserId) {
return null;
}
return result.payload;
}
}
+22 -17
View File
@@ -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<BattleSimResultPayload | null> {
const raw = await this.client.get(this.buildResultKey(jobId));
private async readResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
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<BattleSimResultPayload | null> {
const existing = await this.readResult(jobId);
private async waitForResult(
jobId: string,
requesterUserId: string,
timeoutMs: number
): Promise<BattleSimResultPayload | null> {
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<BattleSimTransportResponse> {
public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse> {
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<BattleSimResultPayload | null> {
return this.readResult(jobId);
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
return this.readResult(jobId, requesterUserId);
}
public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise<void> {
const resultKey = this.buildResultKey(jobId);
const notifyKey = this.buildNotifyKey(jobId);
public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise<void> {
const resultKey = this.buildResultKey(jobId, requesterUserId);
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
await this.client.set(resultKey, JSON.stringify(payload), {
EX: this.resultTtlSeconds,
});
+2 -2
View File
@@ -1,6 +1,6 @@
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
export interface BattleSimTransport {
simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse>;
getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null>;
simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse>;
getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null>;
}
+1
View File
@@ -134,6 +134,7 @@ export interface BattleSimResultPayload {
export interface BattleSimJob {
jobId: string;
requesterUserId: string;
requestedAt: string;
payload: BattleSimJobPayload;
}
+42 -24
View File
@@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => {
return result.element ?? null;
};
export const runBattleSimWorker = async (): Promise<void> => {
export interface BattleSimWorkerOptions {
signal?: AbortSignal;
}
export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}): Promise<void> => {
const config = resolveGameApiConfigFromEnv();
const redis = createRedisConnector(resolveRedisConfigFromEnv());
await redis.connect();
@@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise<void> => {
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();
}
};
+13 -5
View File
@@ -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<string, unknown>, experience: number): number => {
const expLevel = meta.explevel ?? meta.expLevel;
if (typeof expLevel === 'number' && Number.isFinite(expLevel)) {
@@ -48,7 +56,7 @@ const resolveDexValue = (meta: Record<string, unknown>, 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 };
}
+20 -14
View File
@@ -7,6 +7,21 @@ import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundar
const t = initTRPC.context<GameApiContext>().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);
+230 -21
View File
@@ -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<string, string>();
private readonly results = new Map<string, BattleSimResultPayload>();
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<DatabaseClient>;
}): 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<string, unknown>) => ({
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',
});
});
});
@@ -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<string, string>();
readonly lists = new Map<string, string[]>();
async rPush(key: string, value: string): Promise<number> {
const list = this.lists.get(key) ?? [];
list.push(value);
this.lists.set(key, list);
return list.length;
}
async blPop(): Promise<null> {
return null;
}
async set(key: string, value: string): Promise<'OK'> {
this.values.set(key, value);
return 'OK';
}
async get(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async expire(): Promise<number> {
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);
});
});
@@ -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();
}
});
});
+1
View File
@@ -817,6 +817,7 @@ export const adminRouter = router({
profileName: profile.profileName,
apiRunning: false,
daemonRunning: false,
battleSimRunning: false,
tournamentRunning: false,
},
}));
@@ -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<string, { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }>
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,
@@ -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<string, string> };
daemon: { name: string; script: string; cwd: string; env: Record<string, string> };
battleSim: { name: string; script: string; cwd: string; env: Record<string, string> };
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
} => {
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<string, bool
profileNames.map((profileName) => {
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<void> {
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;
}
@@ -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']);
@@ -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'));
});
});
@@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({
profileName: 'che:2',
apiRunning: runtimeRunning,
daemonRunning: runtimeRunning,
battleSimRunning: runtimeRunning,
tournamentRunning: runtimeRunning,
},
});
+3 -1
View File
@@ -70,6 +70,7 @@ type AdminProfile = {
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
};
buildCommitSha?: string;
@@ -1352,7 +1353,8 @@ onMounted(() => {
</div>
<div class="text-xs text-zinc-400">
상태: {{ 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' }}
</div>
</div>
@@ -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' }}
</div>
</div>
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">Battle sim worker</div>
<div
:class="selectedProfile.runtime.battleSimRunning ? 'text-emerald-400' : 'text-zinc-500'"
>
{{ selectedProfile.runtime.battleSimRunning ? 'RUNNING' : 'STOPPED' }}
</div>
</div>
<div class="rounded bg-zinc-950 p-3">
<div class="text-xs text-zinc-500">Tournament worker</div>
<div