From 1a1d0b2d9169e84df583000f63ae4b1e265c1ebc Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 7 Aug 2026 10:13:29 +0000 Subject: [PATCH] refactor: enforce package boundaries --- AGENTS.md | 4 + README.md | 3 + app/game-api/src/battleSim/environment.ts | 2 +- app/game-api/src/battleSim/unitSetLoader.ts | 39 -- app/game-api/src/maps/mapDefinition.ts | 26 +- app/game-api/src/maps/mapLayout.ts | 335 ++---------------- app/game-api/src/paths.ts | 18 - app/game-api/src/router/general/index.ts | 2 +- app/game-api/src/router/join/index.ts | 2 +- .../nation/endpoints/getBattleCenter.ts | 2 +- .../router/nation/endpoints/getGeneralLog.ts | 2 +- .../router/nation/endpoints/getNationInfo.ts | 11 +- .../nation/endpoints/getSecretGeneralList.ts | 2 +- app/game-api/src/router/npc/index.ts | 2 +- app/game-api/src/router/public/index.ts | 2 +- app/game-api/src/router/world/index.ts | 2 +- app/game-api/src/router/yearbook/index.ts | 2 +- .../src/services/remoteContentImageStore.ts | 2 +- app/game-api/src/services/selectPool.ts | 2 +- app/game-api/src/turns/commandInput.ts | 2 +- app/game-api/src/turns/turnCommandProfile.ts | 30 -- app/game-api/test/mainRecordsRouter.test.ts | 2 +- app/game-api/test/mapLayout.test.ts | 54 +++ app/game-engine/src/paths.ts | 2 +- app/game-engine/src/turn/ai/distance.ts | 96 ----- app/game-engine/src/turn/ai/generalAi/core.ts | 2 +- .../src/turn/ai/generalAi/nation/capital.ts | 2 +- .../src/turn/ai/generalAi/nation/diplomacy.ts | 2 +- .../src/turn/reservedTurnCommands.ts | 2 + app/game-engine/src/turn/runtimeTrace.ts | 35 ++ app/game-engine/src/turn/selectPoolService.ts | 4 +- app/game-engine/test/runtimeTrace.test.ts | 33 ++ app/game-frontend/package.json | 6 +- .../src/account/remoteUserIconStore.ts | 9 +- .../src/orchestrator/gatewayOrchestrator.ts | 6 +- .../src/orchestrator/profileSeedCli.ts | 2 +- .../src/orchestrator/seedProfileDatabase.ts | 2 +- .../src/scenario/scenarioCatalog.ts | 15 +- app/gateway-frontend/package.json | 8 +- docs/architecture/overview.md | 5 + docs/architecture/package-boundaries.md | 67 ++++ docs/developer/code-map.md | 7 +- docs/developer/index.md | 2 + docs/index.md | 2 + package.json | 2 + packages/infra/package.json | 1 + packages/infra/src/gamePrisma.ts | 2 +- packages/infra/src/gatewayPrisma.ts | 7 +- packages/infra/src/index.ts | 10 +- packages/infra/src/logRepository.ts | 4 +- packages/infra/src/turnEngineDb.ts | 4 +- packages/infra/tsconfig.json | 3 +- packages/logic/src/actions/turn/commandEnv.ts | 2 + .../src/actions/turn/general/che_기술연구.ts | 24 +- .../src/actions/turn/general/che_출병.ts | 56 +-- packages/logic/src/index.ts | 1 + packages/logic/src/ports/trace.ts | 15 + packages/logic/src/war/aftermath.ts | 19 +- packages/logic/src/war/types.ts | 2 + packages/logic/src/world/distance.ts | 92 +++++ packages/logic/test/world/distance.test.ts | 33 +- pnpm-lock.yaml | 45 ++- tools/check-package-boundaries.mjs | 208 +++++++++++ tools/check-package-boundaries.test.mjs | 53 +++ 64 files changed, 812 insertions(+), 628 deletions(-) delete mode 100644 app/game-api/src/battleSim/unitSetLoader.ts delete mode 100644 app/game-api/src/paths.ts delete mode 100644 app/game-api/src/turns/turnCommandProfile.ts create mode 100644 app/game-api/test/mapLayout.test.ts delete mode 100644 app/game-engine/src/turn/ai/distance.ts create mode 100644 app/game-engine/src/turn/runtimeTrace.ts create mode 100644 app/game-engine/test/runtimeTrace.test.ts create mode 100644 docs/architecture/package-boundaries.md create mode 100644 packages/logic/src/ports/trace.ts create mode 100644 tools/check-package-boundaries.mjs create mode 100644 tools/check-package-boundaries.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 830a552..339fbd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -272,12 +272,16 @@ profile별 정확한 포트와 game frontend/API 변수는 ```sh CI=1 pnpm typecheck +pnpm check:architecture pnpm lint pnpm test pnpm build ``` - 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요. +- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해 + 주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra + adapter로 분리합니다. - `pnpm test`의 skip 수를 pass처럼 보고하지 말아 주세요. - Vitest file/name filter는 package script 뒤에 불필요한 `--`를 넣지 말아 주세요. 예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts` diff --git a/README.md b/README.md index 6ad51d8..710f7e5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ 위치는 [개발자 핸드북](docs/developer/index.md)에서 확인해 주세요. ref entry point와 core 구현의 대응 근거는 상위 작업공간의 `../docs/ref-core2026-mapping.md`에 있습니다. +패키지 의존 방향과 파일 배치 규칙은 +[패키지와 파일 경계](docs/architecture/package-boundaries.md)에 고정되어 있으며 +`pnpm check:architecture`로 검사합니다. ## 런타임 경계 diff --git a/app/game-api/src/battleSim/environment.ts b/app/game-api/src/battleSim/environment.ts index 9e95fcf..e31f570 100644 --- a/app/game-api/src/battleSim/environment.ts +++ b/app/game-api/src/battleSim/environment.ts @@ -1,6 +1,6 @@ import type { WorldStateRow } from '../context.js'; import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.js'; -import { loadUnitSetDefinitionByName } from './unitSetLoader.js'; +import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; import { normalizeScenarioEffect, type ScenarioEffectKey, type WarEngineConfig } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; import type { UnitSetDefinition } from '@sammo-ts/logic'; diff --git a/app/game-api/src/battleSim/unitSetLoader.ts b/app/game-api/src/battleSim/unitSetLoader.ts deleted file mode 100644 index b7e2706..0000000 --- a/app/game-api/src/battleSim/unitSetLoader.ts +++ /dev/null @@ -1,39 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic'; - -import { resolveWorkspaceRoot } from '../paths.js'; - -const REPO_ROOT = resolveWorkspaceRoot(); -const DEFAULT_UNIT_SET_ROOT = path.resolve(REPO_ROOT, 'resources', 'unitset'); - -export interface UnitSetLoaderOptions { - unitSetRoot?: string; - filePrefix?: string; -} - -const readJsonFile = async (filePath: string): Promise => { - const raw = await fs.readFile(filePath, 'utf8'); - return JSON.parse(raw) as unknown; -}; - -const resolveUnitSetRoot = (options?: UnitSetLoaderOptions): string => options?.unitSetRoot ?? DEFAULT_UNIT_SET_ROOT; - -export const resolveUnitSetDefinitionPath = (unitSetName: string, options?: UnitSetLoaderOptions): string => { - const prefix = options?.filePrefix ?? 'unitset_'; - return path.resolve(resolveUnitSetRoot(options), `${prefix}${unitSetName}.json`); -}; - -export const loadUnitSetDefinition = async (unitSetPath: string): Promise => { - const raw = await readJsonFile(unitSetPath); - return parseUnitSetDefinition(raw); -}; - -export const loadUnitSetDefinitionByName = async ( - unitSetName: string, - options?: UnitSetLoaderOptions -): Promise => { - const unitSetPath = resolveUnitSetDefinitionPath(unitSetName, options); - return loadUnitSetDefinition(unitSetPath); -}; diff --git a/app/game-api/src/maps/mapDefinition.ts b/app/game-api/src/maps/mapDefinition.ts index 41a27a3..bec9cf6 100644 --- a/app/game-api/src/maps/mapDefinition.ts +++ b/app/game-api/src/maps/mapDefinition.ts @@ -1,25 +1,6 @@ -import fs from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; +import { loadMapDefinitionByName as loadRuntimeMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js'; +import type { MapDefinition } from '@sammo-ts/logic'; -import { MapDefinitionSchema, type MapDefinition } from '@sammo-ts/logic'; - -const resolveWorkspaceRoot = (): string => { - let current = path.resolve(process.cwd()); - for (let depth = 0; depth <= 6; depth += 1) { - if (existsSync(path.join(current, 'pnpm-workspace.yaml'))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - return path.resolve(process.cwd()); -}; - -const RESOURCE_MAP_ROOT = path.resolve(resolveWorkspaceRoot(), 'resources/map'); const mapCache = new Map(); export const loadMapDefinitionByName = async (mapName: string): Promise => { @@ -30,8 +11,7 @@ export const loadMapDefinitionByName = async (mapName: string): Promise; } -interface ParsedCityConst { - initCity?: unknown[]; - regionMap?: Record; - levelMap?: Record; +export interface MapLayoutLoaderOptions { + loadScenario?: (scenarioId: number) => Promise; + loadMap?: typeof loadMapDefinitionByName; } -const LEGACY_SCENARIO_ROOT = path.resolve(process.cwd(), 'legacy/hwe/scenario'); -const LEGACY_MAP_ROOT = path.resolve(LEGACY_SCENARIO_ROOT, 'map'); -const LEGACY_CITY_CONST = path.resolve(process.cwd(), 'legacy/hwe/sammo/CityConstBase.php'); - const layoutCache = new Map(); -const stripComments = (value: string): string => value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); - -const extractPhpArray = (source: string, marker: string): string | null => { - const idx = source.indexOf(marker); - if (idx < 0) { +const parseScenarioId = (scenario: string): number | null => { + const normalized = scenario.replace(/^scenario_/i, '').replace(/\.json$/i, ''); + if (!/^\d+$/.test(normalized)) { return null; } - const start = source.indexOf('[', idx); - if (start < 0) { - return null; - } - - let depth = 0; - let inString = false; - let stringChar = ''; - - for (let i = start; i < source.length; i += 1) { - const char = source[i]; - if (inString) { - if (char === stringChar && source[i - 1] !== '\\') { - inString = false; - } - continue; - } - if (char === '"' || char === "'") { - inString = true; - stringChar = char; - continue; - } - if (char === '[') { - depth += 1; - continue; - } - if (char === ']') { - depth -= 1; - if (depth === 0) { - return source.slice(start, i + 1); - } - } - } - - return null; + const scenarioId = Number(normalized); + return Number.isSafeInteger(scenarioId) ? scenarioId : null; }; -const parsePhpArray = (input: string): unknown => { - let index = 0; - - const skipWhitespace = () => { - while (index < input.length && /\s/.test(input[index] ?? '')) { - index += 1; - } - }; - - const parseString = () => { - const quote = input[index]; - index += 1; - let value = ''; - while (index < input.length) { - const char = input[index]; - if (char === quote && input[index - 1] !== '\\') { - index += 1; - return value; - } - value += char; - index += 1; - } - return value; - }; - - const parseNumber = () => { - let raw = ''; - while (index < input.length && /[0-9.+-]/.test(input[index] ?? '')) { - raw += input[index]; - index += 1; - } - return Number(raw); - }; - - const parseValue = (): unknown => { - skipWhitespace(); - const char = input[index]; - if (!char) { - return null; - } - if (char === '[') { - return parseArray(); - } - if (char === '"' || char === "'") { - return parseString(); - } - if (/[0-9.+-]/.test(char)) { - return parseNumber(); - } - if (input.startsWith('true', index)) { - index += 4; - return true; - } - if (input.startsWith('false', index)) { - index += 5; - return false; - } - if (input.startsWith('null', index)) { - index += 4; - return null; - } - return null; - }; - - const parseArray = (): unknown => { - const output: unknown[] = []; - const objectOutput: Record = {}; - let hasKeyed = false; - index += 1; - - while (index < input.length) { - skipWhitespace(); - if (input[index] === ']') { - index += 1; - break; - } - const keyOrValue = parseValue(); - skipWhitespace(); - if (input.slice(index, index + 2) === '=>') { - hasKeyed = true; - index += 2; - const value = parseValue(); - objectOutput[String(keyOrValue)] = value; - } else if (keyOrValue !== null) { - output.push(keyOrValue); - } - skipWhitespace(); - if (input[index] === ',') { - index += 1; - } - } - - if (hasKeyed) { - return objectOutput; - } - return output; - }; - - return parseValue(); -}; - -const parseCityConstFile = async (filePath: string): Promise => { +const resolveMapName = async ( + scenario: string, + loadScenario: (scenarioId: number) => Promise +): Promise => { + const scenarioId = parseScenarioId(scenario); + if (scenarioId === null) { + return 'che'; + } try { - const raw = await fs.readFile(filePath, 'utf-8'); - const source = stripComments(raw); - const initCityRaw = extractPhpArray(source, '$initCity'); - const regionMapRaw = extractPhpArray(source, '$regionMap'); - const levelMapRaw = extractPhpArray(source, '$levelMap'); - - return { - initCity: initCityRaw ? (parsePhpArray(initCityRaw) as unknown[]) : undefined, - regionMap: regionMapRaw ? (parsePhpArray(regionMapRaw) as Record) : undefined, - levelMap: levelMapRaw ? (parsePhpArray(levelMapRaw) as Record) : undefined, - }; - } catch { - return {}; - } -}; - -const resolveScenarioFile = async (scenario: string): Promise => { - const normalized = scenario.replace(/\.json$/i, ''); - const candidates = [`${normalized}.json`, `scenario_${normalized}.json`, 'default.json']; - - for (const candidate of candidates) { - const fullPath = path.join(LEGACY_SCENARIO_ROOT, candidate); - try { - await fs.access(fullPath); - return fullPath; - } catch { - continue; - } - } - - return path.join(LEGACY_SCENARIO_ROOT, 'default.json'); -}; - -const resolveMapName = async (scenario: string): Promise => { - const scenarioPath = await resolveScenarioFile(scenario); - try { - const raw = await fs.readFile(scenarioPath, 'utf-8'); - const parsed = JSON.parse(raw) as { map?: { mapName?: string } }; - return parsed.map?.mapName ?? 'che'; + const definition = await loadScenario(scenarioId); + return definition.config.environment.mapName; } catch { + // 운영 DB가 보존된 상태에서 해당 commit에 scenario resource가 없을 수 + // 있으므로 기존 기본 map인 che로 안전하게 돌아갑니다. return 'che'; } }; -const buildLookupMap = (raw: Record | undefined) => { - const idToName: Record = {}; - const nameToId: Record = {}; - if (!raw) { - return { idToName, nameToId }; - } - - for (const [key, value] of Object.entries(raw)) { - const numericKey = Number(key); - if (typeof value === 'string' && Number.isFinite(numericKey)) { - idToName[numericKey] = value; - continue; - } - if (typeof value === 'number') { - nameToId[key] = value; - } - } - - return { idToName, nameToId }; -}; - -const normalizeInitCity = ( - initCity: unknown[], - levelMap: ReturnType, - regionMap: ReturnType -): MapLayoutCity[] => { - const rows = initCity.filter(Array.isArray) as unknown[][]; - const nameToId = new Map(); - - for (const row of rows) { - if (typeof row[0] === 'number' && typeof row[1] === 'string') { - nameToId.set(row[1], row[0]); - } - } - - return rows - .map((row) => { - const [id, name, levelLabel, _pop, _agri, _comm, _secu, _def, _wall, regionLabel, x, y, path] = row; - if (typeof id !== 'number' || typeof name !== 'string') { - return null; - } - - const levelValue = - typeof levelLabel === 'number' - ? levelLabel - : typeof levelLabel === 'string' - ? (levelMap.nameToId[levelLabel] ?? Number(levelLabel)) - : 0; - - const regionValue = - typeof regionLabel === 'number' - ? regionLabel - : typeof regionLabel === 'string' - ? (regionMap.nameToId[regionLabel] ?? Number(regionLabel)) - : 0; - - const pathNames = Array.isArray(path) ? (path as string[]) : []; - const pathIds = pathNames - .map((pathName) => nameToId.get(pathName)) - .filter((value): value is number => typeof value === 'number'); - - return { - id, - name, - level: Number.isFinite(levelValue) ? levelValue : 0, - region: Number.isFinite(regionValue) ? regionValue : 0, - x: typeof x === 'number' ? x : 0, - y: typeof y === 'number' ? y : 0, - path: pathIds, - } satisfies MapLayoutCity; - }) - .filter((value): value is MapLayoutCity => value !== null); -}; - -export const loadMapLayout = async (scenario: string): Promise => { - const mapName = await resolveMapName(scenario); - const cached = layoutCache.get(mapName); +export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise => { + const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById); + const useCache = !options.loadScenario && !options.loadMap; + const cached = useCache ? layoutCache.get(mapName) : undefined; if (cached) { return cached; } - const base = await parseCityConstFile(LEGACY_CITY_CONST); - const mapPath = path.join(LEGACY_MAP_ROOT, `${mapName}.php`); - const map = await parseCityConstFile(mapPath); - - const regionMapRaw = { - ...(base.regionMap ?? {}), - ...(map.regionMap ?? {}), - }; - const levelMapRaw = { - ...(base.levelMap ?? {}), - ...(map.levelMap ?? {}), - }; - - const regionMap = buildLookupMap(regionMapRaw); - const levelMap = buildLookupMap(levelMapRaw); - - const initCity = map.initCity ?? base.initCity ?? []; - let cityList = normalizeInitCity(initCity, levelMap, regionMap); - if (cityList.length === 0) { - const resourceMap = await loadMapDefinitionByName(mapName); - cityList = resourceMap.cities.map((city) => ({ + const map = await (options.loadMap ?? loadMapDefinitionByName)(mapName); + const layout: MapLayout = { + mapName, + cityList: map.cities.map((city) => ({ id: city.id, name: city.name, level: city.level, @@ -331,16 +73,13 @@ export const loadMapLayout = async (scenario: string): Promise => { x: city.position.x, y: city.position.y, path: [...city.connections], - })); - } - - const layout: MapLayout = { - mapName, - cityList, - regionMap: regionMap.idToName, - levelMap: levelMap.idToName, + })), + regionMap: {}, + levelMap: {}, }; - layoutCache.set(mapName, layout); + if (useCache) { + layoutCache.set(mapName, layout); + } return layout; }; diff --git a/app/game-api/src/paths.ts b/app/game-api/src/paths.ts deleted file mode 100644 index 4f17cea..0000000 --- a/app/game-api/src/paths.ts +++ /dev/null @@ -1,18 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -const hasWorkspaceMarker = (dir: string): boolean => fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')); - -export const resolveWorkspaceRoot = ( - startDir: string = process.env.GAME_WORKSPACE_ROOT ?? 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-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 01256fb..3f5c007 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { LogCategory, LogScope } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; import type { GameApiContext } from '../../context.js'; diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 4bd3fd8..e9393ca 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -21,7 +21,7 @@ import { ConflictingTurnDaemonCommandError, RejectedNpcPossessionCommandError, } from '../../daemon/databaseTransport.js'; -import { NpcPossessionError, reserveNpcPossessionCandidates } from '@sammo-ts/game-engine'; +import { NpcPossessionError, reserveNpcPossessionCandidates } from '@sammo-ts/game-engine/turn/npcPossessionService.js'; import { resolveNationScoutMessage } from '../nation/shared.js'; const resolveSelectionCommandResult = ( diff --git a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts index 63525a1..eba207a 100644 --- a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts +++ b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; -import { LogCategory } from '@sammo-ts/infra'; +import { LogCategory } from '@sammo-ts/logic'; import { accessAuthedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; diff --git a/app/game-api/src/router/nation/endpoints/getGeneralLog.ts b/app/game-api/src/router/nation/endpoints/getGeneralLog.ts index d280e5c..99b5836 100644 --- a/app/game-api/src/router/nation/endpoints/getGeneralLog.ts +++ b/app/game-api/src/router/nation/endpoints/getGeneralLog.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { LogCategory, LogScope } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import { authedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; diff --git a/app/game-api/src/router/nation/endpoints/getNationInfo.ts b/app/game-api/src/router/nation/endpoints/getNationInfo.ts index 5da19c3..0648943 100644 --- a/app/game-api/src/router/nation/endpoints/getNationInfo.ts +++ b/app/game-api/src/router/nation/endpoints/getNationInfo.ts @@ -1,8 +1,15 @@ import { TRPCError } from '@trpc/server'; import { asRecord } from '@sammo-ts/common'; -import { LogCategory, LogScope } from '@sammo-ts/infra'; -import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome } from '@sammo-ts/logic'; +import { + getGoldIncome, + getOutcome, + getRiceIncome, + getWallIncome, + getWarGoldIncome, + LogCategory, + LogScope, +} from '@sammo-ts/logic'; import { authedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; diff --git a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts index c4fede1..e3460b4 100644 --- a/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts +++ b/app/game-api/src/router/nation/endpoints/getSecretGeneralList.ts @@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server'; import { asRecord } from '@sammo-ts/common'; -import { loadUnitSetDefinitionByName } from '../../../battleSim/unitSetLoader.js'; +import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; import { accessAuthedProcedure } from '../../../trpc.js'; import { getMyGeneral } from '../../shared/general.js'; import { assertNationAccess, resolveNationPermission } from '../shared.js'; diff --git a/app/game-api/src/router/npc/index.ts b/app/game-api/src/router/npc/index.ts index 0a0af99..7cc6dac 100644 --- a/app/game-api/src/router/npc/index.ts +++ b/app/game-api/src/router/npc/index.ts @@ -5,7 +5,7 @@ import { asRecord, isRecord } from '@sammo-ts/common'; import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; -import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js'; +import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; import type { GameApiContext } from '../../context.js'; import { getMyGeneral } from '../shared/general.js'; import { resolveSecretPermission } from '../shared/secretPermission.js'; diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index e024401..c503ecc 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; import { asNumber, asRecord } from '@sammo-ts/common'; -import { LogCategory, LogScope } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import { z } from 'zod'; import type { GameApiContext } from '../../context.js'; diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 2c9d29f..36b8256 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -6,7 +6,7 @@ import { accessAuthedProcedure, authedProcedure, procedure, router } from '../.. import { asRecord, isRecord } from '@sammo-ts/common'; import { loadWorldMap } from '../../maps/worldMap.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; -import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js'; +import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; import { getMyGeneral, getOwnedGeneral } from '../shared/general.js'; import { getGeneralDirectory, getNationDirectory } from './directory.js'; diff --git a/app/game-api/src/router/yearbook/index.ts b/app/game-api/src/router/yearbook/index.ts index 6eb9d09..80eab2b 100644 --- a/app/game-api/src/router/yearbook/index.ts +++ b/app/game-api/src/router/yearbook/index.ts @@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { asRecord, isRecord } from '@sammo-ts/common'; -import { LogCategory, LogScope } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import type { GameApiContext } from '../../context.js'; import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js'; diff --git a/app/game-api/src/services/remoteContentImageStore.ts b/app/game-api/src/services/remoteContentImageStore.ts index 949de08..6dd2ec2 100644 --- a/app/game-api/src/services/remoteContentImageStore.ts +++ b/app/game-api/src/services/remoteContentImageStore.ts @@ -37,7 +37,7 @@ export class RemoteContentImageStore implements ContentImageUploadStore { 'x-image-request-id': requestId, 'x-image-signature': signature, }, - body: input.body, + body: new Uint8Array(input.body), }); if (!response.ok) { throw new Error(`Image repository upload failed with HTTP ${response.status}.`); diff --git a/app/game-api/src/services/selectPool.ts b/app/game-api/src/services/selectPool.ts index 265075d..508d567 100644 --- a/app/game-api/src/services/selectPool.ts +++ b/app/game-api/src/services/selectPool.ts @@ -9,4 +9,4 @@ export { type SelectPoolCandidateDto, type SelectPoolCandidateInfo, type SelectPoolReservationDto, -} from '@sammo-ts/game-engine'; +} from '@sammo-ts/game-engine/turn/selectPoolService.js'; diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 88614c0..75bc184 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -7,7 +7,7 @@ import { import { asRecord, isRecord } from '@sammo-ts/common'; import { z } from 'zod'; -import { loadTurnCommandProfile } from './turnCommandProfile.js'; +import { loadTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js'; export type TurnCommandOptionValue = string | number; diff --git a/app/game-api/src/turns/turnCommandProfile.ts b/app/game-api/src/turns/turnCommandProfile.ts deleted file mode 100644 index 91136b6..0000000 --- a/app/game-api/src/turns/turnCommandProfile.ts +++ /dev/null @@ -1,30 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; - -import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic'; - -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 { - filePath?: string; -} - -const readCommandProfile = async (filePath: string): Promise => { - const raw = await fs.readFile(filePath, 'utf8'); - return parseTurnCommandProfile(JSON.parse(raw) as unknown); -}; - -export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise => { - const filePath = options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH; - try { - return await readCommandProfile(filePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return DEFAULT_TURN_COMMAND_PROFILE; - } - throw error; - } -}; diff --git a/app/game-api/test/mainRecordsRouter.test.ts b/app/game-api/test/mainRecordsRouter.test.ts index a53923d..ad6f28c 100644 --- a/app/game-api/test/mainRecordsRouter.test.ts +++ b/app/game-api/test/mainRecordsRouter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; -import { LogCategory, LogScope } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; import type { DatabaseClient, GameApiContext } from '../src/context.js'; import { appRouter } from '../src/router.js'; diff --git a/app/game-api/test/mapLayout.test.ts b/app/game-api/test/mapLayout.test.ts new file mode 100644 index 0000000..d924ae6 --- /dev/null +++ b/app/game-api/test/mapLayout.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { loadMapLayout } from '../src/maps/mapLayout.js'; + +describe('map layout resource adapter', () => { + it('resolves the scenario map through the shared runtime loaders', async () => { + const loadScenario = vi.fn().mockResolvedValue({ + config: { environment: { mapName: 'custom-map' } }, + }); + const loadMap = vi.fn().mockResolvedValue({ + cities: [ + { + id: 7, + name: '테스트 도시', + level: 3, + region: 2, + position: { x: 11, y: 13 }, + connections: [8], + }, + ], + }); + + await expect(loadMapLayout('scenario_2601.json', { loadScenario, loadMap })).resolves.toEqual({ + mapName: 'custom-map', + cityList: [ + { + id: 7, + name: '테스트 도시', + level: 3, + region: 2, + x: 11, + y: 13, + path: [8], + }, + ], + regionMap: {}, + levelMap: {}, + }); + expect(loadScenario).toHaveBeenCalledWith(2601); + expect(loadMap).toHaveBeenCalledWith('custom-map'); + }); + + it('retains the che fallback for unknown preserved scenarios', async () => { + const loadMap = vi.fn().mockResolvedValue({ cities: [] }); + + await expect( + loadMapLayout('custom-runtime', { + loadScenario: vi.fn(), + loadMap, + }) + ).resolves.toMatchObject({ mapName: 'che' }); + expect(loadMap).toHaveBeenCalledWith('che'); + }); +}); diff --git a/app/game-engine/src/paths.ts b/app/game-engine/src/paths.ts index c6e156b..5e467d8 100644 --- a/app/game-engine/src/paths.ts +++ b/app/game-engine/src/paths.ts @@ -7,7 +7,7 @@ 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(), + startDir: string = process.env.GAME_WORKSPACE_ROOT ?? process.env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), maxDepth = 6 ): string => { let current = path.resolve(startDir); diff --git a/app/game-engine/src/turn/ai/distance.ts b/app/game-engine/src/turn/ai/distance.ts deleted file mode 100644 index 7998077..0000000 --- a/app/game-engine/src/turn/ai/distance.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { City, MapDefinition } from '@sammo-ts/logic'; - -const buildConnectionMap = (map: MapDefinition): Map => { - const result = new Map(); - for (const city of map.cities) { - result.set(city.id, city.connections ?? []); - } - return result; -}; - -export const searchAllDistanceByCityList = ( - map: MapDefinition, - cityIds: number[] -): Record> => { - if (cityIds.length === 0) { - return {}; - } - const connectionMap = buildConnectionMap(map); - const citySet = new Set(cityIds); - const result: Record> = {}; - - for (const startId of citySet) { - const distances: Record = { [startId]: 0 }; - const queue: Array<[number, number]> = [[startId, 0]]; - - while (queue.length > 0) { - const [currentId, dist] = queue.shift()!; - const connections = connectionMap.get(currentId) ?? []; - for (const nextId of connections) { - if (!citySet.has(nextId)) { - continue; - } - if (distances[nextId] !== undefined) { - continue; - } - distances[nextId] = dist + 1; - queue.push([nextId, dist + 1]); - } - } - - result[startId] = distances; - } - - return result; -}; - -export const searchAllDistanceByNationList = ( - map: MapDefinition, - cities: City[], - nationIds: number[], - suppliedCityOnly: boolean -): Record> => { - if (nationIds.length === 0) { - return {}; - } - const cityIds = cities - .filter((city) => nationIds.includes(city.nationId)) - .filter((city) => !suppliedCityOnly || city.supplyState > 0) - .map((city) => city.id); - return searchAllDistanceByCityList(map, cityIds); -}; - -export const isNeighbor = ( - map: MapDefinition, - cities: City[], - nationA: number, - nationB: number, - includeNoSupply = true -): boolean => { - if (nationA === nationB) { - return false; - } - const connectionMap = buildConnectionMap(map); - const nationACities = new Set( - cities - .filter((city) => city.nationId === nationA) - .filter((city) => includeNoSupply || city.supplyState > 0) - .map((city) => city.id) - ); - - const nationBCities = cities - .filter((city) => city.nationId === nationB) - .filter((city) => includeNoSupply || city.supplyState > 0) - .map((city) => city.id); - - for (const cityId of nationBCities) { - const connections = connectionMap.get(cityId) ?? []; - for (const adjId of connections) { - if (nationACities.has(adjId)) { - return true; - } - } - } - - return false; -}; diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index ca81677..0640725 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -30,7 +30,7 @@ import { valueFit, withCanonicalArgumentAliases, } from '../aiUtils.js'; -import { searchAllDistanceByNationList } from '../distance.js'; +import { searchAllDistanceByNationList } from '@sammo-ts/logic/world/distance.js'; import { generalActionHandlers } from '../generalAiGeneralActions.js'; import { nationActionHandlers } from '../generalAiNationActions.js'; import { resolveConstraintEnv, type ConstraintEnv } from './constraint.js'; diff --git a/app/game-engine/src/turn/ai/generalAi/nation/capital.ts b/app/game-engine/src/turn/ai/generalAi/nation/capital.ts index c2d96e0..9128cc4 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/capital.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/capital.ts @@ -1,7 +1,7 @@ import type { GeneralAI } from '../core.js'; import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { calcCityDevRatio } from '../../aiUtils.js'; -import { searchAllDistanceByCityList } from '../../distance.js'; +import { searchAllDistanceByCityList } from '@sammo-ts/logic/world/distance.js'; export const do천도 = (ai: GeneralAI) => { if (!ai.nation || !ai.nation.capitalCityId) { diff --git a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts index 64c1ff6..2bebe54 100644 --- a/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts +++ b/app/game-engine/src/turn/ai/generalAi/nation/diplomacy.ts @@ -1,6 +1,6 @@ import type { GeneralAI } from '../core.js'; import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.js'; -import { isNeighbor } from '../../distance.js'; +import { isNeighbor } from '@sammo-ts/logic/world/distance.js'; import { resolveNationIncome } from './helpers.js'; const isTechLimited = (ai: GeneralAI, tech: number): boolean => { diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index 4201bc9..f9b1c90 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -15,6 +15,7 @@ import { loadActionModuleBundle, } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; +import { createRuntimeTrace } from './runtimeTrace.js'; // legacy GameConstBase 기본값 const DEFAULT_GENERAL_GOLD = 1000; @@ -80,6 +81,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit const constValues = asRecord(config.const); return { + trace: createRuntimeTrace(), ...(unitSet ? { unitSet } : {}), scenarioEffect: config.environment.scenarioEffect ?? null, develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0), diff --git a/app/game-engine/src/turn/runtimeTrace.ts b/app/game-engine/src/turn/runtimeTrace.ts new file mode 100644 index 0000000..d0e5e0b --- /dev/null +++ b/app/game-engine/src/turn/runtimeTrace.ts @@ -0,0 +1,35 @@ +import type { TraceEvent, TracePort, TraceSubject } from '@sammo-ts/logic/ports/trace.js'; + +const parseIds = (value: string | undefined): Set => new Set(value?.split(',') ?? []); + +const intersects = (configured: ReadonlySet, requested: readonly number[] | undefined): boolean => + requested?.some((id) => configured.has(String(id))) ?? false; + +export const createRuntimeTrace = ( + env: NodeJS.ProcessEnv = process.env, + write: (line: string) => void = (line) => process.stdout.write(line) +): TracePort => { + const generalIds = parseIds(env.CORE_AI_TRACE_GENERAL_IDS); + const nationIds = parseIds(env.CORE_AI_TRACE_NATION_IDS); + const warTechNationIds = parseIds(env.CORE_WAR_TECH_TRACE_NATION_IDS); + + return { + isEnabled(event: TraceEvent, subject: TraceSubject = {}): boolean { + switch (event) { + case 'AI_ACTION_PATCH_TRACE': + return intersects(generalIds, subject.generalIds) || intersects(nationIds, subject.nationIds); + case 'AI_WAR_TRACE': + return intersects(generalIds, subject.generalIds); + case 'AI_WAR_FIXTURE_CORE': + return env.CORE_BATTLE_FIXTURE_TRACE === '1'; + case 'WAR_TECH_TRACE': + return intersects(warTechNationIds, subject.nationIds); + default: + return false; + } + }, + write(event: TraceEvent, payload: unknown): void { + write(`${event} ${JSON.stringify(payload)}\n`); + }, + }; +}; diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index da8cf26..55931dd 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -1,11 +1,13 @@ import { z } from 'zod'; import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; -import { GamePrisma, LogCategory, LogScope } from '@sammo-ts/infra'; +import { GamePrisma } from '@sammo-ts/infra'; import { EventDomesticTraitLoader, isEventDomesticTraitKey, isPersonalityTraitKey, + LogCategory, + LogScope, PERSONALITY_TRAIT_KEYS, simpleSerialize, } from '@sammo-ts/logic'; diff --git a/app/game-engine/test/runtimeTrace.test.ts b/app/game-engine/test/runtimeTrace.test.ts new file mode 100644 index 0000000..ec960df --- /dev/null +++ b/app/game-engine/test/runtimeTrace.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createRuntimeTrace } from '../src/turn/runtimeTrace.js'; + +describe('runtime trace adapter', () => { + it('maps environment filters to domain trace subjects', () => { + const trace = createRuntimeTrace({ + CORE_AI_TRACE_GENERAL_IDS: '3,7', + CORE_AI_TRACE_NATION_IDS: '11', + CORE_WAR_TECH_TRACE_NATION_IDS: '13', + CORE_BATTLE_FIXTURE_TRACE: '1', + }); + + expect(trace.isEnabled('AI_ACTION_PATCH_TRACE', { generalIds: [7] })).toBe(true); + expect(trace.isEnabled('AI_ACTION_PATCH_TRACE', { nationIds: [11] })).toBe(true); + expect(trace.isEnabled('AI_WAR_TRACE', { generalIds: [3] })).toBe(true); + expect(trace.isEnabled('WAR_TECH_TRACE', { nationIds: [13] })).toBe(true); + expect(trace.isEnabled('AI_WAR_FIXTURE_CORE')).toBe(true); + expect(trace.isEnabled('AI_WAR_TRACE', { generalIds: [9] })).toBe(false); + expect( + createRuntimeTrace({ CORE_AI_TRACE_GENERAL_IDS: ' 7' }).isEnabled('AI_WAR_TRACE', { generalIds: [7] }) + ).toBe(false); + }); + + it('preserves the legacy line protocol', () => { + const write = vi.fn(); + const trace = createRuntimeTrace({}, write); + + trace.write('AI_WAR_TRACE', { generalId: 7 }); + + expect(write).toHaveBeenCalledWith('AI_WAR_TRACE {"generalId":7}\n'); + }); +}); diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 6ed76c5..fb4a990 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -27,8 +27,6 @@ }, "dependencies": { "@sammo-ts/common": "workspace:*", - "@sammo-ts/game-api": "workspace:*", - "@sammo-ts/gateway-api": "workspace:*", "@sammo-ts/logic": "workspace:*", "@tiptap/extension-image": "^3.5.0", "@tiptap/extension-link": "^3.5.0", @@ -48,6 +46,10 @@ "zod": "^4.3.5" }, "devDependencies": { + "@sammo-ts/game-api": "workspace:*", + "@sammo-ts/game-engine": "workspace:*", + "@sammo-ts/gateway-api": "workspace:*", + "@sammo-ts/infra": "workspace:*", "@tailwindcss/vite": "^4.1.18", "@vitejs/plugin-vue": "^6.0.3", "autoprefixer": "^10.4.23", diff --git a/app/gateway-api/src/account/remoteUserIconStore.ts b/app/gateway-api/src/account/remoteUserIconStore.ts index fa9756a..35ab127 100644 --- a/app/gateway-api/src/account/remoteUserIconStore.ts +++ b/app/gateway-api/src/account/remoteUserIconStore.ts @@ -55,19 +55,14 @@ export class RemoteUserIconStore implements UserIconUploadStore { input.body ), }, - body: input.body, + body: new Uint8Array(input.body), }); if (!response.ok) { throw new Error(`Image repository upload failed with HTTP ${response.status}.`); } const picture = `users/core2026/${input.filename}`; const payload: unknown = await response.json(); - if ( - !payload || - typeof payload !== 'object' || - !('path' in payload) || - payload.path !== `icons/${picture}` - ) { + if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== `icons/${picture}`) { throw new Error('Image repository returned an unexpected upload path.'); } return { picture, publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${picture}` }; diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index a7f6275..fc0055e 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -3,7 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { createHash, randomBytes, randomUUID } from 'node:crypto'; -import { type ScenarioInstallOptions } from '@sammo-ts/game-engine'; +import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js'; import { createGamePostgresConnector, createRedisConnector, @@ -1630,9 +1630,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } private async clearTournamentRuntimeStateFromRedis(profileName: string): Promise { - const connector = createRedisConnector( - resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env) - ); + const connector = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env)); await connector.connect(); try { await clearTournamentRuntimeKeys(connector.client, profileName); diff --git a/app/gateway-api/src/orchestrator/profileSeedCli.ts b/app/gateway-api/src/orchestrator/profileSeedCli.ts index f1c0afc..7d7194c 100644 --- a/app/gateway-api/src/orchestrator/profileSeedCli.ts +++ b/app/gateway-api/src/orchestrator/profileSeedCli.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import type { ScenarioInstallOptions } from '@sammo-ts/game-engine'; +import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js'; import { seedProfileDatabase, type AdminSeedUser } from './seedProfileDatabase.js'; diff --git a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts index 9a8b135..b3f61af 100644 --- a/app/gateway-api/src/orchestrator/seedProfileDatabase.ts +++ b/app/gateway-api/src/orchestrator/seedProfileDatabase.ts @@ -1,4 +1,4 @@ -import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine'; +import { seedScenarioToDatabase, type ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js'; import type { GamePrisma } from '@sammo-ts/infra'; import { GameClock, asRecord, type GameClockMode } from '@sammo-ts/common'; diff --git a/app/gateway-api/src/scenario/scenarioCatalog.ts b/app/gateway-api/src/scenario/scenarioCatalog.ts index 0a3b303..dca08c6 100644 --- a/app/gateway-api/src/scenario/scenarioCatalog.ts +++ b/app/gateway-api/src/scenario/scenarioCatalog.ts @@ -3,11 +3,8 @@ import path from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { - composeScenarioResource, - loadScenarioDefinitionById, - resolveScenarioDefaultsPath, -} from '@sammo-ts/game-engine'; +import { composeScenarioResource } from '@sammo-ts/game-engine/scenario/scenarioComposition.js'; +import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js'; import { parseScenarioDefaults, parseScenarioDefinition, type ScenarioDefaults } from '@sammo-ts/logic'; import { resolveWorkspaceRoot } from '../orchestrator/workspaceRoot.js'; @@ -40,14 +37,12 @@ const SCENARIO_ROOT = path.join('resources', 'scenario'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const REPO_ROOT = resolveWorkspaceRoot(process.env.GATEWAY_WORKSPACE_ROOT ?? __dirname); +const SCENARIO_RESOURCE_ROOT = path.resolve(REPO_ROOT, SCENARIO_ROOT); const previewCache = new Map(); const defaultsCache = new Map(); -const resolveScenarioRoot = (): string => { - const defaultsPath = resolveScenarioDefaultsPath(); - return path.dirname(defaultsPath); -}; +const resolveScenarioRoot = (): string => SCENARIO_RESOURCE_ROOT; const runGit = (args: string[]): Promise<{ ok: boolean; output: string }> => new Promise((resolve) => { @@ -210,7 +205,7 @@ const countGeneralsByNation = ( }; const buildScenarioPreview = async (scenarioId: number): Promise => { - const scenario = await loadScenarioDefinitionById(scenarioId); + const scenario = await loadScenarioDefinitionById(scenarioId, { scenarioRoot: SCENARIO_RESOURCE_ROOT }); const resolveNationId = buildNationIdResolver(scenario.nations); const baseCounts = new Map(scenario.nations.map((nation) => [nation.id, 0])); diff --git a/app/gateway-frontend/package.json b/app/gateway-frontend/package.json index 8415e58..f40e72f 100644 --- a/app/gateway-frontend/package.json +++ b/app/gateway-frontend/package.json @@ -16,9 +16,6 @@ }, "dependencies": { "@sammo-ts/common": "workspace:*", - "@sammo-ts/game-api": "workspace:*", - "@sammo-ts/gateway-api": "workspace:*", - "@sammo-ts/logic": "workspace:*", "@trpc/client": "^11.8.1", "@trpc/server": "^11.8.1", "@vueuse/core": "^13.9.0", @@ -31,6 +28,11 @@ "zod": "^4.3.5" }, "devDependencies": { + "@sammo-ts/game-api": "workspace:*", + "@sammo-ts/game-engine": "workspace:*", + "@sammo-ts/gateway-api": "workspace:*", + "@sammo-ts/infra": "workspace:*", + "@sammo-ts/logic": "workspace:*", "@tailwindcss/vite": "^4.1.18", "@vitejs/plugin-vue": "^6.0.3", "autoprefixer": "^10.4.23", diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e83b2a4..c348b83 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -76,6 +76,11 @@ persistence는 `app/game-engine`이 제공합니다. `src/gatewayPrisma.ts`, `src/gamePrisma.ts`, `src/postgres.ts`, `src/redis.ts`가 연결과 client 생성을 담당합니다. +구체적인 import 방향과 파일 배치 기준은 +[패키지와 파일 경계](./package-boundaries.md)를 따릅니다. 순수 거리 계산과 +도메인 로그 enum은 `packages/logic`, resource 파일 loader와 trace 출력은 +app/infra adapter가 소유합니다. + ## 데이터 소유권 | 데이터 | 기준 저장소 | 주요 접근 경로 | diff --git a/docs/architecture/package-boundaries.md b/docs/architecture/package-boundaries.md new file mode 100644 index 0000000..ccaebfb --- /dev/null +++ b/docs/architecture/package-boundaries.md @@ -0,0 +1,67 @@ +# 패키지와 파일 경계 + +## 의존 방향 + +제품 소스의 의존 방향은 다음과 같습니다. + +```text +packages/common + ↑ +packages/logic ← packages/infra + ↑ ↑ + └──── app/game-engine ────┐ + ↑ │ + app/game-api app/gateway-api + ↑ ↑ + game-frontend gateway-frontend +``` + +화살표의 시작점이 끝점을 import합니다. `packages/logic`은 DB, Redis, 파일, +네트워크, 환경 변수와 stdout을 직접 사용하지 않습니다. 런타임 관찰이 필요한 +경우 `packages/logic/src/ports/`에 포트를 선언하고 app 계층에서 구현을 +주입합니다. Prisma 생성 타입과 connector는 `packages/infra`가 소유하며, +도메인 enum과 규칙은 생성 client에서 다시 export하지 않습니다. + +## 위치를 정하는 기준 + +| 위치 | 포함하는 코드 | 포함하지 않는 코드 | +| ----------------- | ----------------------------------------------------------- | ------------------------------------------------------- | +| `packages/common` | process 사이 직렬화 타입, 인증 token, 결정적 RNG, 범용 함수 | DB client, 파일 loader, 게임 mutation | +| `packages/logic` | 명령·전투·AI 계산, domain type, constraint, port interface | Prisma/Redis, `process.env`, stdout, 파일·HTTP 접근 | +| `packages/infra` | Prisma 생성 client, PostgreSQL/Redis connector와 repository | 도메인 규칙, API 인증·validation, process orchestration | +| `app/game-engine` | daemon 조립, resource loader, in-memory state, transaction | 재사용 가능한 순수 계산의 유일 구현 | +| `app/game-api` | tRPC/SSE, 인증, request validation, worker transport | daemon process entrypoint의 암묵 실행 | +| `app/gateway-api` | 계정·profile 정책, operation queue, PM2 orchestration | game-engine process entrypoint의 암묵 실행 | +| `app/*-frontend` | 브라우저 UI, store, 공개 API client | Node/DB runtime과 backend value import | + +Resource를 읽는 `scenarioLoader`, `mapLoader`, `unitSetLoader`, +`turnCommandProfile`은 game-engine runtime adapter가 소유합니다. 다른 app은 +동일 loader를 복사하지 않고 `@sammo-ts/game-engine/...`의 구체적인 subpath를 +사용합니다. `@sammo-ts/game-engine` 루트는 daemon process entrypoint이므로 +API 제품 소스에서 library처럼 import하지 않습니다. + +Frontend가 tRPC router shape를 참조할 때는 `import type`만 사용하고 backend +package를 `devDependencies`에 둡니다. 브라우저에서 실제 실행하는 공유 값만 +`common` 또는 `logic`의 browser-safe export에서 가져옵니다. + +## 자동 검사 + +```sh +pnpm check:architecture +pnpm test:architecture +``` + +`check:architecture`는 모든 `packages/*/src`와 `app/*/src`를 읽어 다음을 +검사합니다. + +- 허용되지 않은 workspace package 의존 +- common/logic의 DB·Redis·파일·네트워크 import와 직접 `fetch` +- logic의 직접 환경 변수·stdout 접근 +- frontend의 Node import와 backend value import +- API의 game-engine 루트 entrypoint import +- `infra`에서 도메인 로그 enum을 가져오는 코드 +- source import와 `package.json` dependency 종류의 불일치 + +Integration/E2E fixture는 실제 DB와 Node 파일 API를 사용할 수 있으므로 제품 +`src` 검사와 분리합니다. 테스트 예외는 제품 코드의 경계를 완화하는 근거가 +아닙니다. diff --git a/docs/developer/code-map.md b/docs/developer/code-map.md index 2de6cb2..b8f6e6c 100644 --- a/docs/developer/code-map.md +++ b/docs/developer/code-map.md @@ -33,12 +33,14 @@ core2026/ | turn daemon | `app/game-engine/src/turn/turnDaemon.ts` | lifecycle, loader, handler, flush | | daemon lease | `app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts` | `TurnDaemonLease` | | world load·flush | `worldLoader.ts`, `databaseHooks.ts` | `EngineStateManager`, game Prisma | +| package 경계 | `tools/check-package-boundaries.mjs` | source import, manifest dependency | | 장수·국가 명령 | `packages/logic/src/actions/turn` | constraint, command module, engine handler | | 전투 | `packages/logic/src/war` | action module, crew type, item, trait | +| 지도 거리 계산 | `packages/logic/src/world/distance.ts` | AI·명령이 공유하는 순수 BFS | | 월간 처리 | `app/game-engine/src/turn/monthly*.ts` | scenario event, world dirty state | | frontend route | `app/*-frontend/src/router/index.ts` | view, store, tRPC client | | schema | `packages/infra/prisma/*.prisma` | migration, client, loader | -| resource | `resources/` | scenario/map/unit-set loader | +| resource | `resources/` | `app/game-engine/src/scenario/*Loader.ts` | ## 변경 단위 @@ -53,6 +55,9 @@ Router의 input schema, procedure, actor 해석, transaction과 error를 먼저 ref entry point, SQL, RNG, log와 mutation 순서를 조사합니다. 순수 계산은 `packages/logic`, 실행 context와 persistence는 `app/game-engine`에 둡니다. Fixed-seed unit, 실제 DB integration과 ref 차등 fixture를 함께 갱신합니다. +새 package import나 파일 이동은 +[패키지 경계 문서](../architecture/package-boundaries.md)와 +`pnpm check:architecture`로 확인합니다. ### DB diff --git a/docs/developer/index.md b/docs/developer/index.md index 16ad2fb..85a2c94 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -8,6 +8,7 @@ | process·worker·daemon | [런타임 아키텍처](../architecture/runtime.md) | app server와 CLI | | profile·Gateway 배포 | [릴리스 운영 매뉴얼](../release-operations.md) | Admin GUI와 release-controller | | 파일 위치 | [파일 지도](./code-map.md) | router, handler, schema | +| package 의존 방향 | [패키지와 파일 경계](../architecture/package-boundaries.md) | `packages/*`, `app/*` | | 명령·전투·효과 | [도메인과 조립](./domain-and-classes.md) | `packages/logic` | | mutation·flush | [요청·턴·저장](./request-turn-persistence.md) | game API, game engine | | action module | [행동 모듈 프로토콜](../architecture/action-module-protocol.md) | `actionModules/` | @@ -16,6 +17,7 @@ ## 경계 - `packages/logic`은 계산과 규칙을 소유합니다. +- runtime I/O는 logic의 port를 app/infra adapter가 구현해 주입합니다. - `app/game-engine`은 clock, queue, AI, 월간 순서, transaction과 flush를 소유합니다. - `app/game-api`는 transport, 인증, input validation과 request acceptance를 diff --git a/docs/index.md b/docs/index.md index ba991ff..68e479f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,6 +34,8 @@ features: Gateway 배포는 [릴리스 운영 매뉴얼](./release-operations.md)을 따라 주세요. 게임 진행 시각과 운영 벽시계의 경계는 [게임 시계](./architecture/game-clock.md)에 설명합니다. +[패키지와 파일 경계](./architecture/package-boundaries.md)는 source import와 +폴더별 책임, 자동 검사 방법을 설명합니다. Ref 전용 수치·저장 표현 보정과 제거 절차는 [Ref 호환 shim 인벤토리](./ref-compatibility-shims.md)에 모아 둡니다. diff --git a/package.json b/package.json index e67d69c..1747476 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "check:legacy:nation": "node tools/compare-command-constraints.mjs --include '^Nation/' --check && node tools/compare-command-logs.mjs --include '^Nation/' --mode action --check", "check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check", "check:ref-compat-markers": "node tools/check-ref-compat-markers.mjs", + "check:architecture": "node tools/check-package-boundaries.mjs", + "test:architecture": "node --test tools/check-package-boundaries.test.mjs", "test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json", "typecheck:e2e:frontend-legacy": "tsc -p tools/frontend-legacy-parity/tsconfig.json --noEmit", "test:e2e:main-front-status-live": "node tools/frontend-legacy-parity/run-main-front-status-live.mjs", diff --git a/packages/infra/package.json b/packages/infra/package.json index 5e0c44c..ac03cc7 100644 --- a/packages/infra/package.json +++ b/packages/infra/package.json @@ -27,6 +27,7 @@ "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "@prisma/client-runtime-utils": "^7.2.0", + "@sammo-ts/logic": "workspace:*", "es-toolkit": "^1.43.0", "pg": "^8.16.3", "redis": "^5.10.0" diff --git a/packages/infra/src/gamePrisma.ts b/packages/infra/src/gamePrisma.ts index 2ab5125..61935c0 100644 --- a/packages/infra/src/gamePrisma.ts +++ b/packages/infra/src/gamePrisma.ts @@ -1,5 +1,5 @@ import { PrismaClient as GamePrismaClient } from '../prisma/generated/game/index.js'; -export { LogCategory, LogScope, Prisma as GamePrisma } from '../prisma/generated/game/index.js'; +export { Prisma as GamePrisma } from '../prisma/generated/game/index.js'; export type { PrismaClient as GamePrismaClient } from '../prisma/generated/game/index.js'; import type { PostgresConfig, PostgresConnector } from './postgres.js'; diff --git a/packages/infra/src/gatewayPrisma.ts b/packages/infra/src/gatewayPrisma.ts index 9657394..0f2809e 100644 --- a/packages/infra/src/gatewayPrisma.ts +++ b/packages/infra/src/gatewayPrisma.ts @@ -1,10 +1,5 @@ import { PrismaClient as GatewayPrismaClient } from '../prisma/generated/gateway/index.js'; -export { - GatewayBuildStatus, - GatewayProfileStatus, - OAuthType, - Prisma as GatewayPrisma, -} from '../prisma/generated/gateway/index.js'; +export { Prisma as GatewayPrisma } from '../prisma/generated/gateway/index.js'; export type { PrismaClient as GatewayPrismaClient } from '../prisma/generated/gateway/index.js'; import type { PostgresConfig, PostgresConnector } from './postgres.js'; diff --git a/packages/infra/src/index.ts b/packages/infra/src/index.ts index 25bf645..6dfab63 100644 --- a/packages/infra/src/index.ts +++ b/packages/infra/src/index.ts @@ -1,13 +1,7 @@ export * from './postgres.js'; -export { createGamePostgresConnector, GamePrisma, LogCategory, LogScope } from './gamePrisma.js'; +export { createGamePostgresConnector, GamePrisma } from './gamePrisma.js'; export type { GamePrismaClient } from './gamePrisma.js'; -export { - createGatewayPostgresConnector, - GatewayBuildStatus, - GatewayProfileStatus, - GatewayPrisma, - OAuthType, -} from './gatewayPrisma.js'; +export { createGatewayPostgresConnector, GatewayPrisma } from './gatewayPrisma.js'; export type { GatewayPrismaClient } from './gatewayPrisma.js'; export * from './db.js'; export * from './errorLogRepository.js'; diff --git a/packages/infra/src/logRepository.ts b/packages/infra/src/logRepository.ts index e6285fc..b71a4bd 100644 --- a/packages/infra/src/logRepository.ts +++ b/packages/infra/src/logRepository.ts @@ -1,4 +1,6 @@ -import { LogCategory, LogScope, type GamePrisma, type GamePrismaClient } from './gamePrisma.js'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; + +import type { GamePrisma, GamePrismaClient } from './gamePrisma.js'; export interface LogQueryOptions { limit?: number; diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index f7859e6..8f035b6 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -1,4 +1,6 @@ -import type { GamePrisma, LogCategory, LogScope } from './gamePrisma.js'; +import type { LogCategory, LogScope } from '@sammo-ts/logic'; + +import type { GamePrisma } from './gamePrisma.js'; export type JsonValue = GamePrisma.JsonValue; export type InputJsonValue = GamePrisma.InputJsonValue; diff --git a/packages/infra/tsconfig.json b/packages/infra/tsconfig.json index c7dc8b2..847688b 100644 --- a/packages/infra/tsconfig.json +++ b/packages/infra/tsconfig.json @@ -4,5 +4,6 @@ "outDir": "dist", "composite": true }, - "include": ["src", "test", "*.ts"] + "include": ["src", "test", "*.ts"], + "references": [{ "path": "../logic" }] } diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index b0b1f29..92bc1bc 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -4,6 +4,7 @@ import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; import type { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.js'; import type { RefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js'; import type { ScenarioEffectKey } from '@sammo-ts/logic/scenario/scenarioEffect.js'; +import type { TracePort } from '@sammo-ts/logic/ports/trace.js'; export interface TurnCommandItemCatalogEntry { slot: 'horse' | 'weapon' | 'book' | 'item'; @@ -17,6 +18,7 @@ export interface TurnCommandItemCatalogEntry { } export interface TurnCommandEnv { + trace?: TracePort; unitSet?: UnitSetDefinition; scenarioEffect?: ScenarioEffectKey | null; develCost: number; diff --git a/packages/logic/src/actions/turn/general/che_기술연구.ts b/packages/logic/src/actions/turn/general/che_기술연구.ts index 748d6a1..9641ab0 100644 --- a/packages/logic/src/actions/turn/general/che_기술연구.ts +++ b/packages/logic/src/actions/turn/general/che_기술연구.ts @@ -124,20 +124,26 @@ export class ActionDefinition< const generalCount = Math.max(context.nationGeneralCount, this.env.initialNationGenLimit); if ( - (process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(context.general.id)) || - (process.env.CORE_AI_TRACE_NATION_IDS?.split(',') ?? []).includes(String(context.nation.id)) + this.env.trace?.isEnabled('AI_ACTION_PATCH_TRACE', { + generalIds: [context.general.id], + nationIds: [context.nation.id], + }) ) { - process.stdout.write( - `AI_ACTION_PATCH_TRACE ${JSON.stringify({ engine: 'core-tech', generalId: context.general.id, nationId: context.nation.id, currentTech, techScore, nationGeneralCount: context.nationGeneralCount, generalCount, delta: techScore / generalCount })}\n` - ); + this.env.trace.write('AI_ACTION_PATCH_TRACE', { + engine: 'core-tech', + generalId: context.general.id, + nationId: context.nation.id, + currentTech, + techScore, + nationGeneralCount: context.nationGeneralCount, + generalCount, + delta: techScore / generalCount, + }); } context.nation.meta = { ...context.nation.meta, - tech: addLegacyStoredTech( - currentTech, - techScore / generalCount - ), + tech: addLegacyStoredTech(currentTech, techScore / generalCount), }; context.general.gold = Math.max(0, context.general.gold - result.costGold); context.general.experience += result.exp; diff --git a/packages/logic/src/actions/turn/general/che_출병.ts b/packages/logic/src/actions/turn/general/che_출병.ts index 703d69c..32fcd5e 100644 --- a/packages/logic/src/actions/turn/general/che_출병.ts +++ b/packages/logic/src/actions/turn/general/che_출병.ts @@ -39,6 +39,7 @@ import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/typ import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import { buildNationFrontStatePatches } from '../../../diplomacy/frontState.js'; +import type { TracePort } from '../../../ports/trace.js'; import { formatDestCityConstraintFailure } from '../constraintFailure.js'; import { buildWarAftermathConfig, @@ -380,7 +381,8 @@ export class ActionDefinition< constructor( modules: ReadonlyArray | null | undefined> = [], nationTraitModules: NationTraitModule[] = [], - generalModules: ReadonlyArray | null | undefined> = [] + generalModules: ReadonlyArray | null | undefined> = [], + private readonly trace?: TracePort ) { this.warModules = modules.filter(Boolean) as ReadonlyArray>; this.nationTraitModules = new Map(nationTraitModules.map((module) => [module.key, module])); @@ -572,29 +574,25 @@ export class ActionDefinition< (unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false) ) ); - const traceGeneralIds = new Set(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []); - const shouldTraceWar = - traceGeneralIds.has(String(context.general.id)) || - defenderGenerals.some((general) => traceGeneralIds.has(String(general.id))); + const battleGeneralIds = [context.general.id, ...defenderGenerals.map((general) => general.id)]; + const shouldTraceWar = this.trace?.isEnabled('AI_WAR_TRACE', { generalIds: battleGeneralIds }) ?? false; - if (process.env.CORE_BATTLE_FIXTURE_TRACE === '1') { - process.stdout.write( - `AI_WAR_FIXTURE_CORE ${JSON.stringify({ - action: 'battle', - seed, - repeatCnt: 1, - year: time.year, - month: time.month, - startYear: time.startYear, - scenarioEffect: null, - attackerGeneral: buildBattleGeneralFixture(context.general), - attackerCity: buildBattleCityFixture(attackerCity), - attackerNation: buildBattleNationFixture(attackerNation), - defenderGenerals: defenderGenerals.map(buildBattleGeneralFixture), - defenderCity: buildBattleCityFixture(defenderCity), - defenderNation: buildBattleNationFixture(defenderNation), - })}\n` - ); + if (this.trace?.isEnabled('AI_WAR_FIXTURE_CORE', { generalIds: battleGeneralIds })) { + this.trace.write('AI_WAR_FIXTURE_CORE', { + action: 'battle', + seed, + repeatCnt: 1, + year: time.year, + month: time.month, + startYear: time.startYear, + scenarioEffect: null, + attackerGeneral: buildBattleGeneralFixture(context.general), + attackerCity: buildBattleCityFixture(attackerCity), + attackerNation: buildBattleNationFixture(attackerNation), + defenderGenerals: defenderGenerals.map(buildBattleGeneralFixture), + defenderCity: buildBattleCityFixture(defenderCity), + defenderNation: buildBattleNationFixture(defenderNation), + }); } const battle = resolveWarBattle({ @@ -619,9 +617,7 @@ export class ActionDefinition< ...(shouldTraceWar ? { trace: (event) => { - process.stdout.write( - `AI_WAR_TRACE ${JSON.stringify({ generalId: context.general.id, event })}\n` - ); + this.trace?.write('AI_WAR_TRACE', { generalId: context.general.id, event }); }, } : {}), @@ -648,6 +644,7 @@ export class ActionDefinition< baseGain ); }, + ...(this.trace ? { trace: this.trace } : {}), }); // Ref ConquerCity() recalculates the fronts of every nation around the @@ -802,5 +799,10 @@ export const commandSpec: GeneralTurnCommandSpec = { availabilityArgs: { destCityId: 0 }, argsSchema: ARGS_SCHEMA, createDefinition: (env: TurnCommandEnv) => - new ActionDefinition(env.warActionModules ?? [], env.nationTraitModules ?? [], env.generalActionModules ?? []), + new ActionDefinition( + env.warActionModules ?? [], + env.nationTraitModules ?? [], + env.generalActionModules ?? [], + env.trace + ), }; diff --git a/packages/logic/src/index.ts b/packages/logic/src/index.ts index 16e583b..735b10c 100644 --- a/packages/logic/src/index.ts +++ b/packages/logic/src/index.ts @@ -17,6 +17,7 @@ export * from './inheritance/inheritBuff.js'; export * from './resources/index.js'; export * from './ports/world.js'; export * from './ports/worldSnapshot.js'; +export * from './ports/trace.js'; export * from './scenario/index.js'; export * from './triggers/index.js'; export * from './turn/index.js'; diff --git a/packages/logic/src/ports/trace.ts b/packages/logic/src/ports/trace.ts new file mode 100644 index 0000000..05c59e1 --- /dev/null +++ b/packages/logic/src/ports/trace.ts @@ -0,0 +1,15 @@ +export interface TraceSubject { + generalIds?: readonly number[]; + nationIds?: readonly number[]; +} + +export type TraceEvent = 'AI_ACTION_PATCH_TRACE' | 'AI_WAR_TRACE' | 'AI_WAR_FIXTURE_CORE' | 'WAR_TECH_TRACE'; + +/** + * 도메인 계산이 환경 변수나 stdout에 직접 의존하지 않도록 런타임이 주입하는 + * 진단 포트입니다. event 이름은 기존 비교 도구가 소비하는 출력 prefix입니다. + */ +export interface TracePort { + isEnabled(event: TraceEvent, subject?: TraceSubject): boolean; + write(event: TraceEvent, payload: unknown): void; +} diff --git a/packages/logic/src/war/aftermath.ts b/packages/logic/src/war/aftermath.ts index 10e11d8..5eb715e 100644 --- a/packages/logic/src/war/aftermath.ts +++ b/packages/logic/src/war/aftermath.ts @@ -134,10 +134,21 @@ const applyNationTechGain = ( // arithmetic starts from the stored binary32 value without a PHP text read. nation.meta.tech = Math.fround(currentTech + delta); // REF-COMPAT:END ref-mariadb-float-boundary - if ((process.env.CORE_WAR_TECH_TRACE_NATION_IDS?.split(',') ?? []).includes(String(nation.id))) { - process.stdout.write( - `WAR_TECH_TRACE ${JSON.stringify({ engine: 'core', nationId: nation.id, side: context.side, currentTech, baseGain, gain, total, effective, divisor, delta, storedTech: nation.meta.tech, attackerGeneralId: context.attackerReport.id })}\n` - ); + if (input.trace?.isEnabled('WAR_TECH_TRACE', { nationIds: [nation.id] })) { + input.trace.write('WAR_TECH_TRACE', { + engine: 'core', + nationId: nation.id, + side: context.side, + currentTech, + baseGain, + gain, + total, + effective, + divisor, + delta, + storedTech: nation.meta.tech, + attackerGeneralId: context.attackerReport.id, + }); } }; diff --git a/packages/logic/src/war/types.ts b/packages/logic/src/war/types.ts index 5118618..5472671 100644 --- a/packages/logic/src/war/types.ts +++ b/packages/logic/src/war/types.ts @@ -4,6 +4,7 @@ import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js'; import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import type { LogEntryDraft } from '@sammo-ts/logic/logging/types.js'; +import type { TracePort } from '@sammo-ts/logic/ports/trace.js'; import type { UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; import type { WarActionModule } from './actions.js'; import type { WarTriggerRegistry } from './triggers.js'; @@ -194,6 +195,7 @@ export interface WarAftermathInput | null | undefined>; calcNationTechGain?: (context: WarAftermathTechContext) => number; + trace?: TracePort; } export interface WarAftermathOutcome { diff --git a/packages/logic/src/world/distance.ts b/packages/logic/src/world/distance.ts index 2f25a38..f79e0ac 100644 --- a/packages/logic/src/world/distance.ts +++ b/packages/logic/src/world/distance.ts @@ -1,5 +1,97 @@ +import type { City } from '@sammo-ts/logic/domain/entities.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; +const buildConnectionMap = (map: MapDefinition): Map => { + const result = new Map(); + for (const city of map.cities) { + result.set(city.id, city.connections ?? []); + } + return result; +}; + +export const searchAllDistanceByCityList = ( + map: MapDefinition, + cityIds: number[] +): Record> => { + if (cityIds.length === 0) { + return {}; + } + const connectionMap = buildConnectionMap(map); + const citySet = new Set(cityIds); + const result: Record> = {}; + + for (const startId of citySet) { + const distances: Record = { [startId]: 0 }; + const queue: Array<[number, number]> = [[startId, 0]]; + + while (queue.length > 0) { + const [currentId, dist] = queue.shift()!; + const connections = connectionMap.get(currentId) ?? []; + for (const nextId of connections) { + if (!citySet.has(nextId) || distances[nextId] !== undefined) { + continue; + } + distances[nextId] = dist + 1; + queue.push([nextId, dist + 1]); + } + } + + result[startId] = distances; + } + + return result; +}; + +export const searchAllDistanceByNationList = ( + map: MapDefinition, + cities: City[], + nationIds: number[], + suppliedCityOnly: boolean +): Record> => { + if (nationIds.length === 0) { + return {}; + } + const cityIds = cities + .filter((city) => nationIds.includes(city.nationId)) + .filter((city) => !suppliedCityOnly || city.supplyState > 0) + .map((city) => city.id); + return searchAllDistanceByCityList(map, cityIds); +}; + +export const isNeighbor = ( + map: MapDefinition, + cities: City[], + nationA: number, + nationB: number, + includeNoSupply = true +): boolean => { + if (nationA === nationB) { + return false; + } + const connectionMap = buildConnectionMap(map); + const nationACities = new Set( + cities + .filter((city) => city.nationId === nationA) + .filter((city) => includeNoSupply || city.supplyState > 0) + .map((city) => city.id) + ); + + const nationBCities = cities + .filter((city) => city.nationId === nationB) + .filter((city) => includeNoSupply || city.supplyState > 0) + .map((city) => city.id); + + for (const cityId of nationBCities) { + for (const adjacentId of connectionMap.get(cityId) ?? []) { + if (nationACities.has(adjacentId)) { + return true; + } + } + } + + return false; +}; + export const getCityDistance = (map: MapDefinition, startCityId: number, endCityId: number): number => { if (startCityId === endCityId) return 0; diff --git a/packages/logic/test/world/distance.test.ts b/packages/logic/test/world/distance.test.ts index 5c08f27..c9e27e1 100644 --- a/packages/logic/test/world/distance.test.ts +++ b/packages/logic/test/world/distance.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { getCityDistance, searchDistance, searchDistanceEntries } from '@sammo-ts/logic/world/distance.js'; +import { + getCityDistance, + isNeighbor, + searchAllDistanceByCityList, + searchAllDistanceByNationList, + searchDistance, + searchDistanceEntries, +} from '@sammo-ts/logic/world/distance.js'; +import type { City } from '@sammo-ts/logic'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; describe('World Distance', () => { @@ -87,4 +95,27 @@ describe('World Distance', () => { ]); }); }); + + describe('AI distance projections', () => { + const cities = [ + { id: 1, nationId: 1, supplyState: 1 }, + { id: 2, nationId: 2, supplyState: 1 }, + { id: 3, nationId: 1, supplyState: 0 }, + ] as City[]; + + it('projects pairwise distances only across the selected city set', () => { + expect(searchAllDistanceByCityList(mockMap, [1, 2, 4])).toEqual({ + 1: { 1: 0, 2: 1, 4: 2 }, + 2: { 1: 1, 2: 0, 4: 1 }, + 4: { 1: 2, 2: 1, 4: 0 }, + }); + expect(searchAllDistanceByNationList(mockMap, cities, [1], true)).toEqual({ 1: { 1: 0 } }); + }); + + it('checks supplied and unsupplied borders with the requested policy', () => { + expect(isNeighbor(mockMap, cities, 1, 2, true)).toBe(true); + expect(isNeighbor(mockMap, cities, 1, 2, false)).toBe(true); + expect(isNeighbor(mockMap, cities, 1, 1, true)).toBe(false); + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b814576..dc7007e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,12 +158,6 @@ importers: '@sammo-ts/common': specifier: workspace:* version: link:../../packages/common - '@sammo-ts/game-api': - specifier: workspace:* - version: link:../game-api - '@sammo-ts/gateway-api': - specifier: workspace:* - version: link:../gateway-api '@sammo-ts/logic': specifier: workspace:* version: link:../../packages/logic @@ -216,6 +210,18 @@ importers: specifier: ^4.3.5 version: 4.3.5 devDependencies: + '@sammo-ts/game-api': + specifier: workspace:* + version: link:../game-api + '@sammo-ts/game-engine': + specifier: workspace:* + version: link:../game-engine + '@sammo-ts/gateway-api': + specifier: workspace:* + version: link:../gateway-api + '@sammo-ts/infra': + specifier: workspace:* + version: link:../../packages/infra '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.1(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) @@ -310,15 +316,6 @@ importers: '@sammo-ts/common': specifier: workspace:* version: link:../../packages/common - '@sammo-ts/game-api': - specifier: workspace:* - version: link:../game-api - '@sammo-ts/gateway-api': - specifier: workspace:* - version: link:../gateway-api - '@sammo-ts/logic': - specifier: workspace:* - version: link:../../packages/logic '@trpc/client': specifier: ^11.8.1 version: 11.8.1(@trpc/server@11.8.1(typescript@6.0.2))(typescript@6.0.2) @@ -350,6 +347,21 @@ importers: specifier: ^4.3.5 version: 4.3.5 devDependencies: + '@sammo-ts/game-api': + specifier: workspace:* + version: link:../game-api + '@sammo-ts/game-engine': + specifier: workspace:* + version: link:../game-engine + '@sammo-ts/gateway-api': + specifier: workspace:* + version: link:../gateway-api + '@sammo-ts/infra': + specifier: workspace:* + version: link:../../packages/infra + '@sammo-ts/logic': + specifier: workspace:* + version: link:../../packages/logic '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@7.3.0(@types/node@26.1.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) @@ -421,6 +433,9 @@ importers: '@prisma/client-runtime-utils': specifier: ^7.2.0 version: 7.2.0 + '@sammo-ts/logic': + specifier: workspace:* + version: link:../logic es-toolkit: specifier: ^1.43.0 version: 1.43.0 diff --git a/tools/check-package-boundaries.mjs b/tools/check-package-boundaries.mjs new file mode 100644 index 0000000..7e8dd1a --- /dev/null +++ b/tools/check-package-boundaries.mjs @@ -0,0 +1,208 @@ +import fs from 'node:fs/promises'; +import { builtinModules } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const zones = [ + { name: 'common', root: 'packages/common/src', allowed: [] }, + { name: 'logic', root: 'packages/logic/src', allowed: ['@sammo-ts/common'] }, + { name: 'infra', root: 'packages/infra/src', allowed: ['@sammo-ts/logic'] }, + { + name: 'game-engine', + root: 'app/game-engine/src', + allowed: ['@sammo-ts/common', '@sammo-ts/infra', '@sammo-ts/logic'], + }, + { + name: 'game-api', + root: 'app/game-api/src', + allowed: ['@sammo-ts/common', '@sammo-ts/infra', '@sammo-ts/logic', '@sammo-ts/game-engine'], + }, + { + name: 'gateway-api', + root: 'app/gateway-api/src', + allowed: ['@sammo-ts/common', '@sammo-ts/infra', '@sammo-ts/logic', '@sammo-ts/game-engine'], + }, + { + name: 'release-controller', + root: 'app/release-controller/src', + allowed: ['@sammo-ts/gateway-api', '@sammo-ts/infra'], + }, + { + name: 'game-frontend', + root: 'app/game-frontend/src', + allowed: ['@sammo-ts/common', '@sammo-ts/logic', '@sammo-ts/game-api', '@sammo-ts/gateway-api'], + frontend: true, + }, + { + name: 'gateway-frontend', + root: 'app/gateway-frontend/src', + allowed: ['@sammo-ts/common', '@sammo-ts/game-api', '@sammo-ts/gateway-api'], + frontend: true, + }, +]; + +const persistenceModules = new Set([ + '@sammo-ts/infra', + '@prisma/client', + 'pg', + 'redis', + 'fs', + 'fs/promises', + 'http', + 'https', + 'net', + 'child_process', + 'node:fs', + 'node:fs/promises', + 'node:http', + 'node:https', + 'node:net', + 'node:dgram', + 'node:dns', + 'node:tls', + 'node:child_process', + 'node:worker_threads', +]); + +const backendPackages = new Set(['@sammo-ts/game-api', '@sammo-ts/gateway-api']); +const nodeBuiltins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); + +const isPersistenceModule = (specifier) => + persistenceModules.has(specifier) || + ['@sammo-ts/infra/', '@prisma/client/', 'pg/', 'redis/'].some((prefix) => specifier.startsWith(prefix)); + +const listSources = async (root) => { + const result = []; + const visit = async (directory) => { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + await visit(absolute); + } else if (/\.(?:ts|tsx|vue)$/.test(entry.name)) { + result.push(absolute); + } + } + }; + await visit(root); + return result.sort(); +}; + +const internalPackageName = (specifier) => { + const match = /^(@sammo-ts\/[^/]+)/.exec(specifier); + return match?.[1] ?? null; +}; + +export const extractImports = (source) => { + const imports = []; + const fromPattern = /\b(?:import|export)\s+(type\s+)?([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g; + const sideEffectPattern = /\bimport\s+['"]([^'"]+)['"]/g; + const dynamicPattern = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g; + let match; + while ((match = fromPattern.exec(source))) { + imports.push({ specifier: match[3], typeOnly: Boolean(match[1]), clause: match[2] }); + } + while ((match = sideEffectPattern.exec(source))) { + imports.push({ specifier: match[1], typeOnly: false, clause: '' }); + } + while ((match = dynamicPattern.exec(source))) { + imports.push({ specifier: match[1], typeOnly: false, clause: '' }); + } + return imports; +}; + +export const checkSource = ({ source, relativePath, zone }) => { + const violations = []; + for (const imported of extractImports(source)) { + const packageName = internalPackageName(imported.specifier); + if (packageName && packageName !== `@sammo-ts/${zone.name}` && !zone.allowed.includes(packageName)) { + violations.push(`${relativePath}: ${zone.name} may not depend on ${packageName}`); + } + if ((zone.name === 'common' || zone.name === 'logic') && isPersistenceModule(imported.specifier)) { + violations.push( + `${relativePath}: ${zone.name} must access I/O through an injected port (${imported.specifier})` + ); + } + if (zone.frontend && nodeBuiltins.has(imported.specifier)) { + violations.push(`${relativePath}: frontend production code may not import ${imported.specifier}`); + } + if (zone.frontend && packageName && backendPackages.has(packageName) && !imported.typeOnly) { + violations.push(`${relativePath}: frontend may reference ${packageName} only through import type`); + } + if ( + (zone.name === 'game-api' || zone.name === 'gateway-api') && + imported.specifier === '@sammo-ts/game-engine' + ) { + violations.push( + `${relativePath}: import a side-effect-free @sammo-ts/game-engine subpath instead of its process entrypoint` + ); + } + if (imported.specifier === '@sammo-ts/infra' && /\b(?:LogCategory|LogScope)\b/.test(imported.clause)) { + violations.push(`${relativePath}: LogCategory and LogScope are owned by @sammo-ts/logic`); + } + } + if ((zone.name === 'common' || zone.name === 'logic') && /\bprocess\.(?:env|stdout|stderr)\b/.test(source)) { + violations.push(`${relativePath}: ${zone.name} runtime diagnostics must use an injected port`); + } + if ((zone.name === 'common' || zone.name === 'logic') && /\bfetch\s*\(/.test(source)) { + violations.push(`${relativePath}: ${zone.name} network access must use an injected port`); + } + return violations; +}; + +export const checkWorkspace = async () => { + const violations = []; + for (const zone of zones) { + const absoluteRoot = path.join(workspaceRoot, zone.root); + const packageRoot = path.dirname(absoluteRoot); + const manifest = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8')); + const importedPackages = new Map(); + for (const filename of await listSources(absoluteRoot)) { + const source = await fs.readFile(filename, 'utf8'); + const relativePath = path.relative(workspaceRoot, filename); + violations.push(...checkSource({ source, relativePath, zone })); + for (const imported of extractImports(source)) { + const packageName = internalPackageName(imported.specifier); + if (!packageName || packageName === manifest.name) { + continue; + } + const usage = importedPackages.get(packageName) ?? { runtime: false, typeOnly: false }; + usage.typeOnly ||= imported.typeOnly; + usage.runtime ||= !imported.typeOnly; + importedPackages.set(packageName, usage); + } + } + for (const [packageName, usage] of importedPackages) { + const inDependencies = Object.hasOwn(manifest.dependencies ?? {}, packageName); + const inDevDependencies = Object.hasOwn(manifest.devDependencies ?? {}, packageName); + if (!inDependencies && !inDevDependencies) { + violations.push(`${path.relative(workspaceRoot, packageRoot)}/package.json: missing ${packageName}`); + } + if (usage.runtime && !inDependencies) { + violations.push( + `${path.relative(workspaceRoot, packageRoot)}/package.json: runtime dependency ${packageName} must be in dependencies` + ); + } + if (zone.frontend && backendPackages.has(packageName) && inDependencies) { + violations.push( + `${path.relative(workspaceRoot, packageRoot)}/package.json: type-only backend ${packageName} belongs in devDependencies` + ); + } + } + } + return violations; +}; + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const violations = await checkWorkspace(); + if (violations.length > 0) { + console.error(`Package boundary check failed (${violations.length})`); + for (const violation of violations) { + console.error(`- ${violation}`); + } + process.exitCode = 1; + } else { + console.info('Package boundary check passed.'); + } +} diff --git a/tools/check-package-boundaries.test.mjs b/tools/check-package-boundaries.test.mjs new file mode 100644 index 0000000..1c90d3a --- /dev/null +++ b/tools/check-package-boundaries.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { checkSource, extractImports } from './check-package-boundaries.mjs'; + +test('extracts multiline and type-only package imports', () => { + assert.deepEqual(extractImports("import type { AppRouter } from '@sammo-ts/game-api';"), [ + { specifier: '@sammo-ts/game-api', typeOnly: true, clause: '{ AppRouter }' }, + ]); + assert.equal( + extractImports("import {\n GamePrisma,\n type DatabaseClient\n} from '@sammo-ts/infra';")[0]?.specifier, + '@sammo-ts/infra' + ); +}); + +test('rejects infrastructure access and process diagnostics in logic', () => { + const zone = { name: 'logic', allowed: ['@sammo-ts/common'] }; + const violations = checkSource({ + source: "import { PrismaClient } from '@prisma/client/runtime';\nprocess.stdout.write('trace');\nfetch('/x');", + relativePath: 'packages/logic/src/example.ts', + zone, + }); + assert.equal(violations.length, 3); +}); + +test('requires frontend backend references to be type-only', () => { + const zone = { + name: 'game-frontend', + allowed: ['@sammo-ts/game-api'], + frontend: true, + }; + const violations = checkSource({ + source: "import { appRouter } from '@sammo-ts/game-api';", + relativePath: 'app/game-frontend/src/trpc.ts', + zone, + }); + assert.deepEqual(violations, [ + 'app/game-frontend/src/trpc.ts: frontend may reference @sammo-ts/game-api only through import type', + ]); +}); + +test('rejects prefixed and bare Node builtins in frontend source', () => { + const violations = checkSource({ + source: "import path from 'path';\nimport fs from 'node:fs';", + relativePath: 'app/game-frontend/src/example.ts', + zone: { name: 'game-frontend', allowed: [], frontend: true }, + }); + + assert.deepEqual(violations, [ + 'app/game-frontend/src/example.ts: frontend production code may not import path', + 'app/game-frontend/src/example.ts: frontend production code may not import node:fs', + ]); +});