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
+1 -1
View File
@@ -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);
};
+3 -23
View File
@@ -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;
};
+37 -298
View File
@@ -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;
};
-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 { 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';
+1 -1
View File
@@ -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';
+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 { 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 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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}.`);
+1 -1
View File
@@ -9,4 +9,4 @@ export {
type SelectPoolCandidateDto,
type SelectPoolCandidateInfo,
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 { 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;
}
};