From 61b540384cc8336dc30a1e229434d21f87aca24f Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 05:30:28 +0000 Subject: [PATCH 1/2] feat(battle-sim): isolate authenticated worker lifecycle --- .../src/battleSim/inMemoryTransport.ts | 14 +- app/game-api/src/battleSim/redisTransport.ts | 39 +-- app/game-api/src/battleSim/transport.ts | 4 +- app/game-api/src/battleSim/types.ts | 1 + app/game-api/src/battleSim/worker.ts | 66 +++-- app/game-api/src/router/battle/index.ts | 18 +- app/game-api/src/trpc.ts | 34 ++- app/game-api/test/battleSimRouter.test.ts | 251 ++++++++++++++++-- app/game-api/test/battleSimTransport.test.ts | 69 +++++ .../test/battleSimWorker.integration.test.ts | 94 +++++++ app/gateway-api/src/adminRouter.ts | 1 + .../src/lobby/profileStatusService.ts | 7 +- .../src/orchestrator/gatewayOrchestrator.ts | 40 ++- .../test/orchestratorOperations.test.ts | 13 +- app/gateway-api/test/orchestratorPlan.test.ts | 11 + .../e2e/server-operations.spec.ts | 1 + app/gateway-frontend/src/views/AdminView.vue | 4 +- .../src/views/ServerOperationsView.vue | 15 +- 18 files changed, 583 insertions(+), 99 deletions(-) create mode 100644 app/game-api/test/battleSimTransport.test.ts create mode 100644 app/game-api/test/battleSimWorker.integration.test.ts diff --git a/app/game-api/src/battleSim/inMemoryTransport.ts b/app/game-api/src/battleSim/inMemoryTransport.ts index 6ad068d..efec98c 100644 --- a/app/game-api/src/battleSim/inMemoryTransport.ts +++ b/app/game-api/src/battleSim/inMemoryTransport.ts @@ -4,16 +4,20 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportRes import { processBattleSimJob } from './processor.js'; export class InMemoryBattleSimTransport { - private readonly results = new Map(); + private readonly results = new Map(); - public async simulate(payload: BattleSimJobPayload): Promise { + public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise { const jobId = crypto.randomUUID(); const result = processBattleSimJob(payload); - this.results.set(jobId, result); + this.results.set(jobId, { requesterUserId, payload: result }); return { status: 'completed', jobId, payload: result }; } - public async getSimulationResult(jobId: string): Promise { - return this.results.get(jobId) ?? null; + public async getSimulationResult(jobId: string, requesterUserId: string): Promise { + const result = this.results.get(jobId); + if (!result || result.requesterUserId !== requesterUserId) { + return null; + } + return result.payload; } } diff --git a/app/game-api/src/battleSim/redisTransport.ts b/app/game-api/src/battleSim/redisTransport.ts index db7436e..295fcd2 100644 --- a/app/game-api/src/battleSim/redisTransport.ts +++ b/app/game-api/src/battleSim/redisTransport.ts @@ -44,16 +44,16 @@ export class RedisBattleSimTransport { this.resultTtlSeconds = options.resultTtlSeconds; } - private buildResultKey(jobId: string): string { - return `${this.keys.resultKeyPrefix}${jobId}`; + private buildResultKey(jobId: string, requesterUserId: string): string { + return `${this.keys.resultKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`; } - private buildNotifyKey(jobId: string): string { - return `${this.keys.notifyKeyPrefix}${jobId}`; + private buildNotifyKey(jobId: string, requesterUserId: string): string { + return `${this.keys.notifyKeyPrefix}${encodeURIComponent(requesterUserId)}:${jobId}`; } - private async readResult(jobId: string): Promise { - const raw = await this.client.get(this.buildResultKey(jobId)); + private async readResult(jobId: string, requesterUserId: string): Promise { + const raw = await this.client.get(this.buildResultKey(jobId, requesterUserId)); if (!raw) { return null; } @@ -64,44 +64,49 @@ export class RedisBattleSimTransport { } } - private async waitForResult(jobId: string, timeoutMs: number): Promise { - const existing = await this.readResult(jobId); + private async waitForResult( + jobId: string, + requesterUserId: string, + timeoutMs: number + ): Promise { + const existing = await this.readResult(jobId, requesterUserId); if (existing) { return existing; } - const notifyKey = this.buildNotifyKey(jobId); + const notifyKey = this.buildNotifyKey(jobId, requesterUserId); const timeoutSec = toTimeoutSeconds(timeoutMs); const signal = await this.client.blPop(notifyKey, timeoutSec); if (!parseBlPopValue(signal)) { return null; } - return this.readResult(jobId); + return this.readResult(jobId, requesterUserId); } - public async simulate(payload: BattleSimJobPayload): Promise { + public async simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise { const jobId = crypto.randomUUID(); const job = { jobId, + requesterUserId, requestedAt: new Date().toISOString(), payload, }; await this.client.rPush(this.keys.queueKey, JSON.stringify(job)); - const result = await this.waitForResult(jobId, this.requestTimeoutMs); + const result = await this.waitForResult(jobId, requesterUserId, this.requestTimeoutMs); if (result) { return { status: 'completed', jobId, payload: result }; } return { status: 'queued', jobId }; } - public async getSimulationResult(jobId: string): Promise { - return this.readResult(jobId); + public async getSimulationResult(jobId: string, requesterUserId: string): Promise { + return this.readResult(jobId, requesterUserId); } - public async pushResult(jobId: string, payload: BattleSimResultPayload): Promise { - const resultKey = this.buildResultKey(jobId); - const notifyKey = this.buildNotifyKey(jobId); + public async pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload): Promise { + const resultKey = this.buildResultKey(jobId, requesterUserId); + const notifyKey = this.buildNotifyKey(jobId, requesterUserId); await this.client.set(resultKey, JSON.stringify(payload), { EX: this.resultTtlSeconds, }); diff --git a/app/game-api/src/battleSim/transport.ts b/app/game-api/src/battleSim/transport.ts index cd6592a..0932b81 100644 --- a/app/game-api/src/battleSim/transport.ts +++ b/app/game-api/src/battleSim/transport.ts @@ -1,6 +1,6 @@ import type { BattleSimJobPayload, BattleSimResultPayload, BattleSimTransportResponse } from './types.js'; export interface BattleSimTransport { - simulate(payload: BattleSimJobPayload): Promise; - getSimulationResult(jobId: string): Promise; + simulate(payload: BattleSimJobPayload, requesterUserId: string): Promise; + getSimulationResult(jobId: string, requesterUserId: string): Promise; } diff --git a/app/game-api/src/battleSim/types.ts b/app/game-api/src/battleSim/types.ts index 6850b20..41d522b 100644 --- a/app/game-api/src/battleSim/types.ts +++ b/app/game-api/src/battleSim/types.ts @@ -134,6 +134,7 @@ export interface BattleSimResultPayload { export interface BattleSimJob { jobId: string; + requesterUserId: string; requestedAt: string; payload: BattleSimJobPayload; } diff --git a/app/game-api/src/battleSim/worker.ts b/app/game-api/src/battleSim/worker.ts index 6829051..6c5a2ba 100644 --- a/app/game-api/src/battleSim/worker.ts +++ b/app/game-api/src/battleSim/worker.ts @@ -18,7 +18,11 @@ const parseBlPopValue = (result: RedisBlPopResult): string | null => { return result.element ?? null; }; -export const runBattleSimWorker = async (): Promise => { +export interface BattleSimWorkerOptions { + signal?: AbortSignal; +} + +export const runBattleSimWorker = async (options: BattleSimWorkerOptions = {}): Promise => { const config = resolveGameApiConfigFromEnv(); const redis = createRedisConnector(resolveRedisConfigFromEnv()); await redis.connect(); @@ -30,35 +34,49 @@ export const runBattleSimWorker = async (): Promise => { resultTtlSeconds: config.battleSimResultTtlSeconds, }); - const handleExit = async () => { - await redis.disconnect(); + let stopped = options.signal?.aborted ?? false; + const handleExit = () => { + stopped = true; + }; + const handleAbort = () => { + stopped = true; }; process.on('SIGINT', handleExit); process.on('SIGTERM', handleExit); + options.signal?.addEventListener('abort', handleAbort, { once: true }); - while (true) { - const item = await redis.client.blPop(keys.queueKey, 0); - const raw = parseBlPopValue(item); - if (!raw) { - continue; - } + try { + while (!stopped) { + // A finite block lets SIGTERM and test AbortSignal stop the worker without + // leaving a Redis operation or a detached lifecycle process behind. + const item = await redis.client.blPop(keys.queueKey, 1); + const raw = parseBlPopValue(item); + if (!raw) { + continue; + } - let job: BattleSimJob | null = null; - try { - job = JSON.parse(raw) as BattleSimJob; - } catch { - continue; - } + let job: BattleSimJob | null = null; + try { + job = JSON.parse(raw) as BattleSimJob; + } catch { + continue; + } - try { - const result = processBattleSimJob(job.payload); - await transport.pushResult(job.jobId, result); - } catch (error) { - const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류'; - await transport.pushResult(job.jobId, { - result: false, - reason, - }); + try { + const result = processBattleSimJob(job.payload); + await transport.pushResult(job.jobId, job.requesterUserId, result); + } catch (error) { + const reason = error instanceof Error ? error.message : '전투 시뮬레이션 오류'; + await transport.pushResult(job.jobId, job.requesterUserId, { + result: false, + reason, + }); + } } + } finally { + process.off('SIGINT', handleExit); + process.off('SIGTERM', handleExit); + options.signal?.removeEventListener('abort', handleAbort); + await redis.disconnect(); } }; diff --git a/app/game-api/src/router/battle/index.ts b/app/game-api/src/router/battle/index.ts index 172e595..f77da37 100644 --- a/app/game-api/src/router/battle/index.ts +++ b/app/game-api/src/router/battle/index.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import { getDexLevel } from '@sammo-ts/logic'; -import { authedProcedure, procedure, router } from '../../trpc.js'; +import { authedProcedure, readOnlyAuthedProcedure, router } from '../../trpc.js'; import { buildBattleSimEnvironment, buildBattleSimJobPayload } from '../../battleSim/environment.js'; import { zBattleSimJobId, zBattleSimRequest } from '../../battleSim/schema.js'; import { @@ -30,6 +30,14 @@ const normalizeOptionalKey = (value: string | null): string | null => { return value; }; +const getAuthenticatedUserId = (auth: { user: { id: string } } | null): string => { + const userId = auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }); + } + return userId; +}; + const resolveExpLevel = (meta: Record, experience: number): number => { const expLevel = meta.explevel ?? meta.expLevel; if (typeof expLevel === 'number' && Number.isFinite(expLevel)) { @@ -48,7 +56,7 @@ const resolveDexValue = (meta: Record, key: string): number => }; export const battleRouter = router({ - simulate: procedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => { + simulate: readOnlyAuthedProcedure.input(zBattleSimRequest).mutation(async ({ ctx, input }) => { const worldState = await ctx.db.worldState.findFirst(); if (!worldState) { throw new TRPCError({ @@ -58,10 +66,10 @@ export const battleRouter = router({ } const payload = await buildBattleSimJobPayload(worldState, input, ctx.profile.id); - return ctx.battleSim.simulate(payload); + return ctx.battleSim.simulate(payload, getAuthenticatedUserId(ctx.auth)); }), - getSimulation: procedure.input(zBattleSimJobId).query(async ({ ctx, input }) => { - const result = await ctx.battleSim.getSimulationResult(input.jobId); + getSimulation: readOnlyAuthedProcedure.input(zBattleSimJobId).query(async ({ ctx, input }) => { + const result = await ctx.battleSim.getSimulationResult(input.jobId, getAuthenticatedUserId(ctx.auth)); if (!result) { return { status: 'queued', jobId: input.jobId }; } diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index a8ce3e9..4a3e8dc 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -7,6 +7,21 @@ import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundar const t = initTRPC.context().create(); +const requireAuthMiddleware = t.middleware(({ ctx, next }) => { + if (!ctx.auth) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Unauthorized', + }); + } + return next({ + ctx: { + ...ctx, + auth: ctx.auth, + }, + }); +}); + const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { if (type !== 'mutation' || !ctx.db.$transaction) { return next(); @@ -46,17 +61,8 @@ const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => { export const router = t.router; export const procedure = t.procedure.use(inputEventMiddleware); -export const authedProcedure: typeof procedure = procedure.use(({ ctx, next }) => { - if (!ctx.auth) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'Unauthorized', - }); - } - return next({ - ctx: { - ...ctx, - auth: ctx.auth, - }, - }); -}); +export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware); + +// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과 +// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다. +export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index d0d0f49..3d7192d 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -19,19 +19,30 @@ const profile: GameProfile = { class QueuedBattleSimTransport implements BattleSimTransport { public simulateCalls = 0; public lastPayload: BattleSimJobPayload | null = null; + public lastRequesterUserId: string | null = null; + private readonly owners = new Map(); private readonly results = new Map(); - async simulate(payload: BattleSimJobPayload) { + async simulate(payload: BattleSimJobPayload, requesterUserId: string) { this.simulateCalls += 1; this.lastPayload = payload; - return { status: 'queued', jobId: 'job-1' } as const; + this.lastRequesterUserId = requesterUserId; + const jobId = `job-${this.simulateCalls}`; + this.owners.set(jobId, requesterUserId); + return { status: 'queued', jobId } as const; } - async getSimulationResult(jobId: string) { + async getSimulationResult(jobId: string, requesterUserId: string) { + if (this.owners.get(jobId) !== requesterUserId) { + return null; + } return this.results.get(jobId) ?? null; } - pushResult(jobId: string, payload: BattleSimResultPayload) { + pushResult(jobId: string, requesterUserId: string, payload: BattleSimResultPayload) { + if (this.owners.get(jobId) !== requesterUserId) { + throw new Error('requester mismatch'); + } this.results.set(jobId, payload); } } @@ -194,8 +205,13 @@ const buildBattleRequest = () => ({ }, }); -const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTransport }): GameApiContext => { - const db = { +const buildContext = (options: { + state: WorldStateRow; + battleSim: BattleSimTransport; + userId?: string | null; + db?: Partial; +}): GameApiContext => { + const db = options.db ?? { worldState: { findFirst: async () => options.state, }, @@ -207,20 +223,23 @@ const buildContext = (options: { state: WorldStateRow; battleSim: BattleSimTrans }, profile.name ); - const auth: GameSessionTokenPayload = { - version: 1, - profile: profile.name, - issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), - expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), - sessionId: 'session-1', - user: { - id: 'user-1', - username: 'tester', - displayName: 'Tester', - roles: [], - }, - sanctions: {}, - }; + const auth: GameSessionTokenPayload | null = + options.userId === null + ? null + : { + version: 1, + profile: profile.name, + issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), + expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), + sessionId: 'session-1', + user: { + id: options.userId ?? 'user-1', + username: 'tester', + displayName: 'Tester', + roles: [], + }, + sanctions: {}, + }; return { db: db as unknown as DatabaseClient, turnDaemon: new InMemoryTurnDaemonTransport(), @@ -255,14 +274,204 @@ describe('battle router orchestration', () => { const response = await caller.battle.simulate(buildBattleRequest()); expect(response.status).toBe('queued'); expect(battleSim.simulateCalls).toBe(1); + expect(battleSim.lastRequesterUserId).toBe('user-1'); const queued = await caller.battle.getSimulation({ jobId: response.jobId }); expect(queued.status).toBe('queued'); - battleSim.pushResult(response.jobId, { result: true, reason: 'success', avgWar: 1 }); + battleSim.pushResult(response.jobId, 'user-1', { result: true, reason: 'success', avgWar: 1 }); const completed = await caller.battle.getSimulation({ jobId: response.jobId }); expect(completed.status).toBe('completed'); expect(completed.payload?.result).toBe(true); }); + + it('requires login, allows a user without a general, and does not open an input-event transaction', async () => { + const battleSim = new QueuedBattleSimTransport(); + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + let transactionCalls = 0; + const db = { + worldState: { findFirst: async () => state }, + $transaction: async () => { + transactionCalls += 1; + throw new Error('simulation must not create an input event transaction'); + }, + } as unknown as DatabaseClient; + + const anonymous = appRouter.createCaller(buildContext({ state, battleSim, userId: null, db })); + await expect(anonymous.battle.simulate(buildBattleRequest())).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + + const noGeneralUser = appRouter.createCaller( + buildContext({ state, battleSim, userId: 'user-without-general', db }) + ); + await expect(noGeneralUser.battle.simulate(buildBattleRequest())).resolves.toMatchObject({ + status: 'queued', + }); + expect(transactionCalls).toBe(0); + expect(battleSim.lastRequesterUserId).toBe('user-without-general'); + }); + + it('does not expose queued results across authenticated users', async () => { + const battleSim = new QueuedBattleSimTransport(); + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + const owner = appRouter.createCaller(buildContext({ state, battleSim, userId: 'owner-user' })); + const other = appRouter.createCaller(buildContext({ state, battleSim, userId: 'other-user' })); + const response = await owner.battle.simulate(buildBattleRequest()); + battleSim.pushResult(response.jobId, 'owner-user', { result: true, reason: 'success', avgWar: 7 }); + + await expect(owner.battle.getSimulation({ jobId: response.jobId })).resolves.toMatchObject({ + status: 'completed', + payload: { avgWar: 7 }, + }); + await expect(other.battle.getSimulation({ jobId: response.jobId })).resolves.toEqual({ + status: 'queued', + jobId: response.jobId, + }); + }); +}); + +describe('battle simulator general import permissions', () => { + const state: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: {}, + meta: {}, + updatedAt: new Date('2026-01-01T00:00:00Z'), + }; + + const buildGeneral = (overrides: Record) => ({ + id: 1, + userId: 'same-nation-user', + name: '관전자', + npcState: 0, + nationId: 1, + leadership: 70, + strength: 71, + intel: 72, + officerLevel: 1, + injury: 0, + rice: 9000, + crew: 5000, + crewTypeId: 100, + atmos: 100, + train: 100, + experience: 400, + horseCode: null, + weaponCode: null, + bookCode: null, + itemCode: null, + personalCode: null, + special2Code: null, + meta: {}, + ...overrides, + }); + + const actor = buildGeneral({ id: 1, userId: 'same-nation-user', nationId: 1 }); + const ally = buildGeneral({ + id: 2, + userId: 'ally-user', + name: '아군 장수', + nationId: 1, + officerLevel: 4, + rice: 4321, + crew: 3210, + train: 97, + atmos: 96, + horseCode: 'che_적토마', + weaponCode: 'che_의천검', + bookCode: 'che_손자병법', + itemCode: 'che_옥새', + meta: { + dex1: 10000, + rank_warnum: 33, + rank_killnum: 22, + rank_killcrew: 1111, + }, + }); + const foreignActor = buildGeneral({ id: 3, userId: 'foreign-user', nationId: 2 }); + const generals = [actor, ally, foreignActor]; + const db = { + worldState: { findFirst: async () => state }, + general: { + findFirst: async ({ where }: { where: { userId: string } }) => + generals.find((general) => general.userId === where.userId) ?? null, + findUnique: async ({ where }: { where: { id: number } }) => + generals.find((general) => general.id === where.id) ?? null, + }, + } as unknown as DatabaseClient; + + it('returns full ally details to the same nation but redacts them for another nation', async () => { + const battleSim = new QueuedBattleSimTransport(); + const sameNation = appRouter.createCaller(buildContext({ state, battleSim, userId: 'same-nation-user', db })); + const foreign = appRouter.createCaller(buildContext({ state, battleSim, userId: 'foreign-user', db })); + + const visible = await sameNation.battle.getGeneralDetail({ generalId: ally.id }); + expect(visible.general).toMatchObject({ + name: '아군 장수', + officer_level: 4, + horse: 'che_적토마', + crew: 3210, + rice: 4321, + train: 97, + atmos: 96, + warnum: 33, + killnum: 22, + killcrew: 1111, + }); + + const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id }); + expect(redacted.general).toMatchObject({ + name: '아군 장수', + officer_level: 1, + horse: null, + weapon: null, + book: null, + item: null, + crew: 0, + rice: 10000, + dex1: 0, + warnum: 0, + killnum: 0, + killcrew: 0, + }); + }); + + it('requires a game general only for server-side general import', async () => { + const caller = appRouter.createCaller( + buildContext({ + state, + battleSim: new QueuedBattleSimTransport(), + userId: 'user-without-general', + db, + }) + ); + + await expect(caller.battle.getGeneralDetail({ generalId: ally.id })).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: 'General not found', + }); + }); }); diff --git a/app/game-api/test/battleSimTransport.test.ts b/app/game-api/test/battleSimTransport.test.ts new file mode 100644 index 0000000..1f0eb54 --- /dev/null +++ b/app/game-api/test/battleSimTransport.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js'; +import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js'; +import type { BattleSimJob, BattleSimJobPayload } from '../src/battleSim/types.js'; + +class FakeRedisClient { + readonly values = new Map(); + readonly lists = new Map(); + + async rPush(key: string, value: string): Promise { + const list = this.lists.get(key) ?? []; + list.push(value); + this.lists.set(key, list); + return list.length; + } + + async blPop(): Promise { + return null; + } + + async set(key: string, value: string): Promise<'OK'> { + this.values.set(key, value); + return 'OK'; + } + + async get(key: string): Promise { + return this.values.get(key) ?? null; + } + + async expire(): Promise { + return 1; + } +} + +describe('RedisBattleSimTransport requester isolation', () => { + it('records the requester on queued jobs and scopes completed results to that user', async () => { + const client = new FakeRedisClient(); + const keys = buildBattleSimQueueKeys('che:test'); + const transport = new RedisBattleSimTransport(client, { + keys, + requestTimeoutMs: 1, + resultTtlSeconds: 60, + }); + + const response = await transport.simulate({} as BattleSimJobPayload, 'user/one'); + expect(response.status).toBe('queued'); + + const queuedRaw = client.lists.get(keys.queueKey)?.[0]; + expect(queuedRaw).toBeTruthy(); + expect(JSON.parse(queuedRaw ?? '{}') as BattleSimJob).toMatchObject({ + jobId: response.jobId, + requesterUserId: 'user/one', + }); + + await transport.pushResult(response.jobId, 'user/one', { + result: true, + reason: 'success', + avgWar: 3, + }); + + await expect(transport.getSimulationResult(response.jobId, 'user/one')).resolves.toMatchObject({ + result: true, + avgWar: 3, + }); + await expect(transport.getSimulationResult(response.jobId, 'user/two')).resolves.toBeNull(); + expect(Array.from(client.values.keys()).some((key) => key.includes('user%2Fone'))).toBe(true); + }); +}); diff --git a/app/game-api/test/battleSimWorker.integration.test.ts b/app/game-api/test/battleSimWorker.integration.test.ts new file mode 100644 index 0000000..6a8ccf8 --- /dev/null +++ b/app/game-api/test/battleSimWorker.integration.test.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildBattleSimEnvironment } from '../src/battleSim/environment.js'; +import { buildBattleSimQueueKeys } from '../src/battleSim/keys.js'; +import { RedisBattleSimTransport } from '../src/battleSim/redisTransport.js'; +import type { BattleSimRequestPayload } from '../src/battleSim/types.js'; +import { runBattleSimWorker } from '../src/battleSim/worker.js'; +import type { WorldStateRow } from '../src/context.js'; + +const liveDescribe = process.env.REDIS_URL ? describe : describe.skip; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +liveDescribe('battle simulator worker with live Redis', () => { + it('consumes an isolated queue, produces a result, and stops cleanly', { timeout: 30_000 }, async () => { + const scenario = `battle-sim-e2e-${randomUUID()}`; + const profileName = `che:${scenario}`; + const requesterUserId = 'worker-e2e-user'; + vi.stubEnv('PROFILE', 'che'); + vi.stubEnv('SCENARIO', scenario); + vi.stubEnv('GAME_TOKEN_SECRET', 'battle-sim-test-only'); + + const fixturePath = path.resolve( + process.cwd(), + '../../tools/integration-tests/fixtures/battle/basic-infantry.json' + ); + const fixture = JSON.parse(await fs.readFile(fixturePath, 'utf8')) as BattleSimRequestPayload & { + startYear: number; + }; + const { startYear, ...request } = fixture; + const worldState: WorldStateRow = { + id: 1, + scenarioCode: 'default', + currentYear: request.year, + currentMonth: request.month, + tickSeconds: 600, + config: {}, + meta: { scenarioMeta: { startYear } }, + updatedAt: new Date(), + }; + const environment = await buildBattleSimEnvironment(worldState, 'che'); + const payload = { + ...request, + unitSet: environment.unitSet, + config: environment.config, + time: { year: request.year, month: request.month, startYear }, + }; + + const clientConnector = createRedisConnector(resolveRedisConfigFromEnv()); + await clientConnector.connect(); + const keys = buildBattleSimQueueKeys(profileName); + const transport = new RedisBattleSimTransport(clientConnector.client, { + keys, + requestTimeoutMs: 15_000, + resultTtlSeconds: 60, + }); + const abortController = new AbortController(); + const worker = runBattleSimWorker({ signal: abortController.signal }); + let jobId: string | null = null; + + try { + const result = await transport.simulate(payload, requesterUserId); + jobId = result.jobId; + expect(result.status).toBe('completed'); + if (result.status === 'completed') { + expect(result.payload).toMatchObject({ + result: true, + reason: 'success', + avgWar: 1, + }); + expect(result.payload.phase).toBeGreaterThan(0); + } + } finally { + abortController.abort(); + await worker; + if (jobId) { + const encodedRequester = encodeURIComponent(requesterUserId); + await clientConnector.client.del([ + keys.queueKey, + `${keys.resultKeyPrefix}${encodedRequester}:${jobId}`, + `${keys.notifyKeyPrefix}${encodedRequester}:${jobId}`, + ]); + } + await clientConnector.disconnect(); + } + }); +}); diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index e469052..4552131 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -817,6 +817,7 @@ export const adminRouter = router({ profileName: profile.profileName, apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }, })); diff --git a/app/gateway-api/src/lobby/profileStatusService.ts b/app/gateway-api/src/lobby/profileStatusService.ts index b65c2f5..9814831 100644 --- a/app/gateway-api/src/lobby/profileStatusService.ts +++ b/app/gateway-api/src/lobby/profileStatusService.ts @@ -26,6 +26,7 @@ export type LobbyProfileStatus = { runtime: { apiRunning: boolean; daemonRunning: boolean; + battleSimRunning: boolean; tournamentRunning: boolean; }; korName: string; @@ -69,7 +70,10 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi private mapProfile( row: GatewayProfileRecord, - runtimeMap: Map + runtimeMap: Map< + string, + { apiRunning: boolean; daemonRunning: boolean; battleSimRunning: boolean; tournamentRunning: boolean } + > ): LobbyProfileStatus { const meta = row.meta; return { @@ -81,6 +85,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi runtime: runtimeMap.get(row.profileName) ?? { apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }, korName: (meta.korName as string | undefined) ?? row.profile, diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 273c083..804e411 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -39,6 +39,7 @@ export interface GatewayOrchestratorOptions { export interface ProfileRuntimeState { apiRunning: boolean; daemonRunning: boolean; + battleSimRunning: boolean; tournamentRunning: boolean; } @@ -66,13 +67,19 @@ export const planProfileReconcile = ( ): { shouldStart: boolean; shouldStop: boolean } => { if (status === 'RUNNING' || status === 'PREOPEN' || status === 'PAUSED' || status === 'COMPLETED') { return { - shouldStart: !(runtime.apiRunning && runtime.daemonRunning && runtime.tournamentRunning), + shouldStart: !( + runtime.apiRunning && + runtime.daemonRunning && + runtime.battleSimRunning && + runtime.tournamentRunning + ), shouldStop: false, }; } return { shouldStart: false, - shouldStop: runtime.apiRunning || runtime.daemonRunning || runtime.tournamentRunning, + shouldStop: + runtime.apiRunning || runtime.daemonRunning || runtime.battleSimRunning || runtime.tournamentRunning, }; }; @@ -273,8 +280,16 @@ const parseInstallOptions = ( }; }; -const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'tournament'): string => - `sammo:${profileName}:${role === 'api' ? 'game-api' : role === 'daemon' ? 'turn-daemon' : 'tournament-worker'}`; +const buildProcessName = (profileName: string, role: 'api' | 'daemon' | 'battle-sim' | 'tournament'): string => + `sammo:${profileName}:${ + role === 'api' + ? 'game-api' + : role === 'daemon' + ? 'turn-daemon' + : role === 'battle-sim' + ? 'battle-sim-worker' + : 'tournament-worker' + }`; const isMissingProcessError = (error: unknown): boolean => error instanceof Error && /process or namespace not found/i.test(error.message); @@ -285,11 +300,13 @@ export const buildProcessDefinitions = ( ): { api: { name: string; script: string; cwd: string; env: Record }; daemon: { name: string; script: string; cwd: string; env: Record }; + battleSim: { name: string; script: string; cwd: string; env: Record }; tournament: { name: string; script: string; cwd: string; env: Record }; } => { const baseEnv = { ...(config.baseEnv ?? {}) }; const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const battleSimName = buildProcessName(profile.profileName, 'battle-sim'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const runtimeWorkspace = profile.buildWorkspace ?? config.workspaceRoot; const apiCwd = path.join(runtimeWorkspace, 'app', 'game-api'); @@ -327,6 +344,15 @@ export const buildProcessDefinitions = ( cwd: daemonCwd, env: daemonEnv, }, + battleSim: { + name: battleSimName, + script: apiScript, + cwd: apiCwd, + env: { + ...apiEnv, + GAME_API_ROLE: 'battle-sim-worker', + }, + }, tournament: { name: tournamentName, script: apiScript, @@ -376,11 +402,13 @@ const mapRuntimeStates = (profileNames: string[], processNames: Map { const apiName = buildProcessName(profileName, 'api'); const daemonName = buildProcessName(profileName, 'daemon'); + const battleSimName = buildProcessName(profileName, 'battle-sim'); const tournamentName = buildProcessName(profileName, 'tournament'); return { profileName, apiRunning: processNames.get(apiName) ?? false, daemonRunning: processNames.get(daemonName) ?? false, + battleSimRunning: processNames.get(battleSimName) ?? false, tournamentRunning: processNames.get(tournamentName) ?? false, }; }); @@ -981,6 +1009,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { try { await this.processManager.start(definitions.api); await this.processManager.start(definitions.daemon); + await this.processManager.start(definitions.battleSim); await this.processManager.start(definitions.tournament); await this.repository.updateLastError(profile.profileName, null); return true; @@ -996,10 +1025,11 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { private async stopProfile(profile: GatewayProfileRecord): Promise { const apiName = buildProcessName(profile.profileName, 'api'); const daemonName = buildProcessName(profile.profileName, 'daemon'); + const battleSimName = buildProcessName(profile.profileName, 'battle-sim'); const tournamentName = buildProcessName(profile.profileName, 'tournament'); const existingNames = new Set((await this.processManager.list()).map((process) => process.name)); const failures: string[] = []; - for (const name of [apiName, daemonName, tournamentName]) { + for (const name of [apiName, daemonName, battleSimName, tournamentName]) { if (!existingNames.has(name)) { continue; } diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 7a47e10..787d20a 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -88,6 +88,7 @@ const createHarness = ( ? [ { name: 'sammo:che:2:game-api', status: 'online' }, { name: 'sammo:che:2:turn-daemon', status: 'online' }, + { name: 'sammo:che:2:battle-sim-worker', status: 'online' }, { name: 'sammo:che:2:tournament-worker', status: 'online' }, ] : [], @@ -138,7 +139,7 @@ const createHarness = ( }; describe('GatewayOrchestrator first-class operations', () => { - it('starts both profile processes and records success', async () => { + it('starts every profile process and records success', async () => { const harness = createHarness(buildOperation('START')); await harness.orchestrator.runOperationsNow(); @@ -147,12 +148,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.started.map((definition) => definition.name)).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); }); - it('stops both profile processes and records success', async () => { + it('stops every profile process and records success', async () => { const harness = createHarness(buildOperation('STOP')); await harness.orchestrator.runOperationsNow(); @@ -161,11 +163,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); @@ -190,6 +194,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['SUCCEEDED']); @@ -203,7 +208,7 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.completions).toEqual(['FAILED']); }); - it('attempts to stop both roles before reporting a partial PM2 failure', async () => { + it('attempts to stop every role before reporting a partial PM2 failure', async () => { const harness = createHarness(buildOperation('STOP'), false, true); await harness.orchestrator.runOperationsNow(); @@ -211,11 +216,13 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.stopped).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.deleted).toEqual([ 'sammo:che:2:game-api', 'sammo:che:2:turn-daemon', + 'sammo:che:2:battle-sim-worker', 'sammo:che:2:tournament-worker', ]); expect(harness.completions).toEqual(['FAILED']); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index ae92d15..431a840 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -29,6 +29,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: false, + battleSimRunning: true, tournamentRunning: true, }) ).toEqual({ shouldStart: true, shouldStop: false }); @@ -39,6 +40,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('PREOPEN', { apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: true, shouldStop: false }); @@ -49,6 +51,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RUNNING', { apiRunning: true, daemonRunning: true, + battleSimRunning: true, tournamentRunning: true, }) ).toEqual({ shouldStart: false, shouldStop: false }); @@ -59,6 +62,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('STOPPED', { apiRunning: false, daemonRunning: true, + battleSimRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: true }); @@ -69,6 +73,7 @@ describe('planProfileReconcile', () => { planProfileReconcile('RESERVED', { apiRunning: false, daemonRunning: false, + battleSimRunning: false, tournamentRunning: false, }) ).toEqual({ shouldStart: false, shouldStop: false }); @@ -95,6 +100,11 @@ describe('buildProcessDefinitions', () => { }); expect(definitions.daemon.cwd).toBe(path.join(buildWorkspace, 'app', 'game-engine')); expect(definitions.daemon.script).toBe(path.join(buildWorkspace, 'app', 'game-engine', 'dist', 'index.js')); + expect(definitions.battleSim).toMatchObject({ + cwd: path.join(buildWorkspace, 'app', 'game-api'), + script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), + env: { GAME_API_ROLE: 'battle-sim-worker' }, + }); expect(definitions.tournament).toMatchObject({ cwd: path.join(buildWorkspace, 'app', 'game-api'), script: path.join(buildWorkspace, 'app', 'game-api', 'dist', 'index.js'), @@ -107,6 +117,7 @@ describe('buildProcessDefinitions', () => { expect(definitions.api.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.daemon.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-engine')); + expect(definitions.battleSim.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); expect(definitions.tournament.cwd).toBe(path.join(processConfig.workspaceRoot, 'app', 'game-api')); }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index ba61af7..65d02c9 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -38,6 +38,7 @@ const profile = (runtimeRunning: boolean) => ({ profileName: 'che:2', apiRunning: runtimeRunning, daemonRunning: runtimeRunning, + battleSimRunning: runtimeRunning, tournamentRunning: runtimeRunning, }, }); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 565317c..b2c9fa6 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -70,6 +70,7 @@ type AdminProfile = { runtime: { apiRunning: boolean; daemonRunning: boolean; + battleSimRunning: boolean; tournamentRunning: boolean; }; buildCommitSha?: string; @@ -1352,7 +1353,8 @@ onMounted(() => {
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} / - DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / TOURNAMENT: + DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / BATTLE SIM: + {{ profile.runtime.battleSimRunning ? 'ON' : 'OFF' }} / TOURNAMENT: {{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 9908d48..b39a048 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -16,7 +16,12 @@ type Profile = { buildWorkspace?: string; buildError?: string; lastError?: string; - runtime: { apiRunning: boolean; daemonRunning: boolean; tournamentRunning: boolean }; + runtime: { + apiRunning: boolean; + daemonRunning: boolean; + battleSimRunning: boolean; + tournamentRunning: boolean; + }; }; type Scenario = { @@ -377,6 +382,14 @@ onBeforeUnmount(() => { {{ selectedProfile.runtime.daemonRunning ? 'RUNNING' : 'STOPPED' }} +
+
Battle sim worker
+
+ {{ selectedProfile.runtime.battleSimRunning ? 'RUNNING' : 'STOPPED' }} +
+
Tournament worker
Date: Sun, 26 Jul 2026 05:30:35 +0000 Subject: [PATCH 2/2] feat(frontend): finish battle simulator workflows --- app/game-frontend/e2e/battleSimulator.spec.ts | 320 ++++++++++++++++++ .../e2e/battleSimulatorRef.spec.ts | 73 ++++ app/game-frontend/e2e/playwright.config.mjs | 2 + app/game-frontend/package.json | 1 + .../components/battle/BattleGeneralCard.vue | 55 ++- app/game-frontend/src/router/index.ts | 1 - .../src/views/BattleSimulatorView.vue | 239 ++++++++++--- app/game-frontend/src/views/JoinView.vue | 23 +- docs/frontend-legacy-parity.md | 18 +- 9 files changed, 636 insertions(+), 96 deletions(-) create mode 100644 app/game-frontend/e2e/battleSimulator.spec.ts create mode 100644 app/game-frontend/e2e/battleSimulatorRef.spec.ts diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts new file mode 100644 index 0000000..bf0760f --- /dev/null +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -0,0 +1,320 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; +const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); + +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const readImage = async (relative: string): Promise => { + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relative)); + } catch { + // Main checkout and feature worktrees have different image-root parents. + } + } + throw new Error(`Reference image not found: ${relative}`); +}; + +const simulatorOptions = { + world: { startYear: 190, currentYear: 205, currentMonth: 8 }, + config: { + maxTrainByWar: 120, + maxAtmosByWar: 120, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + }, + unitSet: { + defaultCrewTypeId: 100, + crewTypes: [ + { id: 100, name: '보병', armType: 1 }, + { id: 200, name: '궁병', armType: 2 }, + ], + }, + nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }], + warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }], + personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }], + items: { horse: [], weapon: [], book: [], item: [] }, + nationLevels: [ + { level: 0, name: '방랑군' }, + { level: 1, name: '소국' }, + ], + cityLevels: [ + { level: 1, name: '소도시' }, + { level: 5, name: '대도시' }, + ], + dexLevels: [ + { level: 0, label: 'F', value: 0 }, + { level: 1, label: 'E', value: 1000 }, + ], +}; + +const generalMe = { + general: { + id: 7, + name: '유비', + npcState: 0, + nationId: 1, + cityId: 1, + troopId: 0, + picture: '22.jpg', + imageServer: 0, + officerLevel: 12, + stats: { leadership: 85, strength: 72, intelligence: 78 }, + gold: 1000, + rice: 8765, + crew: 4321, + train: 99, + atmos: 98, + injury: 0, + experience: 900, + dedication: 100, + items: { horse: null, weapon: null, book: null, item: null }, + }, + city: { id: 1, level: 1, defence: 2222, wall: 3333 }, + nation: { id: 1, level: 1, tech: 4500, typeCode: 'che_중립', capitalCityId: 1 }, + settings: {}, + penalties: {}, +}; + +const importedGeneral = { + general: { + no: 7, + name: '유비', + officer_level: 12, + explevel: 30, + leadership: 85, + strength: 72, + intel: 78, + horse: null, + weapon: null, + book: null, + item: null, + injury: 0, + rice: 8765, + personal: 'che_대담', + special2: 'che_필살', + crew: 4321, + crewtype: 100, + atmos: 98, + train: 99, + dex1: 1000, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + defence_train: 90, + warnum: 12, + killnum: 7, + killcrew: 3456, + }, +}; + +const simulationResult = { + result: true, + reason: 'success', + datetime: '205-08', + avgWar: 5, + phase: 13, + killed: 1234, + maxKilled: 1400, + minKilled: 1100, + dead: 432, + maxDead: 500, + minDead: 400, + attackerRice: 321, + defenderRice: 654, + attackerSkills: { 필살: 2 }, + defendersSkills: [{ 회피: 1 }], + lastWarLog: { + generalHistoryLog: '', + generalActionLog: '', + generalBattleResultLog: '유비가 모의전에서 승리했습니다.', + generalBattleDetailLog: '필살 발동, 피해 1,234', + nationalHistoryLog: '', + globalHistoryLog: '', + globalActionLog: '', + }, +}; + +type Fixture = { + hasGeneral: boolean; + failNextSimulation?: boolean; + queueFirst?: boolean; + pollingCount: number; + requests: string[]; +}; + +const installImages = async (page: Page) => { + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readImage(`game/${filename}`), + }); + }); + } +}; + +const installApi = async (page: Page, fixture: Fixture) => { + await installImages(page); + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_battle_sim_playwright'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationNames(route); + const results = operations.map((operation) => { + fixture.requests.push(operation); + if (operation === 'lobby.info') { + return response({ + year: 205, + month: 8, + myGeneral: fixture.hasGeneral ? { name: '유비', picture: '22.jpg' } : null, + }); + } + if (operation === 'battle.getSimulatorContext') return response(simulatorOptions); + if (operation === 'general.me') return response(fixture.hasGeneral ? generalMe : null); + if (operation === 'battle.getGeneralList') { + return response({ + myNationId: 1, + myGeneralId: 7, + nations: [{ id: 1, name: '촉', color: '#8fbc8f' }], + generalsByNation: { 1: [{ id: 7, name: '유비', npcState: 0 }] }, + }); + } + if (operation === 'battle.getGeneralDetail') return response(importedGeneral); + if (operation === 'battle.simulate') { + if (fixture.failNextSimulation) { + fixture.failNextSimulation = false; + return errorResponse(operation, '시뮬레이터 입력 오류'); + } + if (fixture.queueFirst) { + return response({ status: 'queued', jobId: 'job-playwright' }); + } + return response({ status: 'completed', jobId: 'job-playwright', payload: simulationResult }); + } + if (operation === 'battle.getSimulation') { + fixture.pollingCount += 1; + if (fixture.pollingCount === 1) { + return response({ status: 'queued', jobId: 'job-playwright' }); + } + return response({ + status: 'completed', + jobId: 'job-playwright', + payload: simulationResult, + }); + } + return errorResponse(operation, `Unhandled battle simulator fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +const gotoSimulator = async (page: Page) => { + await page.goto('battle-simulator'); + await expect(page.getByRole('heading', { name: '전투 시뮬레이터' })).toBeVisible(); + await expect(page.getByLabel('시뮬레이터 데이터 안내')).toBeVisible(); + await expect(page.getByText('출병자 설정')).toBeVisible(); +}; + +test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => { + const fixture: Fixture = { hasGeneral: true, queueFirst: true, pollingCount: 0, requests: [] }; + await installApi(page, fixture); + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoSimulator(page); + + const notice = page.getByLabel('시뮬레이터 데이터 안내'); + const noticeRect = await notice.boundingBox(); + expect(noticeRect?.width).toBeGreaterThan(900); + expect(await notice.evaluate((element) => getComputedStyle(element).display)).toBe('flex'); + + await page.getByRole('button', { name: '독립 기본값' }).click(); + await expect(page.getByLabel('연도', { exact: true })).toHaveValue('190'); + await expect(page.getByLabel('월')).toHaveValue('1'); + + await page.getByRole('button', { name: '현재 게임 환경 적용' }).click(); + await expect(page.getByLabel('연도', { exact: true })).toHaveValue('205'); + await expect(page.getByLabel('월')).toHaveValue('8'); + + await page.getByRole('button', { name: '내 장수를 출병자로' }).click(); + await expect(page.getByLabel('이름').first()).toHaveValue('유비'); + await expect(page.getByLabel('병사').first()).toHaveValue('4321'); + + const battleButton = page.getByRole('button', { name: '전투', exact: true }); + await battleButton.hover(); + expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + await page.getByLabel('시드').fill('playwright-fixed-seed'); + await battleButton.click(); + + await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible(); + await expect(page.getByText('5', { exact: true })).toBeVisible(); + expect(fixture.pollingCount).toBe(2); + expect(fixture.requests).toContain('battle.getSimulation'); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-core-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); + +test('keeps simulation available without a game general and preserves input after an API error', async ({ page }) => { + const fixture: Fixture = { + hasGeneral: false, + failNextSimulation: true, + pollingCount: 0, + requests: [], + }; + await installApi(page, fixture); + await page.setViewportSize({ width: 500, height: 900 }); + await gotoSimulator(page); + + await expect(page).toHaveURL(/battle-simulator/); + await expect(page.getByRole('button', { name: '내 장수를 출병자로' })).toBeDisabled(); + await expect(page.getByRole('button', { name: '서버에서 가져오기' }).first()).toBeDisabled(); + + await page.getByLabel('시드').fill('keep-this-seed'); + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(page.getByText('시뮬레이터 입력 오류')).toBeVisible(); + await expect(page.getByLabel('시드')).toHaveValue('keep-this-seed'); + + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(page.getByText('유비가 모의전에서 승리했습니다.')).toBeVisible(); + await expect(page.getByText('시뮬레이터 입력 오류')).toHaveCount(0); + + const notice = page.getByLabel('시뮬레이터 데이터 안내'); + expect(await notice.evaluate((element) => getComputedStyle(element).flexDirection)).toBe('column'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-core-mobile.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); diff --git a/app/game-frontend/e2e/battleSimulatorRef.spec.ts b/app/game-frontend/e2e/battleSimulatorRef.spec.ts new file mode 100644 index 0000000..b11b48a --- /dev/null +++ b/app/game-frontend/e2e/battleSimulatorRef.spec.ts @@ -0,0 +1,73 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { expect, test } from '@playwright/test'; + +const refBaseUrl = process.env.REF_BATTLE_SIM_URL; +const refPasswordFile = process.env.REF_USER_PASSWORD_FILE; +const refUsername = process.env.REF_USER_ID ?? 'refuser1'; +const artifactRoot = process.env.BATTLE_SIM_ARTIFACT_DIR; +const refTest = refBaseUrl && refPasswordFile ? test : test.skip; + +refTest('runs the legacy simulator in the same Chromium and captures its rendered contract', async ({ page }) => { + test.setTimeout(120_000); + if (!refBaseUrl || !refPasswordFile) { + throw new Error('REF_BATTLE_SIM_URL and REF_USER_PASSWORD_FILE are required'); + } + const password = (await readFile(refPasswordFile, 'utf8')).trim(); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(refBaseUrl, { waitUntil: 'networkidle' }); + await page.locator('#username').fill(refUsername); + await page.locator('#password').fill(password); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const loginResponse = await page + .context() + .request.post(new URL('api.php?path=Login/LoginByID', refBaseUrl).toString(), { + data: { username: refUsername, password: passwordHash }, + }); + expect(loginResponse.status()).toBe(200); + await expect(loginResponse.json()).resolves.toMatchObject({ result: true }); + + await page.goto(new URL('hwe/battle_simulator.php', refBaseUrl).toString(), { + waitUntil: 'networkidle', + }); + const battleButton = page.locator('.btn-begin_battle'); + await expect(battleButton).toBeVisible(); + const container = page.locator('#container'); + const rect = await container.boundingBox(); + expect(rect?.width).toBeGreaterThanOrEqual(995); + expect(rect?.width).toBeLessThanOrEqual(1005); + + // A login with no game general leaves the legacy nation selects without a + // selected option. Choose the first legal independent value before running. + await page.locator('.form_nation_type').evaluateAll((elements) => { + for (const element of elements) { + const select = element as HTMLSelectElement; + select.selectedIndex = 0; + select.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + await expect(page.locator('.form_nation_type').first()).not.toHaveValue(''); + + await battleButton.hover(); + expect(await battleButton.evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer'); + const simulationResponse = page.waitForResponse( + (response) => response.url().includes('/j_simulate_battle.php') && response.status() === 200, + { timeout: 90_000 } + ); + await battleButton.click(); + await simulationResponse; + await expect(page.locator('#generalBattleResultLog')).not.toBeEmpty(); + + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'battle-simulator-ref-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 94135ac..5ef6863 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -16,6 +16,8 @@ export default defineConfig({ 'nationOffices.spec.ts', 'nationGeneralSecret.spec.ts', 'npcPolicy.spec.ts', + 'battleSimulator.spec.ts', + 'battleSimulatorRef.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index a60d1d3..5d3a6bd 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -11,6 +11,7 @@ "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", "test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/components/battle/BattleGeneralCard.vue b/app/game-frontend/src/components/battle/BattleGeneralCard.vue index 8f60d54..7a84f23 100644 --- a/app/game-frontend/src/components/battle/BattleGeneralCard.vue +++ b/app/game-frontend/src/components/battle/BattleGeneralCard.vue @@ -7,6 +7,7 @@ interface Props { options: BattleSimOptions; mode: 'attacker' | 'defender'; title: string; + canImportServer: boolean; } const props = defineProps(); @@ -59,7 +60,19 @@ const officerLevelOptions = [
No {{ general.no }}
- + @@ -187,21 +200,11 @@ const officerLevelOptions = [
@@ -391,6 +379,11 @@ const officerLevelOptions = [ color: #f0b6b6; } +.action:disabled { + cursor: not-allowed; + opacity: 0.45; +} + .form-block { display: flex; flex-direction: column; diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 63d0a16..c4dd81d 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -197,7 +197,6 @@ const routes = [ component: BattleSimulatorView, meta: { requiresAuth: true, - requiresGeneral: true, }, }, { diff --git a/app/game-frontend/src/views/BattleSimulatorView.vue b/app/game-frontend/src/views/BattleSimulatorView.vue index 9b6dec7..e8ced0d 100644 --- a/app/game-frontend/src/views/BattleSimulatorView.vue +++ b/app/game-frontend/src/views/BattleSimulatorView.vue @@ -26,6 +26,7 @@ type BattleExport = { type ExportedInfo = { objType: 'general'; data: GeneralExport } | { objType: 'battle'; data: BattleExport }; type GeneralListResponse = Awaited>; +type GeneralMeResponse = Awaited>; const loading = ref(true); const error = ref(null); @@ -72,6 +73,7 @@ const importTarget = ref(null); const generalList = ref(null); const generalListLoading = ref(false); const selectedGeneralId = ref(null); +const gameDefaults = ref(null); let generalIdSeed = 0; @@ -250,6 +252,7 @@ const initializeDefaults = async () => { try { const [context, me] = await Promise.all([trpc.battle.getSimulatorContext.query(), trpc.general.me.query()]); options.value = context; + gameDefaults.value = me; year.value = context.world.currentYear; month.value = context.world.currentMonth; repeatCnt.value = 1; @@ -283,6 +286,47 @@ const initializeDefaults = async () => { } }; +const hasGameGeneral = computed(() => !!gameDefaults.value?.general?.id); + +const applyGameEnvironment = () => { + if (!options.value) { + return; + } + const me = gameDefaults.value; + const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; + year.value = options.value.world.currentYear; + month.value = options.value.world.currentMonth; + attackerNation.type = me?.nation?.typeCode ?? nationTypeDefault; + defenderNation.type = attackerNation.type; + attackerNation.level = me?.nation?.level ?? 0; + defenderNation.level = attackerNation.level; + attackerNation.tech = me?.nation?.tech ? Math.floor(me.nation.tech / 1000) : 1; + defenderNation.tech = attackerNation.tech; + attackerCity.level = me?.city?.level ?? 5; + defenderCity.level = attackerCity.level; + defenderCity.def = me?.city?.defence ?? 1000; + defenderCity.wall = me?.city?.wall ?? 1000; + attackerNation.isCapital = !!me?.city && me.nation?.capitalCityId === me.city.id; + defenderNation.isCapital = attackerNation.isCapital; + error.value = null; +}; + +const applyIndependentEnvironment = () => { + if (!options.value) { + return; + } + const nationTypeDefault = options.value.nationTypes[0]?.key ?? 'che_중립'; + year.value = options.value.world.startYear; + month.value = 1; + seed.value = ''; + repeatCnt.value = 1; + Object.assign(attackerNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false }); + Object.assign(defenderNation, { type: nationTypeDefault, tech: 1, level: 0, isCapital: false }); + attackerCity.level = 5; + Object.assign(defenderCity, { level: 5, def: 1000, wall: 1000 }); + error.value = null; +}; + onMounted(() => { void initializeDefaults(); }); @@ -536,13 +580,19 @@ const runSimulation = async (action: BattleSimRequestPayload['action']) => { } isSimulating.value = true; + error.value = null; + if (action === 'battle') { + battleResult.value = null; + } statusMessage.value = action === 'battle' ? '전투를 진행 중입니다.' : '수비자 순서를 계산 중입니다.'; try { const payload = buildBattlePayload(action); const response = await trpc.battle.simulate.mutate(payload); const result = - 'payload' in response && response.payload ? response.payload : await waitForSimulationResult(response.jobId); + 'payload' in response && response.payload + ? response.payload + : await waitForSimulationResult(response.jobId); if (!result.result) { error.value = result.reason || 'battle_failed'; @@ -786,6 +836,10 @@ const loadGeneralList = async () => { }; const openImportModal = async (target: GeneralDraft) => { + if (!hasGameGeneral.value) { + error.value = '게임 장수를 보유한 사용자만 서버 장수 정보를 가져올 수 있습니다.'; + return; + } importTarget.value = target; importOpen.value = true; if (!generalList.value) { @@ -801,47 +855,62 @@ const closeImportModal = () => { importTarget.value = null; }; +const applyServerGeneral = async (target: GeneralDraft, generalId: number) => { + const response = await trpc.battle.getGeneralDetail.query({ generalId }); + applyGeneralExport(target, { + no: response.general.no, + name: response.general.name, + officerLevel: response.general.officer_level, + expLevel: response.general.explevel, + leadership: response.general.leadership, + strength: response.general.strength, + intel: response.general.intel, + horse: response.general.horse, + weapon: response.general.weapon, + book: response.general.book, + item: response.general.item, + injury: response.general.injury, + rice: response.general.rice, + personal: response.general.personal, + special2: response.general.special2, + crew: response.general.crew, + crewtype: response.general.crewtype, + atmos: response.general.atmos, + train: response.general.train, + dex1: response.general.dex1, + dex2: response.general.dex2, + dex3: response.general.dex3, + dex4: response.general.dex4, + dex5: response.general.dex5, + defenceTrain: response.general.defence_train, + warnum: response.general.warnum, + killnum: response.general.killnum, + killcrew: response.general.killcrew, + inheritBuff: createInheritBuff(), + }); + target.no = target === attackerGeneral.value ? 1 : resolveGeneralNo(response.general.no, target.id); +}; + +const applyMyGeneralToAttacker = async () => { + const generalId = gameDefaults.value?.general?.id; + if (!attackerGeneral.value || !generalId) { + error.value = '불러올 내 장수가 없습니다.'; + return; + } + try { + error.value = null; + await applyServerGeneral(attackerGeneral.value, generalId); + } catch (err) { + error.value = resolveErrorMessage(err); + } +}; + const confirmImport = async () => { if (!importTarget.value || !selectedGeneralId.value) { return; } try { - const response = await trpc.battle.getGeneralDetail.query({ generalId: selectedGeneralId.value }); - applyGeneralExport(importTarget.value, { - no: response.general.no, - name: response.general.name, - officerLevel: response.general.officer_level, - expLevel: response.general.explevel, - leadership: response.general.leadership, - strength: response.general.strength, - intel: response.general.intel, - horse: response.general.horse, - weapon: response.general.weapon, - book: response.general.book, - item: response.general.item, - injury: response.general.injury, - rice: response.general.rice, - personal: response.general.personal, - special2: response.general.special2, - crew: response.general.crew, - crewtype: response.general.crewtype, - atmos: response.general.atmos, - train: response.general.train, - dex1: response.general.dex1, - dex2: response.general.dex2, - dex3: response.general.dex3, - dex4: response.general.dex4, - dex5: response.general.dex5, - defenceTrain: response.general.defence_train, - warnum: response.general.warnum, - killnum: response.general.killnum, - killcrew: response.general.killcrew, - inheritBuff: createInheritBuff(), - }); - importTarget.value.no = - importTarget.value === attackerGeneral.value - ? 1 - : resolveGeneralNo(response.general.no, importTarget.value.id); + await applyServerGeneral(importTarget.value, selectedGeneralId.value); } catch (err) { error.value = resolveErrorMessage(err); } finally { @@ -882,19 +951,21 @@ const summaryRows = computed(() => { { label: '전투 페이즈', value: formatNumber(battleResult.value.phase) }, { label: '준 피해', - value: battleResult.value.minKilled !== battleResult.value.maxKilled - ? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber( - battleResult.value.maxKilled - )})` - : formatNumber(battleResult.value.killed), + value: + battleResult.value.minKilled !== battleResult.value.maxKilled + ? `${formatNumber(battleResult.value.killed)} (${formatNumber(battleResult.value.minKilled)} ~ ${formatNumber( + battleResult.value.maxKilled + )})` + : formatNumber(battleResult.value.killed), }, { label: '받은 피해', - value: battleResult.value.minDead !== battleResult.value.maxDead - ? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber( - battleResult.value.maxDead - )})` - : formatNumber(battleResult.value.dead), + value: + battleResult.value.minDead !== battleResult.value.maxDead + ? `${formatNumber(battleResult.value.dead)} (${formatNumber(battleResult.value.minDead)} ~ ${formatNumber( + battleResult.value.maxDead + )})` + : formatNumber(battleResult.value.dead), }, { label: '출병자 군량 소모', value: formatNumber(battleResult.value.attackerRice) }, { label: '수비자 군량 소모', value: formatNumber(battleResult.value.defenderRice) }, @@ -937,6 +1008,32 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);
+
+
+ 게임 상태와 분리된 모의 계산 +

+ 현재 연도·국가·도시는 시작값으로만 읽으며, 아래 편집과 전투 결과는 턴·DB·장수 상태를 변경하지 + 않습니다. +

+
+
+ + + +
+
+
{{ error }}
{{ statusMessage }}
@@ -1032,6 +1129,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value); :options="options!" mode="attacker" title="출병자 설정" + :can-import-server="hasGameGeneral" @import="openImportModal(attackerGeneral!)" @save="saveGeneral(attackerGeneral!)" @load="(payload) => handleGeneralLoad({ target: attackerGeneral!, file: payload.file })" @@ -1099,6 +1197,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value); :options="options!" mode="defender" :title="`수비자 설정 ${index + 1}`" + :can-import-server="hasGameGeneral" @import="openImportModal(defender)" @save="saveGeneral(defender)" @load="(payload) => handleGeneralLoad({ target: defender, file: payload.file })" @@ -1146,11 +1245,7 @@ const shouldShowUI = computed(() => !loading.value && !!options.value);