feat(battle-sim): isolate authenticated worker lifecycle
This commit is contained in:
@@ -4,16 +4,20 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportRes
|
|||||||
import { processBattleSimJob } from './processor.js';
|
import { processBattleSimJob } from './processor.js';
|
||||||
|
|
||||||
export class InMemoryBattleSimTransport {
|
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 jobId = crypto.randomUUID();
|
||||||
const result = processBattleSimJob(payload);
|
const result = processBattleSimJob(payload);
|
||||||
this.results.set(jobId, result);
|
this.results.set(jobId, { requesterUserId, payload: result });
|
||||||
return { status: 'completed', jobId, payload: result };
|
return { status: 'completed', jobId, payload: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||||
return this.results.get(jobId) ?? null;
|
const result = this.results.get(jobId);
|
||||||
|
if (!result || result.requesterUserId !== requesterUserId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return result.payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,16 +44,16 @@ export class RedisBattleSimTransport {
|
|||||||
this.resultTtlSeconds = options.resultTtlSeconds;
|
this.resultTtlSeconds = options.resultTtlSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildResultKey(jobId: string): string {
|
private buildResultKey(jobId: string, requesterUserId: string): string {
|
||||||
return `${this.keys.resultKeyPrefix}${jobId}`;
|
return `${this.keys.resultKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildNotifyKey(jobId: string): string {
|
private buildNotifyKey(jobId: string, requesterUserId: string): string {
|
||||||
return `${this.keys.notifyKeyPrefix}${jobId}`;
|
return `${this.keys.notifyKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
private async readResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||||
const raw = await this.client.get(this.buildResultKey(jobId));
|
const raw = await this.client.get(this.buildResultKey(jobId, requesterUserId));
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -64,44 +64,49 @@ export class RedisBattleSimTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForResult(jobId: string, timeoutMs: number): Promise<BattleSimResultPayload | null> {
|
private async waitForResult(
|
||||||
const existing = await this.readResult(jobId);
|
jobId: string,
|
||||||
|
requesterUserId: string,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<BattleSimResultPayload | null> {
|
||||||
|
const existing = await this.readResult(jobId, requesterUserId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyKey = this.buildNotifyKey(jobId);
|
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
|
||||||
const timeoutSec = toTimeoutSeconds(timeoutMs);
|
const timeoutSec = toTimeoutSeconds(timeoutMs);
|
||||||
const signal = await this.client.blPop(notifyKey, timeoutSec);
|
const signal = await this.client.blPop(notifyKey, timeoutSec);
|
||||||
if (!parseBlPopValue(signal)) {
|
if (!parseBlPopValue(signal)) {
|
||||||
return null;
|
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 jobId = crypto.randomUUID();
|
||||||
const job = {
|
const job = {
|
||||||
jobId,
|
jobId,
|
||||||
|
requesterUserId,
|
||||||
requestedAt: new Date().toISOString(),
|
requestedAt: new Date().toISOString(),
|
||||||
payload,
|
payload,
|
||||||
};
|
};
|
||||||
await this.client.rPush(this.keys.queueKey, JSON.stringify(job));
|
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) {
|
if (result) {
|
||||||
return { status: 'completed', jobId, payload: result };
|
return { status: 'completed', jobId, payload: result };
|
||||||
}
|
}
|
||||||
return { status: 'queued', jobId };
|
return { status: 'queued', jobId };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null> {
|
public async getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null> {
|
||||||
return this.readResult(jobId);
|
return this.readResult(jobId, requesterUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise<void> {
|
public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise<void> {
|
||||||
const resultKey = this.buildResultKey(jobId);
|
const resultKey = this.buildResultKey(jobId, requesterUserId);
|
||||||
const notifyKey = this.buildNotifyKey(jobId);
|
const notifyKey = this.buildNotifyKey(jobId, requesterUserId);
|
||||||
await this.client.set(resultKey, JSON.stringify(payload), {
|
await this.client.set(resultKey, JSON.stringify(payload), {
|
||||||
EX: this.resultTtlSeconds,
|
EX: this.resultTtlSeconds,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
|
import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js';
|
||||||
|
|
||||||
export interface BattleSimTransport {
|
export interface BattleSimTransport {
|
||||||
simulate(payload: BattleSimJobPayload): Promise<BattleSimTransportResponse>;
|
simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise<BattleSimTransportResponse>;
|
||||||
getSimulationResult(jobId: string): Promise<BattleSimResultPayload | null>;
|
getSimulationResult(jobId: string, requesterUserId: string): Promise<BattleSimResultPayload | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export interface BattleSimResultPayload {
|
|||||||
|
|
||||||
export interface BattleSimJob {
|
export interface BattleSimJob {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
requesterUserId: string;
|
||||||
requestedAt: string;
|
requestedAt: string;
|
||||||
payload: BattleSimJobPayload;
|
payload: BattleSimJobPayload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => {
|
|||||||
return result.element ?? 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 config = resolveGameApiConfigFromEnv();
|
||||||
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
const redis = createRedisConnector(resolveRedisConfigFromEnv());
|
||||||
await redis.connect();
|
await redis.connect();
|
||||||
@@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise<void> => {
|
|||||||
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
resultTtlSeconds: config.battleSimResultTtlSeconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleExit = async () => {
|
let stopped = options.signal?.aborted ?? false;
|
||||||
await redis.disconnect();
|
const handleExit = () => {
|
||||||
|
stopped = true;
|
||||||
|
};
|
||||||
|
const handleAbort = () => {
|
||||||
|
stopped = true;
|
||||||
};
|
};
|
||||||
process.on('SIGINT', handleExit);
|
process.on('SIGINT', handleExit);
|
||||||
process.on('SIGTERM', handleExit);
|
process.on('SIGTERM', handleExit);
|
||||||
|
options.signal?.addEventListener('abort', handleAbort, { once: true });
|
||||||
|
|
||||||
while (true) {
|
try {
|
||||||
const item = await redis.client.blPop(keys.queueKey, 0);
|
while (!stopped) {
|
||||||
const raw = parseBlPopValue(item);
|
// A finite block lets SIGTERM and test AbortSignal stop the worker without
|
||||||
if (!raw) {
|
// leaving a Redis operation or a detached lifecycle process behind.
|
||||||
continue;
|
const item = await redis.client.blPop(keys.queueKey, 1);
|
||||||
}
|
const raw = parseBlPopValue(item);
|
||||||
|
if (!raw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let job: BattleSimJob | null = null;
|
let job: BattleSimJob | null = null;
|
||||||
try {
|
try {
|
||||||
job = JSON.parse(raw) as BattleSimJob;
|
job = JSON.parse(raw) as BattleSimJob;
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = processBattleSimJob(job.payload);
|
const result = processBattleSimJob(job.payload);
|
||||||
await transport.pushResult(job.jobId, result);
|
await transport.pushResult(job.jobId, job.requesterUserId, result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
|
const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류';
|
||||||
await transport.pushResult(job.jobId, {
|
await transport.pushResult(job.jobId, job.requesterUserId, {
|
||||||
result: false,
|
result: false,
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
process.off('SIGINT', handleExit);
|
||||||
|
process.off('SIGTERM', handleExit);
|
||||||
|
options.signal?.removeEventListener('abort', handleAbort);
|
||||||
|
await redis.disconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { getDexLevel } from '@sammo-ts/logic';
|
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 { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js';
|
||||||
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
|
import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,14 @@ const normalizeOptionalKey = (value: string | null): string | null => {
|
|||||||
return value;
|
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 resolveExpLevel = (meta: Record<string, unknown>, experience: number): number => {
|
||||||
const expLevel = meta.explevel ?? meta.expLevel;
|
const expLevel = meta.explevel ?? meta.expLevel;
|
||||||
if (typeof expLevel === 'number' && Number.isFinite(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({
|
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();
|
const worldState = await ctx.db.worldState.findFirst();
|
||||||
if (!worldState) {
|
if (!worldState) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -58,10 +66,10 @@ export const battleRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id);
|
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 }) => {
|
getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => {
|
||||||
const result = await ctx.battleSim.getSimulationResult(input.jobId);
|
const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth));
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return { status: 'queued', jobId: input.jobId };
|
return { status: 'queued', jobId: input.jobId };
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-14
@@ -7,6 +7,21 @@ import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundar
|
|||||||
|
|
||||||
const t = initTRPC.context<GameApiContext>().create();
|
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 }) => {
|
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
||||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||||
return next();
|
return next();
|
||||||
@@ -46,17 +61,8 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
|
|||||||
|
|
||||||
export const router = t.router;
|
export const router = t.router;
|
||||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||||
export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => {
|
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
|
||||||
if (!ctx.auth) {
|
|
||||||
throw new TRPCError({
|
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
|
||||||
code: 'UNAUTHORIZED',
|
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
|
||||||
message: 'Unauthorized',
|
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
|
||||||
});
|
|
||||||
}
|
|
||||||
return next({
|
|
||||||
ctx: {
|
|
||||||
...ctx,
|
|
||||||
auth: ctx.auth,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -19,19 +19,30 @@ const profile: GameProfile = {
|
|||||||
class QueuedBattleSimTransport implements BattleSimTransport {
|
class QueuedBattleSimTransport implements BattleSimTransport {
|
||||||
public simulateCalls = 0;
|
public simulateCalls = 0;
|
||||||
public lastPayload: BattleSimJobPayload | null = null;
|
public lastPayload: BattleSimJobPayload | null = null;
|
||||||
|
public lastRequesterUserId: string | null = null;
|
||||||
|
private readonly owners = new Map<string, string>();
|
||||||
private readonly results = new Map<string, BattleSimResultPayload>();
|
private readonly results = new Map<string, BattleSimResultPayload>();
|
||||||
|
|
||||||
async simulate(payload: BattleSimJobPayload) {
|
async simulate(payload: BattleSimJobPayload, requesterUserId: string) {
|
||||||
this.simulateCalls += 1;
|
this.simulateCalls += 1;
|
||||||
this.lastPayload = payload;
|
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;
|
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);
|
this.results.set(jobId, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,8 +205,13 @@ const buildBattleRequest = () => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => {
|
const buildContext = (options: {
|
||||||
const db = {
|
state: WorldStateRow;
|
||||||
|
battleSim: BattleSimTransport;
|
||||||
|
userId?: string | null;
|
||||||
|
db?: Partial<DatabaseClient>;
|
||||||
|
}): GameApiContext => {
|
||||||
|
const db = options.db ?? {
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => options.state,
|
findFirst: async () => options.state,
|
||||||
},
|
},
|
||||||
@@ -207,20 +223,23 @@ const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTrans
|
|||||||
},
|
},
|
||||||
profile.name
|
profile.name
|
||||||
);
|
);
|
||||||
const auth: GameSessionTokenPayload = {
|
const auth: GameSessionTokenPayload | null =
|
||||||
version: 1,
|
options.userId === null
|
||||||
profile: profile.name,
|
? null
|
||||||
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
|
: {
|
||||||
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
|
version: 1,
|
||||||
sessionId: 'session-1',
|
profile: profile.name,
|
||||||
user: {
|
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
|
||||||
id: 'user-1',
|
expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(),
|
||||||
username: 'tester',
|
sessionId: 'session-1',
|
||||||
displayName: 'Tester',
|
user: {
|
||||||
roles: [],
|
id: options.userId ?? 'user-1',
|
||||||
},
|
username: 'tester',
|
||||||
sanctions: {},
|
displayName: 'Tester',
|
||||||
};
|
roles: [],
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
db: db as unknown as DatabaseClient,
|
db: db as unknown as DatabaseClient,
|
||||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||||
@@ -255,14 +274,204 @@ describe('battle router orchestration', () => {
|
|||||||
const response = await caller.battle.simulate(buildBattleRequest());
|
const response = await caller.battle.simulate(buildBattleRequest());
|
||||||
expect(response.status).toBe('queued');
|
expect(response.status).toBe('queued');
|
||||||
expect(battleSim.simulateCalls).toBe(1);
|
expect(battleSim.simulateCalls).toBe(1);
|
||||||
|
expect(battleSim.lastRequesterUserId).toBe('user-1');
|
||||||
|
|
||||||
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
|
const queued = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||||
expect(queued.status).toBe('queued');
|
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 });
|
const completed = await caller.battle.getSimulation({ jobId: response.jobId });
|
||||||
expect(completed.status).toBe('completed');
|
expect(completed.status).toBe('completed');
|
||||||
expect(completed.payload?.result).toBe(true);
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -817,6 +817,7 @@ export const adminRouter = router({
|
|||||||
profileName: profile.profileName,
|
profileName: profile.profileName,
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type LobbyProfileStatus = {
|
|||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
korName: string;
|
korName: string;
|
||||||
@@ -69,7 +70,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
|
|
||||||
private mapProfile(
|
private mapProfile(
|
||||||
row: GatewayProfileRecord,
|
row: GatewayProfileRecord,
|
||||||
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }>
|
runtimeMap: Map<
|
||||||
|
string,
|
||||||
|
{ apiRunning: boolean; daemonRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean }
|
||||||
|
>
|
||||||
): LobbyProfileStatus {
|
): LobbyProfileStatus {
|
||||||
const meta = row.meta;
|
const meta = row.meta;
|
||||||
return {
|
return {
|
||||||
@@ -81,6 +85,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
runtime: runtimeMap.get(row.profileName) ?? {
|
runtime: runtimeMap.get(row.profileName) ?? {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
},
|
},
|
||||||
korName: (meta.korName as string | undefined) ?? row.profile,
|
korName: (meta.korName as string | undefined) ?? row.profile,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions {
|
|||||||
export interface ProfileRuntimeState {
|
export interface ProfileRuntimeState {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +67,19 @@ export const planProfileReconcile = (
|
|||||||
): { shouldStart: boolean; shouldStop: boolean } => {
|
): { shouldStart: boolean; shouldStop: boolean } => {
|
||||||
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') {
|
||||||
return {
|
return {
|
||||||
shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning),
|
shouldStart: !(
|
||||||
|
runtime.apiRunning &&
|
||||||
|
runtime.daemonRunning &&
|
||||||
|
runtime.battleSimRunning &&
|
||||||
|
runtime.tournamentRunning
|
||||||
|
),
|
||||||
shouldStop: false,
|
shouldStop: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
shouldStart: false,
|
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 =>
|
const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'battle-sim' | 'tournament'): string =>
|
||||||
`sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`;
|
`sammo:${profileName}:${
|
||||||
|
role === 'api'
|
||||||
|
? 'game-api'
|
||||||
|
: role === 'daemon'
|
||||||
|
? 'turn-daemon'
|
||||||
|
: role === 'battle-sim'
|
||||||
|
? 'battle-sim-worker'
|
||||||
|
: 'tournament-worker'
|
||||||
|
}`;
|
||||||
|
|
||||||
const isMissingProcessError = (error: unknown): boolean =>
|
const isMissingProcessError = (error: unknown): boolean =>
|
||||||
error instanceof Error && /process or namespace not found/i.test(error.message);
|
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> };
|
api: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
daemon: { 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> };
|
tournament: { name: string; script: string; cwd: string; env: Record<string, string> };
|
||||||
} => {
|
} => {
|
||||||
const baseEnv = { ...(config.baseEnv ?? {}) };
|
const baseEnv = { ...(config.baseEnv ?? {}) };
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||||
|
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
|
||||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||||
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot;
|
||||||
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api');
|
||||||
@@ -327,6 +344,15 @@ export const buildProcessDefinitions = (
|
|||||||
cwd: daemonCwd,
|
cwd: daemonCwd,
|
||||||
env: daemonEnv,
|
env: daemonEnv,
|
||||||
},
|
},
|
||||||
|
battleSim: {
|
||||||
|
name: battleSimName,
|
||||||
|
script: apiScript,
|
||||||
|
cwd: apiCwd,
|
||||||
|
env: {
|
||||||
|
...apiEnv,
|
||||||
|
GAME_API_ROLE: 'battle-sim-worker',
|
||||||
|
},
|
||||||
|
},
|
||||||
tournament: {
|
tournament: {
|
||||||
name: tournamentName,
|
name: tournamentName,
|
||||||
script: apiScript,
|
script: apiScript,
|
||||||
@@ -376,11 +402,13 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map<string, bool
|
|||||||
profileNames.map((profileName) => {
|
profileNames.map((profileName) => {
|
||||||
const apiName = buildProcessName(profileName, 'api');
|
const apiName = buildProcessName(profileName, 'api');
|
||||||
const daemonName = buildProcessName(profileName, 'daemon');
|
const daemonName = buildProcessName(profileName, 'daemon');
|
||||||
|
const battleSimName = buildProcessName(profileName, 'battle-sim');
|
||||||
const tournamentName = buildProcessName(profileName, 'tournament');
|
const tournamentName = buildProcessName(profileName, 'tournament');
|
||||||
return {
|
return {
|
||||||
profileName,
|
profileName,
|
||||||
apiRunning: processNames.get(apiName) ?? false,
|
apiRunning: processNames.get(apiName) ?? false,
|
||||||
daemonRunning: processNames.get(daemonName) ?? false,
|
daemonRunning: processNames.get(daemonName) ?? false,
|
||||||
|
battleSimRunning: processNames.get(battleSimName) ?? false,
|
||||||
tournamentRunning: processNames.get(tournamentName) ?? false,
|
tournamentRunning: processNames.get(tournamentName) ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -981,6 +1009,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
try {
|
try {
|
||||||
await this.processManager.start(definitions.api);
|
await this.processManager.start(definitions.api);
|
||||||
await this.processManager.start(definitions.daemon);
|
await this.processManager.start(definitions.daemon);
|
||||||
|
await this.processManager.start(definitions.battleSim);
|
||||||
await this.processManager.start(definitions.tournament);
|
await this.processManager.start(definitions.tournament);
|
||||||
await this.repository.updateLastError(profile.profileName, null);
|
await this.repository.updateLastError(profile.profileName, null);
|
||||||
return true;
|
return true;
|
||||||
@@ -996,10 +1025,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
|||||||
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
private async stopProfile(profile: GatewayProfileRecord): Promise<void> {
|
||||||
const apiName = buildProcessName(profile.profileName, 'api');
|
const apiName = buildProcessName(profile.profileName, 'api');
|
||||||
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
const daemonName = buildProcessName(profile.profileName, 'daemon');
|
||||||
|
const battleSimName = buildProcessName(profile.profileName, 'battle-sim');
|
||||||
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
const tournamentName = buildProcessName(profile.profileName, 'tournament');
|
||||||
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
const existingNames = new Set((await this.processManager.list()).map((process) => process.name));
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
for (const name of [apiName, daemonName, tournamentName]) {
|
for (const name of [apiName, daemonName, battleSimName, tournamentName]) {
|
||||||
if (!existingNames.has(name)) {
|
if (!existingNames.has(name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const createHarness = (
|
|||||||
? [
|
? [
|
||||||
{ name: 'sammo:che:2:game-api', status: 'online' },
|
{ name: 'sammo:che:2:game-api', status: 'online' },
|
||||||
{ name: 'sammo:che:2:turn-daemon', 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' },
|
{ name: 'sammo:che:2:tournament-worker', status: 'online' },
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
@@ -138,7 +139,7 @@ const createHarness = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('GatewayOrchestrator first-class operations', () => {
|
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'));
|
const harness = createHarness(buildOperation('START'));
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -147,12 +148,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.started.map((definition) => definition.name)).toEqual([
|
expect(harness.started.map((definition) => definition.name)).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
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'));
|
const harness = createHarness(buildOperation('STOP'));
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -161,11 +163,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.stopped).toEqual([
|
expect(harness.stopped).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
@@ -190,6 +194,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['SUCCEEDED']);
|
expect(harness.completions).toEqual(['SUCCEEDED']);
|
||||||
@@ -203,7 +208,7 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.completions).toEqual(['FAILED']);
|
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);
|
const harness = createHarness(buildOperation('STOP'), false, true);
|
||||||
|
|
||||||
await harness.orchestrator.runOperationsNow();
|
await harness.orchestrator.runOperationsNow();
|
||||||
@@ -211,11 +216,13 @@ describe('GatewayOrchestrator first-class operations', () => {
|
|||||||
expect(harness.stopped).toEqual([
|
expect(harness.stopped).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.deleted).toEqual([
|
expect(harness.deleted).toEqual([
|
||||||
'sammo:che:2:game-api',
|
'sammo:che:2:game-api',
|
||||||
'sammo:che:2:turn-daemon',
|
'sammo:che:2:turn-daemon',
|
||||||
|
'sammo:che:2:battle-sim-worker',
|
||||||
'sammo:che:2:tournament-worker',
|
'sammo:che:2:tournament-worker',
|
||||||
]);
|
]);
|
||||||
expect(harness.completions).toEqual(['FAILED']);
|
expect(harness.completions).toEqual(['FAILED']);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RUNNING', {
|
planProfileReconcile('RUNNING', {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: true,
|
||||||
tournamentRunning: true,
|
tournamentRunning: true,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: true, shouldStop: false });
|
).toEqual({ shouldStart: true, shouldStop: false });
|
||||||
@@ -39,6 +40,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('PREOPEN', {
|
planProfileReconcile('PREOPEN', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: true, shouldStop: false });
|
).toEqual({ shouldStart: true, shouldStop: false });
|
||||||
@@ -49,6 +51,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RUNNING', {
|
planProfileReconcile('RUNNING', {
|
||||||
apiRunning: true,
|
apiRunning: true,
|
||||||
daemonRunning: true,
|
daemonRunning: true,
|
||||||
|
battleSimRunning: true,
|
||||||
tournamentRunning: true,
|
tournamentRunning: true,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: false });
|
).toEqual({ shouldStart: false, shouldStop: false });
|
||||||
@@ -59,6 +62,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('STOPPED', {
|
planProfileReconcile('STOPPED', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: true,
|
daemonRunning: true,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: true });
|
).toEqual({ shouldStart: false, shouldStop: true });
|
||||||
@@ -69,6 +73,7 @@ describe('planProfileReconcile', () => {
|
|||||||
planProfileReconcile('RESERVED', {
|
planProfileReconcile('RESERVED', {
|
||||||
apiRunning: false,
|
apiRunning: false,
|
||||||
daemonRunning: false,
|
daemonRunning: false,
|
||||||
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
})
|
})
|
||||||
).toEqual({ shouldStart: false, shouldStop: 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.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine'));
|
||||||
expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js'));
|
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({
|
expect(definitions.tournament).toMatchObject({
|
||||||
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
cwd: path.join(buildWorkspace, 'app', 'game-api'),
|
||||||
script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'),
|
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.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine'));
|
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'));
|
expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({
|
|||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
apiRunning: runtimeRunning,
|
apiRunning: runtimeRunning,
|
||||||
daemonRunning: runtimeRunning,
|
daemonRunning: runtimeRunning,
|
||||||
|
battleSimRunning: runtimeRunning,
|
||||||
tournamentRunning: runtimeRunning,
|
tournamentRunning: runtimeRunning,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type AdminProfile = {
|
|||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: boolean;
|
apiRunning: boolean;
|
||||||
daemonRunning: boolean;
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
buildCommitSha?: string;
|
buildCommitSha?: string;
|
||||||
@@ -1352,7 +1353,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-zinc-400">
|
<div class="text-xs text-zinc-400">
|
||||||
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
|
상태: {{ 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' }}
|
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ type Profile = {
|
|||||||
buildWorkspace?: string;
|
buildWorkspace?: string;
|
||||||
buildError?: string;
|
buildError?: string;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean };
|
runtime: {
|
||||||
|
apiRunning: boolean;
|
||||||
|
daemonRunning: boolean;
|
||||||
|
battleSimRunning: boolean;
|
||||||
|
tournamentRunning: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type Scenario = {
|
type Scenario = {
|
||||||
@@ -377,6 +382,14 @@ onBeforeUnmount(() => {
|
|||||||
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
{{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }}
|
||||||
</div>
|
</div>
|
||||||
</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="rounded bg-zinc-950 p-3">
|
||||||
<div class="text-xs text-zinc-500">Tournament worker</div>
|
<div class="text-xs text-zinc-500">Tournament worker</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user