Merge branch 'main' into feature/legacy-db-migration
This commit is contained in:
@@ -119,6 +119,7 @@ integration('live sortie PostgreSQL persistence retry', () => {
|
||||
|
||||
it.each([
|
||||
['conquest and nation collapse', 'live-sortie-conquest.json'],
|
||||
['collapsed nation conflict cleanup', 'live-sortie-collapse-conflict.json'],
|
||||
['single defending general', 'live-sortie-defender.json'],
|
||||
['multiple defending generals', 'live-sortie-multiple-defenders.json'],
|
||||
['supply retreat', 'live-sortie-supply-retreat.json'],
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadActionModuleBundle, type City, type GeneralActionModule, type MapDefinition } from '@sammo-ts/logic';
|
||||
import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js';
|
||||
import { createRaiseDisasterHandler } from '@sammo-ts/game-engine/turn/monthlyDisasterAction.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '@sammo-ts/game-engine/turn/types.js';
|
||||
|
||||
import { findTurnDifferentialWorkspaceRoot } from '../src/turn-differential/referenceSnapshot.js';
|
||||
|
||||
type ReferenceGeneral = {
|
||||
id: number;
|
||||
injury: number;
|
||||
crew: number;
|
||||
train: number;
|
||||
atmos: number;
|
||||
item: string;
|
||||
};
|
||||
|
||||
type ReferenceCity = {
|
||||
id: number;
|
||||
state: number;
|
||||
population: number;
|
||||
agriculture: number;
|
||||
commerce: number;
|
||||
security: number;
|
||||
trust: number;
|
||||
defence: number;
|
||||
wall: number;
|
||||
};
|
||||
|
||||
type ReferenceLog = {
|
||||
generalId?: number;
|
||||
text: string;
|
||||
scope?: string;
|
||||
category?: string;
|
||||
};
|
||||
|
||||
type ReferenceMonthlyTrace = {
|
||||
action: string;
|
||||
before: {
|
||||
watermarks: {
|
||||
logId: number;
|
||||
};
|
||||
};
|
||||
after: {
|
||||
logs: ReferenceLog[];
|
||||
};
|
||||
beforeDetails: {
|
||||
generals: ReferenceGeneral[];
|
||||
cities: ReferenceCity[];
|
||||
worldHistory: ReferenceLog[];
|
||||
};
|
||||
afterDetails: {
|
||||
generals: ReferenceGeneral[];
|
||||
cities: ReferenceCity[];
|
||||
worldHistory: ReferenceLog[];
|
||||
};
|
||||
};
|
||||
|
||||
const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT;
|
||||
const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd());
|
||||
const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1');
|
||||
|
||||
const fixtureRelativePath = 'fixtures/monthly-differential/raise-disaster-january.json';
|
||||
|
||||
const runReferenceMonthlyTrace = (): ReferenceMonthlyTrace => {
|
||||
const stackDirectory = path.join(workspaceRoot!, 'docker_compose_files/reference');
|
||||
const fixture = fs.readFileSync(path.join(stackDirectory, fixtureRelativePath), 'utf8');
|
||||
const runnerScript =
|
||||
process.env.MONTHLY_DIFFERENTIAL_RUNNER_SCRIPT ??
|
||||
path.join(workspaceRoot!, 'ref/sam/hwe/compare/monthly_event_trace.php');
|
||||
const stdout = execFileSync('./scripts/run-turn-differential-case.sh', ['-'], {
|
||||
cwd: stackDirectory,
|
||||
input: fixture,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
TURN_DIFFERENTIAL_RUNNER_SCRIPT: runnerScript,
|
||||
},
|
||||
});
|
||||
return JSON.parse(stdout) as ReferenceMonthlyTrace;
|
||||
};
|
||||
|
||||
const map: MapDefinition = {
|
||||
id: 'monthly-disaster-reference',
|
||||
name: 'monthly-disaster-reference',
|
||||
cities: [],
|
||||
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
||||
};
|
||||
|
||||
const city: City = {
|
||||
id: 1,
|
||||
name: '재난도시',
|
||||
nationId: 0,
|
||||
level: 1,
|
||||
state: 5,
|
||||
population: 1_001,
|
||||
populationMax: 2_000,
|
||||
agriculture: 501,
|
||||
agricultureMax: 1_000,
|
||||
commerce: 499,
|
||||
commerceMax: 1_000,
|
||||
security: 0,
|
||||
securityMax: 100,
|
||||
supplyState: 1,
|
||||
frontState: 2,
|
||||
defence: 99,
|
||||
defenceMax: 1_000,
|
||||
wall: 101,
|
||||
wallMax: 1_000,
|
||||
conflict: {},
|
||||
meta: { trust: 99.5, trade: 100 },
|
||||
};
|
||||
|
||||
const buildGeneral = (id: number, name: string, injury: number, item: string | null): TurnGeneral => ({
|
||||
id,
|
||||
name,
|
||||
nationId: 0,
|
||||
cityId: 1,
|
||||
troopId: 0,
|
||||
stats: { leadership: 50, strength: 50, intelligence: 50 },
|
||||
experience: 0,
|
||||
dedication: 0,
|
||||
officerLevel: 1,
|
||||
role: {
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
items: { horse: null, weapon: null, book: null, item },
|
||||
},
|
||||
injury,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
crew: 99,
|
||||
crewTypeId: 1_100,
|
||||
train: 51,
|
||||
atmos: 50,
|
||||
age: 20,
|
||||
npcState: 0,
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
turnTime: new Date('0193-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const projectReferenceGenerals = (generals: ReferenceGeneral[]) =>
|
||||
generals.map((general) => ({
|
||||
id: general.id,
|
||||
injury: general.injury,
|
||||
crew: general.crew,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
item: general.item === 'None' ? null : general.item,
|
||||
}));
|
||||
|
||||
const projectCoreGenerals = (world: InMemoryTurnWorld) =>
|
||||
world
|
||||
.listGenerals()
|
||||
.sort((left, right) => left.id - right.id)
|
||||
.map((general) => ({
|
||||
id: general.id,
|
||||
injury: general.injury,
|
||||
crew: general.crew,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
item: general.role.items.item,
|
||||
}));
|
||||
|
||||
const projectReferenceCity = (citySnapshot: ReferenceCity) => ({
|
||||
state: citySnapshot.state,
|
||||
population: citySnapshot.population,
|
||||
agriculture: citySnapshot.agriculture,
|
||||
commerce: citySnapshot.commerce,
|
||||
security: citySnapshot.security,
|
||||
trust: citySnapshot.trust,
|
||||
defence: citySnapshot.defence,
|
||||
wall: citySnapshot.wall,
|
||||
});
|
||||
|
||||
const projectCoreCity = (citySnapshot: City) => ({
|
||||
state: citySnapshot.state,
|
||||
population: citySnapshot.population,
|
||||
agriculture: citySnapshot.agriculture,
|
||||
commerce: citySnapshot.commerce,
|
||||
security: citySnapshot.security,
|
||||
trust: citySnapshot.meta.trust,
|
||||
defence: citySnapshot.defence,
|
||||
wall: citySnapshot.wall,
|
||||
});
|
||||
|
||||
const normalizeStoredLogText = (value: string): string =>
|
||||
value.replace(/^(?:<C>●<\/>|<S>◆<\/>|<R>★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '');
|
||||
|
||||
const projectReferenceLogs = (trace: ReferenceMonthlyTrace) => [
|
||||
...trace.afterDetails.worldHistory.map((entry) => ({
|
||||
scope: 'system',
|
||||
category: 'history',
|
||||
generalId: null,
|
||||
text: normalizeStoredLogText(entry.text),
|
||||
})),
|
||||
...trace.after.logs
|
||||
.filter((entry) => entry.generalId !== undefined)
|
||||
.map((entry) => ({
|
||||
scope: entry.scope,
|
||||
category: entry.category,
|
||||
generalId: entry.generalId ?? null,
|
||||
text: normalizeStoredLogText(entry.text),
|
||||
})),
|
||||
];
|
||||
|
||||
const buildCoreWorld = async (generalActionModules: GeneralActionModule[]): Promise<InMemoryTurnWorld> => {
|
||||
const state: TurnWorldState = {
|
||||
id: 1,
|
||||
currentYear: 193,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
lastTurnTime: new Date('0193-01-01T00:00:00.000Z'),
|
||||
meta: { hiddenSeed: 'disaster-test-6' },
|
||||
};
|
||||
const snapshot: TurnWorldSnapshot = {
|
||||
scenarioConfig: {
|
||||
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||
iconPath: '',
|
||||
map: {},
|
||||
const: {},
|
||||
environment: { mapName: map.id, unitSet: 'default' },
|
||||
},
|
||||
scenarioMeta: {
|
||||
title: 'monthly disaster reference',
|
||||
startYear: 190,
|
||||
life: null,
|
||||
fiction: null,
|
||||
history: [],
|
||||
ignoreDefaultEvents: false,
|
||||
},
|
||||
map,
|
||||
diplomacy: [],
|
||||
events: [],
|
||||
initialEvents: [],
|
||||
generals: [
|
||||
buildGeneral(1, '보호장수', 0, 'che_부적_태현청생부'),
|
||||
buildGeneral(3, '부상장수', 0, null),
|
||||
buildGeneral(5, '중상장수', 70, null),
|
||||
],
|
||||
cities: [city],
|
||||
nations: [],
|
||||
troops: [],
|
||||
};
|
||||
const world = new InMemoryTurnWorld(state, snapshot, {
|
||||
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
||||
});
|
||||
const event: TurnEvent = {
|
||||
id: 1,
|
||||
targetCode: 'month',
|
||||
priority: 1_000,
|
||||
condition: true,
|
||||
action: [['RaiseDisaster']],
|
||||
meta: {},
|
||||
};
|
||||
await createRaiseDisasterHandler({
|
||||
getWorld: () => world,
|
||||
generalActionModules,
|
||||
})([], { year: 193, month: 1, startyear: 190, currentEventID: event.id, turnTime: state.lastTurnTime }, event);
|
||||
return world;
|
||||
};
|
||||
|
||||
integration('RaiseDisaster core ↔ legacy database snapshot', () => {
|
||||
it('matches city damage, item-aware injury, and persisted logs', async () => {
|
||||
const reference = runReferenceMonthlyTrace();
|
||||
expect(reference.action).toBe('RaiseDisaster');
|
||||
expect(projectReferenceGenerals(reference.beforeDetails.generals)).toEqual([
|
||||
{ id: 1, injury: 0, crew: 99, train: 51, atmos: 50, item: 'che_부적_태현청생부' },
|
||||
{ id: 3, injury: 0, crew: 99, train: 51, atmos: 50, item: null },
|
||||
{ id: 5, injury: 70, crew: 99, train: 51, atmos: 50, item: null },
|
||||
]);
|
||||
|
||||
const moduleBundle = await loadActionModuleBundle();
|
||||
const core = await buildCoreWorld(moduleBundle.general);
|
||||
const coreCity = projectCoreCity(core.getCityById(1)!);
|
||||
const referenceCity = projectReferenceCity(reference.afterDetails.cities[0]!);
|
||||
expect({ ...coreCity, trust: undefined }).toEqual({ ...referenceCity, trust: undefined });
|
||||
expect(coreCity.trust).toBeCloseTo(referenceCity.trust);
|
||||
expect(projectCoreGenerals(core)).toEqual(projectReferenceGenerals(reference.afterDetails.generals));
|
||||
|
||||
const coreLogs = core.peekDirtyState().logs.map((entry) => ({
|
||||
scope: String(entry.scope).toLowerCase(),
|
||||
category: String(entry.category).toLowerCase(),
|
||||
generalId: entry.generalId ?? null,
|
||||
text: entry.text,
|
||||
}));
|
||||
expect(coreLogs).toEqual(projectReferenceLogs(reference));
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -93,6 +93,10 @@ integration('core ↔ legacy command-boundary differential', () => {
|
||||
it.each([
|
||||
['nation declaration', 'fixtures/turn-differential/nation-declaration.json'],
|
||||
['live sortie conquest', 'fixtures/turn-differential/live-sortie-conquest.json'],
|
||||
[
|
||||
'live sortie collapsed nation conflict cleanup',
|
||||
'fixtures/turn-differential/live-sortie-collapse-conflict.json',
|
||||
],
|
||||
['live sortie against a defending general', 'fixtures/turn-differential/live-sortie-defender.json'],
|
||||
[
|
||||
'live sortie against multiple defending generals',
|
||||
@@ -104,6 +108,10 @@ integration('core ↔ legacy command-boundary differential', () => {
|
||||
['live sortie conflict arbitration', 'fixtures/turn-differential/live-sortie-conflict-arbitration.json'],
|
||||
['live sortie tied conflict', 'fixtures/turn-differential/live-sortie-conflict-tie.json'],
|
||||
['live sortie outer lifecycle: conquest', 'fixtures/turn-differential/live-sortie-conquest.json'],
|
||||
[
|
||||
'live sortie outer lifecycle: collapsed nation conflict cleanup',
|
||||
'fixtures/turn-differential/live-sortie-collapse-conflict.json',
|
||||
],
|
||||
['live sortie outer lifecycle: defender', 'fixtures/turn-differential/live-sortie-defender.json'],
|
||||
[
|
||||
'live sortie outer lifecycle: multiple defenders',
|
||||
@@ -173,6 +181,12 @@ integration('core ↔ legacy command-boundary differential', () => {
|
||||
});
|
||||
expect(reference.after.cities.find((city) => city.id === 71)?.supplyState).toBe(1);
|
||||
}
|
||||
if (fixturePath.endsWith('live-sortie-collapse-conflict.json')) {
|
||||
expect(reference.before.cities.find((city) => city.id === 71)?.conflict).toEqual({ 2: 1234 });
|
||||
expect(reference.after.nations.some((nation) => nation.id === 2)).toBe(false);
|
||||
expect(Object.keys(reference.after.cities.find((city) => city.id === 71)?.conflict ?? {})).toEqual([]);
|
||||
expect(Object.keys(core.after.cities.find((city) => city.id === 71)?.conflict ?? {})).toEqual([]);
|
||||
}
|
||||
if (fixturePath.endsWith('live-sortie-conflict-arbitration.json')) {
|
||||
expect(reference.before.cities.find((city) => city.id === 70)?.conflict).toEqual({ 3: 10_000 });
|
||||
expect(reference.after.cities.find((city) => city.id === 70)).toMatchObject({
|
||||
|
||||
@@ -91,6 +91,7 @@ cd "$workspace_root"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @sammo-ts/infra prisma:generate
|
||||
pnpm --filter @sammo-ts/common build
|
||||
pnpm --filter @sammo-ts/infra build
|
||||
|
||||
database_url=$(build_database_url "$integration_schema")
|
||||
export POSTGRES_SCHEMA=$integration_schema
|
||||
|
||||
Reference in New Issue
Block a user