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
View File
@@ -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 -1
View File
@@ -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 -6
View File
@@ -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';
+2 -8
View File
@@ -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';
+3 -1
View File
@@ -1,4 +1,6 @@
import { LogCategory, LogScope, type GamePrisma, type GamePrismaClient } from './gamePrisma.js';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import type { GamePrisma, GamePrismaClient } from './gamePrisma.js';
export interface LogQueryOptions {
limit?: number;
+3 -1
View File
@@ -1,4 +1,6 @@
import type { GamePrisma, LogCategory, LogScope } from './gamePrisma.js';
import type { LogCategory, LogScope } from '@sammo-ts/logic';
import type { GamePrisma } from './gamePrisma.js';
export type JsonValue = GamePrisma.JsonValue;
export type InputJsonValue = GamePrisma.InputJsonValue;
+2 -1
View File
@@ -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
),
};
+1
View File
@@ -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';
+15
View File
@@ -0,0 +1,15 @@
export interface TraceSubject {
generalIds?: readonly number[];
nationIds?: readonly number[];
}
export type TraceEvent = 'AI_ACTION_PATCH_TRACE' | 'AI_WAR_TRACE' | 'AI_WAR_FIXTURE_CORE' | 'WAR_TECH_TRACE';
/**
* 도메인 계산이 환경 변수나 stdout에 직접 의존하지 않도록 런타임이 주입하는
* 진단 포트입니다. event 이름은 기존 비교 도구가 소비하는 출력 prefix입니다.
*/
export interface TracePort {
isEnabled(event: TraceEvent, subject?: TraceSubject): boolean;
write(event: TraceEvent, payload: unknown): void;
}
+15 -4
View File
@@ -134,10 +134,21 @@ const applyNationTechGain = <TriggerState extends GeneralTriggerState>(
// arithmetic starts from the stored binary32 value without a PHP text read.
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,
});
}
};
+2
View File
@@ -4,6 +4,7 @@ import type { City, General, GeneralTriggerState, Nation } from '@sammo-ts/logic
import type { GeneralActionModule } from '@sammo-ts/logic/actionModules/general.js';
import type { 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> {
+92
View File
@@ -1,5 +1,97 @@
import type { City } from '@sammo-ts/logic/domain/entities.js';
import type { MapDefinition } from '@sammo-ts/logic/world/types.js';
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;
+32 -1
View File
@@ -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);
});
});
});