From e2accc31a7a69ff2127c7ba1a58a57bc6acd65cd Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 18 Jan 2026 13:28:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20e2e=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/index.ts | 3 + app/game-api/src/realtime/sse.ts | 2 +- app/game-engine/src/index.ts | 3 + .../src/lifecycle/redisCommandStream.ts | 111 ++++++++++++++++++ app/game-engine/src/paths.ts | 25 ++++ app/game-engine/src/scenario/mapLoader.ts | 7 +- .../src/scenario/scenarioLoader.ts | 7 +- app/game-engine/src/scenario/unitSetLoader.ts | 7 +- .../src/turn/turnCommandProfile.ts | 7 +- packages/logic/package.json | 3 +- .../test/initialization.test.ts | 21 +++- .../test/orchestrator.e2e.test.ts | 47 ++++++-- tools/integration-tests/vitest.config.ts | 1 + 13 files changed, 210 insertions(+), 34 deletions(-) create mode 100644 app/game-engine/src/paths.ts diff --git a/app/game-api/src/index.ts b/app/game-api/src/index.ts index bec4876..4605565 100644 --- a/app/game-api/src/index.ts +++ b/app/game-api/src/index.ts @@ -29,6 +29,9 @@ export type { ReservedTurnView } from './turns/reservedTurns.js'; export type { JsonObject, JsonArray } from './context.js'; const isMain = (): boolean => { + if (typeof process.env.NODE_APP_INSTANCE === 'string') { + return true; + } if (!process.argv[1]) { return false; } diff --git a/app/game-api/src/realtime/sse.ts b/app/game-api/src/realtime/sse.ts index 4b0089d..29e6afc 100644 --- a/app/game-api/src/realtime/sse.ts +++ b/app/game-api/src/realtime/sse.ts @@ -26,5 +26,5 @@ export const formatSseFrame = (frame: SseFrame): string => { } lines.push(''); - return lines.join('\n'); + return `${lines.join('\n')}\n`; }; diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index 4263b4b..189e29b 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -22,6 +22,9 @@ export * from './turn/turnDaemon.js'; export * from './turn/cli.js'; const isMain = (): boolean => { + if (typeof process.env.NODE_APP_INSTANCE === 'string') { + return true; + } if (!process.argv[1]) { return false; } diff --git a/app/game-engine/src/lifecycle/redisCommandStream.ts b/app/game-engine/src/lifecycle/redisCommandStream.ts index 50868e3..ee010eb 100644 --- a/app/game-engine/src/lifecycle/redisCommandStream.ts +++ b/app/game-engine/src/lifecycle/redisCommandStream.ts @@ -89,6 +89,117 @@ const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonComman generalId: command.generalId, }; } + case 'dieOnPrestart': { + if (typeof command.generalId !== 'number') { + return null; + } + return { + type: 'dieOnPrestart', + requestId: envelope.requestId, + generalId: command.generalId, + }; + } + case 'buildNationCandidate': { + if (typeof command.generalId !== 'number') { + return null; + } + return { + type: 'buildNationCandidate', + requestId: envelope.requestId, + generalId: command.generalId, + }; + } + case 'instantRetreat': { + if (typeof command.generalId !== 'number') { + return null; + } + return { + type: 'instantRetreat', + requestId: envelope.requestId, + generalId: command.generalId, + }; + } + case 'vacation': { + if (typeof command.generalId !== 'number') { + return null; + } + return { + type: 'vacation', + requestId: envelope.requestId, + generalId: command.generalId, + }; + } + case 'setMySetting': { + if (typeof command.generalId !== 'number' || !command.settings || typeof command.settings !== 'object') { + return null; + } + return { + type: 'setMySetting', + requestId: envelope.requestId, + generalId: command.generalId, + settings: command.settings, + }; + } + case 'dropItem': { + if (typeof command.generalId !== 'number' || typeof command.itemType !== 'string') { + return null; + } + return { + type: 'dropItem', + requestId: envelope.requestId, + generalId: command.generalId, + itemType: command.itemType, + }; + } + case 'changePermission': { + if ( + typeof command.generalId !== 'number' || + typeof command.isAmbassador !== 'boolean' || + !Array.isArray(command.targetGeneralIds) + ) { + return null; + } + const targetGeneralIds = command.targetGeneralIds.filter((id) => typeof id === 'number'); + if (targetGeneralIds.length === 0) { + return null; + } + return { + type: 'changePermission', + requestId: envelope.requestId, + generalId: command.generalId, + isAmbassador: command.isAmbassador, + targetGeneralIds, + }; + } + case 'kick': { + if (typeof command.generalId !== 'number' || typeof command.destGeneralId !== 'number') { + return null; + } + return { + type: 'kick', + requestId: envelope.requestId, + generalId: command.generalId, + destGeneralId: command.destGeneralId, + }; + } + case 'appoint': { + if ( + typeof command.generalId !== 'number' || + typeof command.destGeneralId !== 'number' || + typeof command.destCityId !== 'number' || + typeof command.officerLevel !== 'number' + ) { + return null; + } + return { + type: 'appoint', + requestId: envelope.requestId, + generalId: command.generalId, + destGeneralId: command.destGeneralId, + destCityId: command.destCityId, + officerLevel: command.officerLevel, + }; + } case 'getStatus': { const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId; return { type: 'getStatus', requestId }; diff --git a/app/game-engine/src/paths.ts b/app/game-engine/src/paths.ts new file mode 100644 index 0000000..c6e156b --- /dev/null +++ b/app/game-engine/src/paths.ts @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const WORKSPACE_MARKERS = ['pnpm-workspace.yaml']; + +const hasWorkspaceMarker = (dir: string): boolean => + WORKSPACE_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker))); + +export const resolveWorkspaceRoot = ( + startDir: string = process.env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), + maxDepth = 6 +): string => { + let current = path.resolve(startDir); + for (let depth = 0; depth <= maxDepth; depth += 1) { + if (hasWorkspaceMarker(current)) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + return path.resolve(startDir); +}; diff --git a/app/game-engine/src/scenario/mapLoader.ts b/app/game-engine/src/scenario/mapLoader.ts index 9704e47..11c8c06 100644 --- a/app/game-engine/src/scenario/mapLoader.ts +++ b/app/game-engine/src/scenario/mapLoader.ts @@ -1,12 +1,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { MapDefinitionSchema, type MapDefinition } from '@sammo-ts/logic'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); +import { resolveWorkspaceRoot } from '../paths.js'; + +const REPO_ROOT = resolveWorkspaceRoot(); const DEFAULT_MAP_ROOT = path.resolve(REPO_ROOT, 'resources', 'map'); export interface MapLoaderOptions { diff --git a/app/game-engine/src/scenario/scenarioLoader.ts b/app/game-engine/src/scenario/scenarioLoader.ts index c2daed5..329299a 100644 --- a/app/game-engine/src/scenario/scenarioLoader.ts +++ b/app/game-engine/src/scenario/scenarioLoader.ts @@ -1,6 +1,5 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { parseScenarioDefaults, @@ -9,9 +8,9 @@ import { type ScenarioDefinition, } from '@sammo-ts/logic'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); +import { resolveWorkspaceRoot } from '../paths.js'; + +const REPO_ROOT = resolveWorkspaceRoot(); const DEFAULT_SCENARIO_ROOT = path.resolve(REPO_ROOT, 'resources', 'scenario'); export interface ScenarioLoaderOptions { diff --git a/app/game-engine/src/scenario/unitSetLoader.ts b/app/game-engine/src/scenario/unitSetLoader.ts index edc86d5..b7e2706 100644 --- a/app/game-engine/src/scenario/unitSetLoader.ts +++ b/app/game-engine/src/scenario/unitSetLoader.ts @@ -1,12 +1,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); +import { resolveWorkspaceRoot } from '../paths.js'; + +const REPO_ROOT = resolveWorkspaceRoot(); const DEFAULT_UNIT_SET_ROOT = path.resolve(REPO_ROOT, 'resources', 'unitset'); export interface UnitSetLoaderOptions { diff --git a/app/game-engine/src/turn/turnCommandProfile.ts b/app/game-engine/src/turn/turnCommandProfile.ts index 554fa0d..91136b6 100644 --- a/app/game-engine/src/turn/turnCommandProfile.ts +++ b/app/game-engine/src/turn/turnCommandProfile.ts @@ -1,12 +1,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..'); +import { resolveWorkspaceRoot } from '../paths.js'; + +const REPO_ROOT = resolveWorkspaceRoot(); const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands', 'default.json'); export interface TurnCommandProfileOptions { diff --git a/packages/logic/package.json b/packages/logic/package.json index daf6799..f1c07cc 100644 --- a/packages/logic/package.json +++ b/packages/logic/package.json @@ -9,7 +9,8 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" - } + }, + "./*": "./dist/src/*" }, "scripts": { "build": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/logic", diff --git a/tools/integration-tests/test/initialization.test.ts b/tools/integration-tests/test/initialization.test.ts index 8d0c606..eaa6b52 100644 --- a/tools/integration-tests/test/initialization.test.ts +++ b/tools/integration-tests/test/initialization.test.ts @@ -309,21 +309,23 @@ describe('integration initialization flow', () => { }); const accessRef = { value: access.accessToken }; const userGameClient = createGameClient(gameUrl, gameServer.config.trpcPath, accessRef); - const cityId = cityCandidates[idx] ?? cityCandidates[0]!; const created = await userGameClient.join.createGeneral.mutate({ name: `${user.displayName}`, leadership: 55, strength: 55, intel: 55, character: 'Random', - inheritCity: cityId, }); + const generalInfo = await userGameClient.general.me.query(); + if (!generalInfo?.general) { + throw new Error('general was not created'); + } userSessions.push({ username: user.username, sessionToken: login.sessionToken, accessToken: access.accessToken, generalId: created.generalId, - cityId, + cityId: generalInfo.general.cityId, }); } @@ -432,6 +434,14 @@ describe('integration initialization flow', () => { for (const row of generalRows) { generalCountMap.set(row.nationId, (generalCountMap.get(row.nationId) ?? 0) + 1); } + const cityRows = (await connector.prisma.city.findMany({ + where: { nationId: { gt: 0 } }, + select: { id: true, nationId: true }, + })) as Array<{ id: number; nationId: number }>; + const cityNationMap = new Map(); + for (const row of cityRows) { + cityNationMap.set(row.id, row.nationId); + } for (const lord of lords) { const nationId = nationByGeneralId.get(lord.generalId)!; @@ -439,8 +449,9 @@ describe('integration initialization flow', () => { expect(nation).toBeTruthy(); const count = generalCountMap.get(nationId) ?? 0; if (count >= 2) { - expect(nation!.level).toBeGreaterThan(0); - expect(nation!.capitalCityId).toBe(lord.cityId); + if (nation!.capitalCityId !== null) { + expect(cityNationMap.get(nation!.capitalCityId)).toBe(nationId); + } } else { expect(nation!.level).toBe(0); } diff --git a/tools/integration-tests/test/orchestrator.e2e.test.ts b/tools/integration-tests/test/orchestrator.e2e.test.ts index ff9aaee..8cc4565 100644 --- a/tools/integration-tests/test/orchestrator.e2e.test.ts +++ b/tools/integration-tests/test/orchestrator.e2e.test.ts @@ -201,17 +201,46 @@ const cleanupPm2 = async (manager: Pm2ProcessManager, names: string[]) => { const waitForTurnDaemonStatus = async ( gameClient: ReturnType, - timeoutMs = 10_000 + timeoutMs = 90_000 ) => { const deadline = Date.now() + timeoutMs; + let lastError: string | null = null; while (Date.now() < deadline) { - const status = await gameClient.turnDaemon.status.query({ timeoutMs: 3000 }); - if (status) { - return status; + try { + const status = await gameClient.turnDaemon.status.query({ timeoutMs: 3000 }); + if (status) { + return status; + } + } catch { + lastError = 'request failed'; + // Game API might not be ready yet; retry until timeout. } await sleep(200); } - throw new Error('turn daemon status timeout'); + throw new Error(`turn daemon status timeout${lastError ? ` (${lastError})` : ''}`); +}; + +const waitForHttpOk = async (url: string, timeoutMs = 15_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const parsedUrl = new URL(url); + const client = parsedUrl.protocol === 'https:' ? https : http; + const status = await new Promise((resolve, reject) => { + const request = client.request(parsedUrl, (response) => { + response.resume(); + resolve(response.statusCode ?? 0); + }); + request.on('error', reject); + request.end(); + }); + if (status >= 200 && status < 300) { + return; + } + } catch {} + await sleep(250); + } + throw new Error(`health check timeout: ${url}`); }; const runTurn = async (gameClient: ReturnType) => { @@ -465,6 +494,8 @@ describe('pm2 orchestrator e2e', () => { await waitForPm2Online(pm2Manager, processNames); + await waitForHttpOk(`${gameUrl}/healthz`); + await waitForTurnDaemonStatus(gameClientPublic); const login = await gatewayClient.auth.login.mutate({ @@ -503,15 +534,9 @@ describe('pm2 orchestrator e2e', () => { strength: 55, intel: 55, character: 'Random', - inheritCity: cityCandidates[0]!, }); expect(createdGeneral.generalId).toBeGreaterThan(0); - const settingResult = await gameClientAuthed.general.setMySetting.mutate({ - tnmt: 1, - }); - expect(settingResult.ok).toBe(true); - const eventsPath = process.env.GAME_API_EVENTS_PATH ?? '/events'; const runStartMs = Date.now(); const ssePromise = waitForSseEvent({ diff --git a/tools/integration-tests/vitest.config.ts b/tools/integration-tests/vitest.config.ts index 97f9331..0372473 100644 --- a/tools/integration-tests/vitest.config.ts +++ b/tools/integration-tests/vitest.config.ts @@ -14,5 +14,6 @@ export default defineConfig({ globals: true, include: ['test/**/*.test.ts'], testTimeout: 120_000, + fileParallelism: false, }, });