refactor: enforce package boundaries
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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`로 검사합니다.
|
||||
|
||||
## 런타임 경계
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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<string, MapDefinition>();
|
||||
|
||||
export const loadMapDefinitionByName = async (mapName: string): Promise<MapDefinition> => {
|
||||
@@ -30,8 +11,7 @@ export const loadMapDefinitionByName = async (mapName: string): Promise<MapDefin
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const raw = await fs.readFile(path.join(RESOURCE_MAP_ROOT, `map_${mapName}.json`), 'utf-8');
|
||||
const map = MapDefinitionSchema.parse(JSON.parse(raw));
|
||||
const map = await loadRuntimeMapDefinitionByName(mapName);
|
||||
mapCache.set(mapName, map);
|
||||
return map;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
|
||||
import type { ScenarioDefinition } from '@sammo-ts/logic';
|
||||
|
||||
import { loadMapDefinitionByName } from './mapDefinition.js';
|
||||
|
||||
export interface MapLayoutCity {
|
||||
@@ -19,311 +20,52 @@ export interface MapLayout {
|
||||
levelMap: Record<number, string>;
|
||||
}
|
||||
|
||||
interface ParsedCityConst {
|
||||
initCity?: unknown[];
|
||||
regionMap?: Record<string, unknown>;
|
||||
levelMap?: Record<string, unknown>;
|
||||
export interface MapLayoutLoaderOptions {
|
||||
loadScenario?: (scenarioId: number) => Promise<ScenarioDefinition>;
|
||||
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<string, MapLayout>();
|
||||
|
||||
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<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> => {
|
||||
const resolveMapName = async (
|
||||
scenario: string,
|
||||
loadScenario: (scenarioId: number) => Promise<ScenarioDefinition>
|
||||
): Promise<string> => {
|
||||
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<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';
|
||||
const definition = await loadScenario(scenarioId);
|
||||
return definition.config.environment.mapName;
|
||||
} catch {
|
||||
// 운영 DB가 보존된 상태에서 해당 commit에 scenario resource가 없을 수
|
||||
// 있으므로 기존 기본 map인 che로 안전하게 돌아갑니다.
|
||||
return 'che';
|
||||
}
|
||||
};
|
||||
|
||||
const buildLookupMap = (raw: Record<string, unknown> | undefined) => {
|
||||
const idToName: Record<number, string> = {};
|
||||
const nameToId: Record<string, number> = {};
|
||||
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<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);
|
||||
export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise<MapLayout> => {
|
||||
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<MapLayout> => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}.`);
|
||||
|
||||
@@ -9,4 +9,4 @@ export {
|
||||
type SelectPoolCandidateDto,
|
||||
type SelectPoolCandidateInfo,
|
||||
type SelectPoolReservationDto,
|
||||
} from '@sammo-ts/game-engine';
|
||||
} from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 { 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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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}` };
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<string, { loadedAt: number; data: ScenarioPreview[] }>();
|
||||
const defaultsCache = new Map<string, ScenarioDefaults>();
|
||||
|
||||
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<ScenarioPreview> => {
|
||||
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]));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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가 소유합니다.
|
||||
|
||||
## 데이터 소유권
|
||||
|
||||
| 데이터 | 기준 저장소 | 주요 접근 경로 |
|
||||
|
||||
@@ -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` 검사와 분리합니다. 테스트 예외는 제품 코드의 경계를 완화하는 근거가
|
||||
아닙니다.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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를
|
||||
|
||||
@@ -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)에 모아 둡니다.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"outDir": "dist",
|
||||
"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 { 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<WarActionModule<TriggerState> | null | undefined> = [],
|
||||
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.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
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -134,10 +134,21 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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<TriggerState extends GeneralTriggerState = Ge
|
||||
rng?: RandUtil;
|
||||
generalActionModules?: ReadonlyArray<GeneralActionModule<TriggerState> | null | undefined>;
|
||||
calcNationTechGain?: (context: WarAftermathTechContext) => number;
|
||||
trace?: TracePort;
|
||||
}
|
||||
|
||||
export interface WarAftermathOutcome<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
|
||||
|
||||
@@ -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<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 => {
|
||||
if (startCityId === endCityId) return 0;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+30
-15
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user