refactor: enforce package boundaries

This commit is contained in:
2026-08-07 10:13:29 +00:00
parent b1de142440
commit 1a1d0b2d91
64 changed files with 812 additions and 628 deletions
+4
View File
@@ -272,12 +272,16 @@ profile별 정확한 포트와 game frontend/API 변수는
```sh ```sh
CI=1 pnpm typecheck CI=1 pnpm typecheck
pnpm check:architecture
pnpm lint pnpm lint
pnpm test pnpm test
pnpm build pnpm build
``` ```
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요. - 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
adapter로 분리합니다.
- `pnpm test`의 skip 수를 pass처럼 보고하지 말아 주세요. - `pnpm test`의 skip 수를 pass처럼 보고하지 말아 주세요.
- Vitest file/name filter는 package script 뒤에 불필요한 `--`를 넣지 말아 주세요. - Vitest file/name filter는 package script 뒤에 불필요한 `--`를 넣지 말아 주세요.
예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts` 예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts`
+3
View File
@@ -28,6 +28,9 @@
위치는 [개발자 핸드북](docs/developer/index.md)에서 확인해 주세요. ref entry 위치는 [개발자 핸드북](docs/developer/index.md)에서 확인해 주세요. ref entry
point와 core 구현의 대응 근거는 상위 작업공간의 point와 core 구현의 대응 근거는 상위 작업공간의
`../docs/ref-core2026-mapping.md`에 있습니다. `../docs/ref-core2026-mapping.md`에 있습니다.
패키지 의존 방향과 파일 배치 규칙은
[패키지와 파일 경계](docs/architecture/package-boundaries.md)에 고정되어 있으며
`pnpm check:architecture`로 검사합니다.
## 런타임 경계 ## 런타임 경계
+1 -1
View File
@@ -1,6 +1,6 @@
import type { WorldStateRow } from '../context.js'; import type { WorldStateRow } from '../context.js';
import type { BattleSimJobPayload, BattleSimRequestPayload } from './types.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 { normalizeScenarioEffect, type ScenarioEffectKey, type WarEngineConfig } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import type { UnitSetDefinition } from '@sammo-ts/logic'; import type { UnitSetDefinition } from '@sammo-ts/logic';
@@ -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<unknown> => {
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<UnitSetDefinition> => {
const raw = await readJsonFile(unitSetPath);
return parseUnitSetDefinition(raw);
};
export const loadUnitSetDefinitionByName = async (
unitSetName: string,
options?: UnitSetLoaderOptions
): Promise<UnitSetDefinition> => {
const unitSetPath = resolveUnitSetDefinitionPath(unitSetName, options);
return loadUnitSetDefinition(unitSetPath);
};
+3 -23
View File
@@ -1,25 +1,6 @@
import fs from 'node:fs/promises'; import { loadMapDefinitionByName as loadRuntimeMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
import { existsSync } from 'node:fs'; import type { MapDefinition } from '@sammo-ts/logic';
import path from 'node:path';
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<string, MapDefinition>(); const mapCache = new Map<string, MapDefinition>();
export const loadMapDefinitionByName = async (mapName: string): Promise<MapDefinition> => { export const loadMapDefinitionByName = async (mapName: string): Promise<MapDefinition> => {
@@ -30,8 +11,7 @@ export const loadMapDefinitionByName = async (mapName: string): Promise<MapDefin
if (cached) { if (cached) {
return cached; return cached;
} }
const raw = await fs.readFile(path.join(RESOURCE_MAP_ROOT, `map_${mapName}.json`), 'utf-8'); const map = await loadRuntimeMapDefinitionByName(mapName);
const map = MapDefinitionSchema.parse(JSON.parse(raw));
mapCache.set(mapName, map); mapCache.set(mapName, map);
return map; return map;
}; };
+37 -298
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs/promises'; import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
import path from 'node:path'; import type { ScenarioDefinition } from '@sammo-ts/logic';
import { loadMapDefinitionByName } from './mapDefinition.js'; import { loadMapDefinitionByName } from './mapDefinition.js';
export interface MapLayoutCity { export interface MapLayoutCity {
@@ -19,311 +20,52 @@ export interface MapLayout {
levelMap: Record<number, string>; levelMap: Record<number, string>;
} }
interface ParsedCityConst { export interface MapLayoutLoaderOptions {
initCity?: unknown[]; loadScenario?: (scenarioId: number) => Promise<ScenarioDefinition>;
regionMap?: Record<string, unknown>; loadMap?: typeof loadMapDefinitionByName;
levelMap?: Record<string, unknown>;
} }
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<string, MapLayout>(); const layoutCache = new Map<string, MapLayout>();
const stripComments = (value: string): string => value.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); const parseScenarioId = (scenario: string): number | null => {
const normalized = scenario.replace(/^scenario_/i, '').replace(/\.json$/i, '');
const extractPhpArray = (source: string, marker: string): string | null => { if (!/^\d+$/.test(normalized)) {
const idx = source.indexOf(marker);
if (idx < 0) {
return null; return null;
} }
const start = source.indexOf('[', idx); const scenarioId = Number(normalized);
if (start < 0) { return Number.isSafeInteger(scenarioId) ? scenarioId : null;
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 parsePhpArray = (input: string): unknown => { const resolveMapName = async (
let index = 0; scenario: string,
loadScenario: (scenarioId: number) => Promise<ScenarioDefinition>
const skipWhitespace = () => { ): Promise<string> => {
while (index < input.length && /\s/.test(input[index] ?? '')) { const scenarioId = parseScenarioId(scenario);
index += 1; if (scenarioId === null) {
} return 'che';
}; }
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<string, unknown> = {};
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<ParsedCityConst> => {
try { try {
const raw = await fs.readFile(filePath, 'utf-8'); const definition = await loadScenario(scenarioId);
const source = stripComments(raw); return definition.config.environment.mapName;
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<string, unknown>) : undefined,
levelMap: levelMapRaw ? (parsePhpArray(levelMapRaw) as Record<string, unknown>) : undefined,
};
} catch {
return {};
}
};
const resolveScenarioFile = async (scenario: string): Promise<string> => {
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<string> => {
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';
} catch { } catch {
// 운영 DB가 보존된 상태에서 해당 commit에 scenario resource가 없을 수
// 있으므로 기존 기본 map인 che로 안전하게 돌아갑니다.
return 'che'; return 'che';
} }
}; };
const buildLookupMap = (raw: Record<string, unknown> | undefined) => { export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise<MapLayout> => {
const idToName: Record<number, string> = {}; const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById);
const nameToId: Record<string, number> = {}; const useCache = !options.loadScenario && !options.loadMap;
if (!raw) { const cached = useCache ? layoutCache.get(mapName) : undefined;
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<typeof buildLookupMap>,
regionMap: ReturnType<typeof buildLookupMap>
): MapLayoutCity[] => {
const rows = initCity.filter(Array.isArray) as unknown[][];
const nameToId = new Map<string, number>();
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<MapLayout> => {
const mapName = await resolveMapName(scenario);
const cached = layoutCache.get(mapName);
if (cached) { if (cached) {
return cached; return cached;
} }
const base = await parseCityConstFile(LEGACY_CITY_CONST); const map = await (options.loadMap ?? loadMapDefinitionByName)(mapName);
const mapPath = path.join(LEGACY_MAP_ROOT, `${mapName}.php`); const layout: MapLayout = {
const map = await parseCityConstFile(mapPath); mapName,
cityList: map.cities.map((city) => ({
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) => ({
id: city.id, id: city.id,
name: city.name, name: city.name,
level: city.level, level: city.level,
@@ -331,16 +73,13 @@ export const loadMapLayout = async (scenario: string): Promise<MapLayout> => {
x: city.position.x, x: city.position.x,
y: city.position.y, y: city.position.y,
path: [...city.connections], path: [...city.connections],
})); })),
} regionMap: {},
levelMap: {},
const layout: MapLayout = {
mapName,
cityList,
regionMap: regionMap.idToName,
levelMap: levelMap.idToName,
}; };
layoutCache.set(mapName, layout); if (useCache) {
layoutCache.set(mapName, layout);
}
return layout; return layout;
}; };
-18
View File
@@ -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);
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; 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 { asRecord } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
+1 -1
View File
@@ -21,7 +21,7 @@ import {
ConflictingTurnDaemonCommandError, ConflictingTurnDaemonCommandError,
RejectedNpcPossessionCommandError, RejectedNpcPossessionCommandError,
} from '../../daemon/databaseTransport.js'; } 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'; import { resolveNationScoutMessage } from '../nation/shared.js';
const resolveSelectionCommandResult = ( const resolveSelectionCommandResult = (
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { LogCategory } from '@sammo-ts/infra'; import { LogCategory } from '@sammo-ts/logic';
import { accessAuthedProcedure } from '../../../trpc.js'; import { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js'; import { getMyGeneral } from '../../shared/general.js';
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { LogCategory, LogScope } from '@sammo-ts/infra'; import { LogCategory, LogScope } from '@sammo-ts/logic';
import { authedProcedure } from '../../../trpc.js'; import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js'; import { getMyGeneral } from '../../shared/general.js';
@@ -1,8 +1,15 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { LogCategory, LogScope } from '@sammo-ts/infra'; import {
import { getGoldIncome, getOutcome, getRiceIncome, getWallIncome, getWarGoldIncome } from '@sammo-ts/logic'; getGoldIncome,
getOutcome,
getRiceIncome,
getWallIncome,
getWarGoldIncome,
LogCategory,
LogScope,
} from '@sammo-ts/logic';
import { authedProcedure } from '../../../trpc.js'; import { authedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js'; import { getMyGeneral } from '../../shared/general.js';
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common'; 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 { accessAuthedProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js'; import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../shared.js'; import { assertNationAccess, resolveNationPermission } from '../shared.js';
+1 -1
View File
@@ -5,7 +5,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.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 type { GameApiContext } from '../../context.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js'; import { resolveSecretPermission } from '../shared/secretPermission.js';
+1 -1
View File
@@ -1,6 +1,6 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { asNumber, asRecord } from '@sammo-ts/common'; 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 { z } from 'zod';
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
+1 -1
View File
@@ -6,7 +6,7 @@ import { accessAuthedProcedure, authedProcedure, procedure, router } from '../..
import { asRecord, isRecord } from '@sammo-ts/common'; import { asRecord, isRecord } from '@sammo-ts/common';
import { loadWorldMap } from '../../maps/worldMap.js'; import { loadWorldMap } from '../../maps/worldMap.js';
import { loadMapLayout } from '../../maps/mapLayout.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 { getMyGeneral, getOwnedGeneral } from '../shared/general.js';
import { getGeneralDirectory, getNationDirectory } from './directory.js'; import { getGeneralDirectory, getNationDirectory } from './directory.js';
+1 -1
View File
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { asRecord, isRecord } from '@sammo-ts/common'; 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 type { GameApiContext } from '../../context.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js'; import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
@@ -37,7 +37,7 @@ export class RemoteContentImageStore implements ContentImageUploadStore {
'x-image-request-id': requestId, 'x-image-request-id': requestId,
'x-image-signature': signature, 'x-image-signature': signature,
}, },
body: input.body, body: new Uint8Array(input.body),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Image repository upload failed with HTTP ${response.status}.`); throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
+1 -1
View File
@@ -9,4 +9,4 @@ export {
type SelectPoolCandidateDto, type SelectPoolCandidateDto,
type SelectPoolCandidateInfo, type SelectPoolCandidateInfo,
type SelectPoolReservationDto, type SelectPoolReservationDto,
} from '@sammo-ts/game-engine'; } from '@sammo-ts/game-engine/turn/selectPoolService.js';
+1 -1
View File
@@ -7,7 +7,7 @@ import {
import { asRecord, isRecord } from '@sammo-ts/common'; import { asRecord, isRecord } from '@sammo-ts/common';
import { z } from 'zod'; import { z } from 'zod';
import { loadTurnCommandProfile } from './turnCommandProfile.js'; import { loadTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
export type TurnCommandOptionValue = string | number; export type TurnCommandOptionValue = string | number;
@@ -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<TurnCommandProfile> => {
const raw = await fs.readFile(filePath, 'utf8');
return parseTurnCommandProfile(JSON.parse(raw) as unknown);
};
export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise<TurnCommandProfile> => {
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;
}
};
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; 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 type { DatabaseClient, GameApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js'; import { appRouter } from '../src/router.js';
+54
View File
@@ -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');
});
});
+1 -1
View File
@@ -7,7 +7,7 @@ const hasWorkspaceMarker = (dir: string): boolean =>
WORKSPACE_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker))); WORKSPACE_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)));
export const resolveWorkspaceRoot = ( 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 maxDepth = 6
): string => { ): string => {
let current = path.resolve(startDir); let current = path.resolve(startDir);
-96
View File
@@ -1,96 +0,0 @@
import type { City, MapDefinition } from '@sammo-ts/logic';
const buildConnectionMap = (map: MapDefinition): Map<number, number[]> => {
const result = new Map<number, number[]>();
for (const city of map.cities) {
result.set(city.id, city.connections ?? []);
}
return result;
};
export const searchAllDistanceByCityList = (
map: MapDefinition,
cityIds: number[]
): Record<number, Record<number, number>> => {
if (cityIds.length === 0) {
return {};
}
const connectionMap = buildConnectionMap(map);
const citySet = new Set(cityIds);
const result: Record<number, Record<number, number>> = {};
for (const startId of citySet) {
const distances: Record<number, number> = { [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<number, Record<number, number>> => {
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;
};
@@ -30,7 +30,7 @@ import {
valueFit, valueFit,
withCanonicalArgumentAliases, withCanonicalArgumentAliases,
} from '../aiUtils.js'; } from '../aiUtils.js';
import { searchAllDistanceByNationList } from '../distance.js'; import { searchAllDistanceByNationList } from '@sammo-ts/logic/world/distance.js';
import { generalActionHandlers } from '../generalAiGeneralActions.js'; import { generalActionHandlers } from '../generalAiGeneralActions.js';
import { nationActionHandlers } from '../generalAiNationActions.js'; import { nationActionHandlers } from '../generalAiNationActions.js';
import { resolveConstraintEnv, type ConstraintEnv } from './constraint.js'; import { resolveConstraintEnv, type ConstraintEnv } from './constraint.js';
@@ -1,7 +1,7 @@
import type { GeneralAI } from '../core.js'; import type { GeneralAI } from '../core.js';
import { GAME_TICKS_PER_TURN } from '@sammo-ts/common'; import { GAME_TICKS_PER_TURN } from '@sammo-ts/common';
import { calcCityDevRatio } from '../../aiUtils.js'; import { calcCityDevRatio } from '../../aiUtils.js';
import { searchAllDistanceByCityList } from '../../distance.js'; import { searchAllDistanceByCityList } from '@sammo-ts/logic/world/distance.js';
export const do천도 = (ai: GeneralAI) => { export const do천도 = (ai: GeneralAI) => {
if (!ai.nation || !ai.nation.capitalCityId) { if (!ai.nation || !ai.nation.capitalCityId) {
@@ -1,6 +1,6 @@
import type { GeneralAI } from '../core.js'; import type { GeneralAI } from '../core.js';
import { asRecord, joinYearMonth, parseYearMonth, readMetaNumber } from '../../aiUtils.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'; import { resolveNationIncome } from './helpers.js';
const isTechLimited = (ai: GeneralAI, tech: number): boolean => { const isTechLimited = (ai: GeneralAI, tech: number): boolean => {
@@ -15,6 +15,7 @@ import {
loadActionModuleBundle, loadActionModuleBundle,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { createRuntimeTrace } from './runtimeTrace.js';
// legacy GameConstBase 기본값 // legacy GameConstBase 기본값
const DEFAULT_GENERAL_GOLD = 1000; const DEFAULT_GENERAL_GOLD = 1000;
@@ -80,6 +81,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
const constValues = asRecord(config.const); const constValues = asRecord(config.const);
return { return {
trace: createRuntimeTrace(),
...(unitSet ? { unitSet } : {}), ...(unitSet ? { unitSet } : {}),
scenarioEffect: config.environment.scenarioEffect ?? null, scenarioEffect: config.environment.scenarioEffect ?? null,
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0), develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
+35
View File
@@ -0,0 +1,35 @@
import type { TraceEvent, TracePort, TraceSubject } from '@sammo-ts/logic/ports/trace.js';
const parseIds = (value: string | undefined): Set<string> => new Set(value?.split(',') ?? []);
const intersects = (configured: ReadonlySet<string>, 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`);
},
};
};
@@ -1,11 +1,13 @@
import { z } from 'zod'; import { z } from 'zod';
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; 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 { import {
EventDomesticTraitLoader, EventDomesticTraitLoader,
isEventDomesticTraitKey, isEventDomesticTraitKey,
isPersonalityTraitKey, isPersonalityTraitKey,
LogCategory,
LogScope,
PERSONALITY_TRAIT_KEYS, PERSONALITY_TRAIT_KEYS,
simpleSerialize, simpleSerialize,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
+33
View File
@@ -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');
});
});
+4 -2
View File
@@ -27,8 +27,6 @@
}, },
"dependencies": { "dependencies": {
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
"@sammo-ts/game-api": "workspace:*",
"@sammo-ts/gateway-api": "workspace:*",
"@sammo-ts/logic": "workspace:*", "@sammo-ts/logic": "workspace:*",
"@tiptap/extension-image": "^3.5.0", "@tiptap/extension-image": "^3.5.0",
"@tiptap/extension-link": "^3.5.0", "@tiptap/extension-link": "^3.5.0",
@@ -48,6 +46,10 @@
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"devDependencies": { "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", "@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
@@ -55,19 +55,14 @@ export class RemoteUserIconStore implements UserIconUploadStore {
input.body input.body
), ),
}, },
body: input.body, body: new Uint8Array(input.body),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Image repository upload failed with HTTP ${response.status}.`); throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
} }
const picture = `users/core2026/${input.filename}`; const picture = `users/core2026/${input.filename}`;
const payload: unknown = await response.json(); const payload: unknown = await response.json();
if ( if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== `icons/${picture}`) {
!payload ||
typeof payload !== 'object' ||
!('path' in payload) ||
payload.path !== `icons/${picture}`
) {
throw new Error('Image repository returned an unexpected upload path.'); throw new Error('Image repository returned an unexpected upload path.');
} }
return { picture, publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${picture}` }; return { picture, publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${picture}` };
@@ -3,7 +3,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { createHash, randomBytes, randomUUID } from 'node:crypto'; 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 { import {
createGamePostgresConnector, createGamePostgresConnector,
createRedisConnector, createRedisConnector,
@@ -1630,9 +1630,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
} }
private async clearTournamentRuntimeStateFromRedis(profileName: string): Promise<void> { private async clearTournamentRuntimeStateFromRedis(profileName: string): Promise<void> {
const connector = createRedisConnector( const connector = createRedisConnector(resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env));
resolveRedisConfigFromEnv(this.processConfig.baseEnv ?? process.env)
);
await connector.connect(); await connector.connect();
try { try {
await clearTournamentRuntimeKeys(connector.client, profileName); await clearTournamentRuntimeKeys(connector.client, profileName);
@@ -1,7 +1,7 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; 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'; import { seedProfileDatabase, type AdminSeedUser } from './seedProfileDatabase.js';
@@ -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 type { GamePrisma } from '@sammo-ts/infra';
import { GameClock, asRecord, type GameClockMode } from '@sammo-ts/common'; import { GameClock, asRecord, type GameClockMode } from '@sammo-ts/common';
@@ -3,11 +3,8 @@ import path from 'node:path';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { import { composeScenarioResource } from '@sammo-ts/game-engine/scenario/scenarioComposition.js';
composeScenarioResource, import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
loadScenarioDefinitionById,
resolveScenarioDefaultsPath,
} from '@sammo-ts/game-engine';
import { parseScenarioDefaults, parseScenarioDefinition, type ScenarioDefaults } from '@sammo-ts/logic'; import { parseScenarioDefaults, parseScenarioDefinition, type ScenarioDefaults } from '@sammo-ts/logic';
import { resolveWorkspaceRoot } from '../orchestrator/workspaceRoot.js'; import { resolveWorkspaceRoot } from '../orchestrator/workspaceRoot.js';
@@ -40,14 +37,12 @@ const SCENARIO_ROOT = path.join('resources', 'scenario');
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const REPO_ROOT = resolveWorkspaceRoot(process.env.GATEWAY_WORKSPACE_ROOT ?? __dirname); const REPO_ROOT = resolveWorkspaceRoot(process.env.GATEWAY_WORKSPACE_ROOT ?? __dirname);
const SCENARIO_RESOURCE_ROOT = path.resolve(REPO_ROOT, SCENARIO_ROOT);
const previewCache = new Map<string, { loadedAt: number; data: ScenarioPreview[] }>(); const previewCache = new Map<string, { loadedAt: number; data: ScenarioPreview[] }>();
const defaultsCache = new Map<string, ScenarioDefaults>(); const defaultsCache = new Map<string, ScenarioDefaults>();
const resolveScenarioRoot = (): string => { const resolveScenarioRoot = (): string => SCENARIO_RESOURCE_ROOT;
const defaultsPath = resolveScenarioDefaultsPath();
return path.dirname(defaultsPath);
};
const runGit = (args: string[]): Promise<{ ok: boolean; output: string }> => const runGit = (args: string[]): Promise<{ ok: boolean; output: string }> =>
new Promise((resolve) => { new Promise((resolve) => {
@@ -210,7 +205,7 @@ const countGeneralsByNation = (
}; };
const buildScenarioPreview = async (scenarioId: number): Promise<ScenarioPreview> => { const buildScenarioPreview = async (scenarioId: number): Promise<ScenarioPreview> => {
const scenario = await loadScenarioDefinitionById(scenarioId); const scenario = await loadScenarioDefinitionById(scenarioId, { scenarioRoot: SCENARIO_RESOURCE_ROOT });
const resolveNationId = buildNationIdResolver(scenario.nations); const resolveNationId = buildNationIdResolver(scenario.nations);
const baseCounts = new Map(scenario.nations.map((nation) => [nation.id, 0])); const baseCounts = new Map(scenario.nations.map((nation) => [nation.id, 0]));
+5 -3
View File
@@ -16,9 +16,6 @@
}, },
"dependencies": { "dependencies": {
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
"@sammo-ts/game-api": "workspace:*",
"@sammo-ts/gateway-api": "workspace:*",
"@sammo-ts/logic": "workspace:*",
"@trpc/client": "^11.8.1", "@trpc/client": "^11.8.1",
"@trpc/server": "^11.8.1", "@trpc/server": "^11.8.1",
"@vueuse/core": "^13.9.0", "@vueuse/core": "^13.9.0",
@@ -31,6 +28,11 @@
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"devDependencies": { "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", "@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
+5
View File
@@ -76,6 +76,11 @@ persistence는 `app/game-engine`이 제공합니다.
`src/gatewayPrisma.ts`, `src/gamePrisma.ts`, `src/postgres.ts`, `src/redis.ts` `src/gatewayPrisma.ts`, `src/gamePrisma.ts`, `src/postgres.ts`, `src/redis.ts`
연결과 client 생성을 담당합니다. 연결과 client 생성을 담당합니다.
구체적인 import 방향과 파일 배치 기준은
[패키지와 파일 경계](./package-boundaries.md)를 따릅니다. 순수 거리 계산과
도메인 로그 enum은 `packages/logic`, resource 파일 loader와 trace 출력은
app/infra adapter가 소유합니다.
## 데이터 소유권 ## 데이터 소유권
| 데이터 | 기준 저장소 | 주요 접근 경로 | | 데이터 | 기준 저장소 | 주요 접근 경로 |
+67
View File
@@ -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` 검사와 분리합니다. 테스트 예외는 제품 코드의 경계를 완화하는 근거가
아닙니다.
+6 -1
View File
@@ -33,12 +33,14 @@ core2026/
| turn daemon | `app/game-engine/src/turn/turnDaemon.ts` | lifecycle, loader, handler, flush | | turn daemon | `app/game-engine/src/turn/turnDaemon.ts` | lifecycle, loader, handler, flush |
| daemon lease | `app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts` | `TurnDaemonLease` | | daemon lease | `app/game-engine/src/lifecycle/databaseTurnDaemonLease.ts` | `TurnDaemonLease` |
| world load·flush | `worldLoader.ts`, `databaseHooks.ts` | `EngineStateManager`, game Prisma | | 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/actions/turn` | constraint, command module, engine handler |
| 전투 | `packages/logic/src/war` | action module, crew type, item, trait | | 전투 | `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 | | 월간 처리 | `app/game-engine/src/turn/monthly*.ts` | scenario event, world dirty state |
| frontend route | `app/*-frontend/src/router/index.ts` | view, store, tRPC client | | frontend route | `app/*-frontend/src/router/index.ts` | view, store, tRPC client |
| schema | `packages/infra/prisma/*.prisma` | migration, client, loader | | 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 순서를 조사합니다. 순수 계산은 ref entry point, SQL, RNG, log와 mutation 순서를 조사합니다. 순수 계산은
`packages/logic`, 실행 context와 persistence는 `app/game-engine`에 둡니다. `packages/logic`, 실행 context와 persistence는 `app/game-engine`에 둡니다.
Fixed-seed unit, 실제 DB integration과 ref 차등 fixture를 함께 갱신합니다. Fixed-seed unit, 실제 DB integration과 ref 차등 fixture를 함께 갱신합니다.
새 package import나 파일 이동은
[패키지 경계 문서](../architecture/package-boundaries.md)와
`pnpm check:architecture`로 확인합니다.
### DB ### DB
+2
View File
@@ -8,6 +8,7 @@
| process·worker·daemon | [런타임 아키텍처](../architecture/runtime.md) | app server와 CLI | | process·worker·daemon | [런타임 아키텍처](../architecture/runtime.md) | app server와 CLI |
| profile·Gateway 배포 | [릴리스 운영 매뉴얼](../release-operations.md) | Admin GUI와 release-controller | | profile·Gateway 배포 | [릴리스 운영 매뉴얼](../release-operations.md) | Admin GUI와 release-controller |
| 파일 위치 | [파일 지도](./code-map.md) | router, handler, schema | | 파일 위치 | [파일 지도](./code-map.md) | router, handler, schema |
| package 의존 방향 | [패키지와 파일 경계](../architecture/package-boundaries.md) | `packages/*`, `app/*` |
| 명령·전투·효과 | [도메인과 조립](./domain-and-classes.md) | `packages/logic` | | 명령·전투·효과 | [도메인과 조립](./domain-and-classes.md) | `packages/logic` |
| mutation·flush | [요청·턴·저장](./request-turn-persistence.md) | game API, game engine | | mutation·flush | [요청·턴·저장](./request-turn-persistence.md) | game API, game engine |
| action module | [행동 모듈 프로토콜](../architecture/action-module-protocol.md) | `actionModules/` | | action module | [행동 모듈 프로토콜](../architecture/action-module-protocol.md) | `actionModules/` |
@@ -16,6 +17,7 @@
## 경계 ## 경계
- `packages/logic`은 계산과 규칙을 소유합니다. - `packages/logic`은 계산과 규칙을 소유합니다.
- runtime I/O는 logic의 port를 app/infra adapter가 구현해 주입합니다.
- `app/game-engine`은 clock, queue, AI, 월간 순서, transaction과 flush를 - `app/game-engine`은 clock, queue, AI, 월간 순서, transaction과 flush를
소유합니다. 소유합니다.
- `app/game-api`는 transport, 인증, input validation과 request acceptance를 - `app/game-api`는 transport, 인증, input validation과 request acceptance를
+2
View File
@@ -34,6 +34,8 @@ features:
Gateway 배포는 [릴리스 운영 매뉴얼](./release-operations.md)을 따라 주세요. Gateway 배포는 [릴리스 운영 매뉴얼](./release-operations.md)을 따라 주세요.
게임 진행 시각과 운영 벽시계의 경계는 게임 진행 시각과 운영 벽시계의 경계는
[게임 시계](./architecture/game-clock.md)에 설명합니다. [게임 시계](./architecture/game-clock.md)에 설명합니다.
[패키지와 파일 경계](./architecture/package-boundaries.md)는 source import와
폴더별 책임, 자동 검사 방법을 설명합니다.
Ref 전용 수치·저장 표현 보정과 제거 절차는 Ref 전용 수치·저장 표현 보정과 제거 절차는
[Ref 호환 shim 인벤토리](./ref-compatibility-shims.md)에 모아 둡니다. [Ref 호환 shim 인벤토리](./ref-compatibility-shims.md)에 모아 둡니다.
+2
View File
@@ -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: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: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: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", "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", "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", "test:e2e:main-front-status-live": "node tools/frontend-legacy-parity/run-main-front-status-live.mjs",
+1
View File
@@ -27,6 +27,7 @@
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"@prisma/client-runtime-utils": "^7.2.0", "@prisma/client-runtime-utils": "^7.2.0",
"@sammo-ts/logic": "workspace:*",
"es-toolkit": "^1.43.0", "es-toolkit": "^1.43.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"redis": "^5.10.0" "redis": "^5.10.0"
+1 -1
View File
@@ -1,5 +1,5 @@
import { PrismaClient as GamePrismaClient } from '../prisma/generated/game/index.js'; 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'; export type { PrismaClient as GamePrismaClient } from '../prisma/generated/game/index.js';
import type { PostgresConfig, PostgresConnector } from './postgres.js'; import type { PostgresConfig, PostgresConnector } from './postgres.js';
+1 -6
View File
@@ -1,10 +1,5 @@
import { PrismaClient as GatewayPrismaClient } from '../prisma/generated/gateway/index.js'; import { PrismaClient as GatewayPrismaClient } from '../prisma/generated/gateway/index.js';
export { export { Prisma as GatewayPrisma } from '../prisma/generated/gateway/index.js';
GatewayBuildStatus,
GatewayProfileStatus,
OAuthType,
Prisma as GatewayPrisma,
} from '../prisma/generated/gateway/index.js';
export type { PrismaClient as GatewayPrismaClient } from '../prisma/generated/gateway/index.js'; export type { PrismaClient as GatewayPrismaClient } from '../prisma/generated/gateway/index.js';
import type { PostgresConfig, PostgresConnector } from './postgres.js'; import type { PostgresConfig, PostgresConnector } from './postgres.js';
+2 -8
View File
@@ -1,13 +1,7 @@
export * from './postgres.js'; 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 type { GamePrismaClient } from './gamePrisma.js';
export { export { createGatewayPostgresConnector, GatewayPrisma } from './gatewayPrisma.js';
createGatewayPostgresConnector,
GatewayBuildStatus,
GatewayProfileStatus,
GatewayPrisma,
OAuthType,
} from './gatewayPrisma.js';
export type { GatewayPrismaClient } from './gatewayPrisma.js'; export type { GatewayPrismaClient } from './gatewayPrisma.js';
export * from './db.js'; export * from './db.js';
export * from './errorLogRepository.js'; export * from './errorLogRepository.js';
+3 -1
View File
@@ -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 { export interface LogQueryOptions {
limit?: number; limit?: number;
+3 -1
View File
@@ -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 JsonValue = GamePrisma.JsonValue;
export type InputJsonValue = GamePrisma.InputJsonValue; export type InputJsonValue = GamePrisma.InputJsonValue;
+2 -1
View File
@@ -4,5 +4,6 @@
"outDir": "dist", "outDir": "dist",
"composite": true "composite": true
}, },
"include": ["src", "test", "*.ts"] "include": ["src", "test", "*.ts"],
"references": [{ "path": "../logic" }]
} }
@@ -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 { NationTraitModule } from '@sammo-ts/logic/actionModules/traits/nation/index.js';
import type { RefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js'; import type { RefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
import type { ScenarioEffectKey } from '@sammo-ts/logic/scenario/scenarioEffect.js'; import type { ScenarioEffectKey } from '@sammo-ts/logic/scenario/scenarioEffect.js';
import type { TracePort } from '@sammo-ts/logic/ports/trace.js';
export interface TurnCommandItemCatalogEntry { export interface TurnCommandItemCatalogEntry {
slot: 'horse' | 'weapon' | 'book' | 'item'; slot: 'horse' | 'weapon' | 'book' | 'item';
@@ -17,6 +18,7 @@ export interface TurnCommandItemCatalogEntry {
} }
export interface TurnCommandEnv { export interface TurnCommandEnv {
trace?: TracePort;
unitSet?: UnitSetDefinition; unitSet?: UnitSetDefinition;
scenarioEffect?: ScenarioEffectKey | null; scenarioEffect?: ScenarioEffectKey | null;
develCost: number; develCost: number;
@@ -124,20 +124,26 @@ export class ActionDefinition<
const generalCount = Math.max(context.nationGeneralCount, this.env.initialNationGenLimit); const generalCount = Math.max(context.nationGeneralCount, this.env.initialNationGenLimit);
if ( if (
(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []).includes(String(context.general.id)) || this.env.trace?.isEnabled('AI_ACTION_PATCH_TRACE', {
(process.env.CORE_AI_TRACE_NATION_IDS?.split(',') ?? []).includes(String(context.nation.id)) generalIds: [context.general.id],
nationIds: [context.nation.id],
})
) { ) {
process.stdout.write( this.env.trace.write('AI_ACTION_PATCH_TRACE', {
`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` 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 = {
...context.nation.meta, ...context.nation.meta,
tech: addLegacyStoredTech( tech: addLegacyStoredTech(currentTech, techScore / generalCount),
currentTech,
techScore / generalCount
),
}; };
context.general.gold = Math.max(0, context.general.gold - result.costGold); context.general.gold = Math.max(0, context.general.gold - result.costGold);
context.general.experience += result.exp; context.general.experience += result.exp;
@@ -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 type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { buildNationFrontStatePatches } from '../../../diplomacy/frontState.js'; import { buildNationFrontStatePatches } from '../../../diplomacy/frontState.js';
import type { TracePort } from '../../../ports/trace.js';
import { formatDestCityConstraintFailure } from '../constraintFailure.js'; import { formatDestCityConstraintFailure } from '../constraintFailure.js';
import { import {
buildWarAftermathConfig, buildWarAftermathConfig,
@@ -380,7 +381,8 @@ export class ActionDefinition<
constructor( constructor(
modules: ReadonlyArray<WarActionModule<TriggerState> | null | undefined> = [], modules: ReadonlyArray<WarActionModule<TriggerState> | null | undefined> = [],
nationTraitModules: NationTraitModule[] = [], nationTraitModules: NationTraitModule[] = [],
generalModules: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined> = [] generalModules: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined> = [],
private readonly trace?: TracePort
) { ) {
this.warModules = modules.filter(Boolean) as ReadonlyArray<WarActionModule<TriggerState>>; this.warModules = modules.filter(Boolean) as ReadonlyArray<WarActionModule<TriggerState>>;
this.nationTraitModules = new Map(nationTraitModules.map((module) => [module.key, module])); 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) (unitSet.crewTypes?.some((crewType) => crewType.id === general.crewTypeId) ?? false)
) )
); );
const traceGeneralIds = new Set(process.env.CORE_AI_TRACE_GENERAL_IDS?.split(',') ?? []); const battleGeneralIds = [context.general.id, ...defenderGenerals.map((general) => general.id)];
const shouldTraceWar = const shouldTraceWar = this.trace?.isEnabled('AI_WAR_TRACE', { generalIds: battleGeneralIds }) ?? false;
traceGeneralIds.has(String(context.general.id)) ||
defenderGenerals.some((general) => traceGeneralIds.has(String(general.id)));
if (process.env.CORE_BATTLE_FIXTURE_TRACE === '1') { if (this.trace?.isEnabled('AI_WAR_FIXTURE_CORE', { generalIds: battleGeneralIds })) {
process.stdout.write( this.trace.write('AI_WAR_FIXTURE_CORE', {
`AI_WAR_FIXTURE_CORE ${JSON.stringify({ action: 'battle',
action: 'battle', seed,
seed, repeatCnt: 1,
repeatCnt: 1, year: time.year,
year: time.year, month: time.month,
month: time.month, startYear: time.startYear,
startYear: time.startYear, scenarioEffect: null,
scenarioEffect: null, attackerGeneral: buildBattleGeneralFixture(context.general),
attackerGeneral: buildBattleGeneralFixture(context.general), attackerCity: buildBattleCityFixture(attackerCity),
attackerCity: buildBattleCityFixture(attackerCity), attackerNation: buildBattleNationFixture(attackerNation),
attackerNation: buildBattleNationFixture(attackerNation), defenderGenerals: defenderGenerals.map(buildBattleGeneralFixture),
defenderGenerals: defenderGenerals.map(buildBattleGeneralFixture), defenderCity: buildBattleCityFixture(defenderCity),
defenderCity: buildBattleCityFixture(defenderCity), defenderNation: buildBattleNationFixture(defenderNation),
defenderNation: buildBattleNationFixture(defenderNation), });
})}\n`
);
} }
const battle = resolveWarBattle({ const battle = resolveWarBattle({
@@ -619,9 +617,7 @@ export class ActionDefinition<
...(shouldTraceWar ...(shouldTraceWar
? { ? {
trace: (event) => { trace: (event) => {
process.stdout.write( this.trace?.write('AI_WAR_TRACE', { generalId: context.general.id, event });
`AI_WAR_TRACE ${JSON.stringify({ generalId: context.general.id, event })}\n`
);
}, },
} }
: {}), : {}),
@@ -648,6 +644,7 @@ export class ActionDefinition<
baseGain baseGain
); );
}, },
...(this.trace ? { trace: this.trace } : {}),
}); });
// Ref ConquerCity() recalculates the fronts of every nation around the // Ref ConquerCity() recalculates the fronts of every nation around the
@@ -802,5 +799,10 @@ export const commandSpec: GeneralTurnCommandSpec = {
availabilityArgs: { destCityId: 0 }, availabilityArgs: { destCityId: 0 },
argsSchema: ARGS_SCHEMA, argsSchema: ARGS_SCHEMA,
createDefinition: (env: TurnCommandEnv) => createDefinition: (env: TurnCommandEnv) =>
new ActionDefinition(env.warActionModules ?? [], env.nationTraitModules ?? [], env.generalActionModules ?? []), new ActionDefinition(
env.warActionModules ?? [],
env.nationTraitModules ?? [],
env.generalActionModules ?? [],
env.trace
),
}; };
+1
View File
@@ -17,6 +17,7 @@ export * from './inheritance/inheritBuff.js';
export * from './resources/index.js'; export * from './resources/index.js';
export * from './ports/world.js'; export * from './ports/world.js';
export * from './ports/worldSnapshot.js'; export * from './ports/worldSnapshot.js';
export * from './ports/trace.js';
export * from './scenario/index.js'; export * from './scenario/index.js';
export * from './triggers/index.js'; export * from './triggers/index.js';
export * from './turn/index.js'; export * from './turn/index.js';
+15
View File
@@ -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;
}
+15 -4
View File
@@ -134,10 +134,21 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
// arithmetic starts from the stored binary32 value without a PHP text read. // arithmetic starts from the stored binary32 value without a PHP text read.
nation.meta.tech = Math.fround(currentTech + delta); nation.meta.tech = Math.fround(currentTech + delta);
// REF-COMPAT:END ref-mariadb-float-boundary // REF-COMPAT:END ref-mariadb-float-boundary
if ((process.env.CORE_WAR_TECH_TRACE_NATION_IDS?.split(',') ?? []).includes(String(nation.id))) { if (input.trace?.isEnabled('WAR_TECH_TRACE', { nationIds: [nation.id] })) {
process.stdout.write( input.trace.write('WAR_TECH_TRACE', {
`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` engine: 'core',
); nationId: nation.id,
side: context.side,
currentTech,
baseGain,
gain,
total,
effective,
divisor,
delta,
storedTech: nation.meta.tech,
attackerGeneralId: context.attackerReport.id,
});
} }
}; };
+2
View File
@@ -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 { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import type { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js';
import type { LogEntryDraft } from '@sammo-ts/logic/logging/types.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 { UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { WarActionModule } from './actions.js'; import type { WarActionModule } from './actions.js';
import type { WarTriggerRegistry } from './triggers.js'; import type { WarTriggerRegistry } from './triggers.js';
@@ -194,6 +195,7 @@ export interface WarAftermathInput<TriggerState extends GeneralTriggerState = Ge
rng?: RandUtil; rng?: RandUtil;
generalActionModules?: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>; generalActionModules?: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>;
calcNationTechGain?: (context: WarAftermathTechContext) => number; calcNationTechGain?: (context: WarAftermathTechContext) => number;
trace?: TracePort;
} }
export interface WarAftermathOutcome<TriggerState extends GeneralTriggerState = GeneralTriggerState> { export interface WarAftermathOutcome<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
+92
View File
@@ -1,5 +1,97 @@
import type { City } from '@sammo-ts/logic/domain/entities.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
const buildConnectionMap = (map: MapDefinition): Map<number, number[]> => {
const result = new Map<number, number[]>();
for (const city of map.cities) {
result.set(city.id, city.connections ?? []);
}
return result;
};
export const searchAllDistanceByCityList = (
map: MapDefinition,
cityIds: number[]
): Record<number, Record<number, number>> => {
if (cityIds.length === 0) {
return {};
}
const connectionMap = buildConnectionMap(map);
const citySet = new Set(cityIds);
const result: Record<number, Record<number, number>> = {};
for (const startId of citySet) {
const distances: Record<number, number> = { [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<number, Record<number, number>> => {
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 => { export const getCityDistance = (map: MapDefinition, startCityId: number, endCityId: number): number => {
if (startCityId === endCityId) return 0; if (startCityId === endCityId) return 0;
+32 -1
View File
@@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest'; 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'; import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
describe('World Distance', () => { 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);
});
});
}); });
+30 -15
View File
@@ -158,12 +158,6 @@ importers:
'@sammo-ts/common': '@sammo-ts/common':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/common 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': '@sammo-ts/logic':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/logic version: link:../../packages/logic
@@ -216,6 +210,18 @@ importers:
specifier: ^4.3.5 specifier: ^4.3.5
version: 4.3.5 version: 4.3.5
devDependencies: 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': '@tailwindcss/vite':
specifier: ^4.1.18 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)) 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': '@sammo-ts/common':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/common 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': '@trpc/client':
specifier: ^11.8.1 specifier: ^11.8.1
version: 11.8.1(@trpc/server@11.8.1(typescript@6.0.2))(typescript@6.0.2) 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 specifier: ^4.3.5
version: 4.3.5 version: 4.3.5
devDependencies: 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': '@tailwindcss/vite':
specifier: ^4.1.18 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)) 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': '@prisma/client-runtime-utils':
specifier: ^7.2.0 specifier: ^7.2.0
version: 7.2.0 version: 7.2.0
'@sammo-ts/logic':
specifier: workspace:*
version: link:../logic
es-toolkit: es-toolkit:
specifier: ^1.43.0 specifier: ^1.43.0
version: 1.43.0 version: 1.43.0
+208
View File
@@ -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.');
}
}
+53
View File
@@ -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',
]);
});