Files
core2026/packages/logic/test/actions/turn/nationDeceptionCommandLogParity.test.ts
T
Hide_D c63a49bd07 fix: 커맨드 차등 생명주기와 로그 그래프를 보강
장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다.

실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
2026-08-23 21:48:29 +00:00

403 lines
16 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { RandomGenerator } from '@sammo-ts/common';
import type { GeneralActionOutcome, GeneralActionResolveContext } from '../../../src/actions/engine.js';
import type { City, General, Nation } from '../../../src/domain/entities.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js';
import {
ActionResolver as DegradeRelationsResolver,
type DegradeRelationsResolveContext,
} from '../../../src/actions/turn/nation/che_이호경식.js';
import { ActionResolver as RaidResolver, type RaidResolveContext } from '../../../src/actions/turn/nation/che_급습.js';
import {
ActionResolver as LastStandResolver,
type DesperateFightResolveContext,
} from '../../../src/actions/turn/nation/che_필사즉생.js';
import {
ActionResolver as DeceptionResolver,
type DeceptionResolveContext,
} from '../../../src/actions/turn/nation/che_허보.js';
import {
ActionResolver as CounterStrategyResolver,
type CounterStrategyResolveContext,
} from '../../../src/actions/turn/nation/che_피장파장.js';
const rng: RandomGenerator = {
nextFloat1: () => 0.5,
nextBool: () => false,
nextInt: (minInclusive) => minInclusive,
};
const buildGeneral = (id: number, nationId: number, cityId: number, name: string): General => ({
id,
name,
nationId,
cityId,
troopId: 0,
stats: { leadership: 80, strength: 70, intelligence: 60 },
experience: 100,
dedication: 100,
officerLevel: id === 1 ? 12 : 1,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 1,
train: 80,
atmos: 80,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
});
const buildNation = (id: number, name: string, chiefGeneralId: number | null): Nation => ({
id,
name,
color: '#000000',
capitalCityId: id * 100,
chiefGeneralId,
gold: 10_000,
rice: 10_000,
power: 100,
level: 1,
typeCode: 'test',
meta: { gennum: 3, strategic_cmd_limit: 0 },
});
const buildCity = (id: number, nationId: number, name: string): City => ({
id,
name,
nationId,
level: 1,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 500,
agricultureMax: 1_000,
commerce: 500,
commerceMax: 1_000,
security: 500,
securityMax: 1_000,
supplyState: 1,
frontState: 0,
defence: 300,
defenceMax: 1_000,
wall: 300,
wallMax: 1_000,
meta: {},
});
const buildFixture = () => {
const actor = buildGeneral(1, 10, 100, '가람');
const friendlyTargets = [buildGeneral(2, 10, 100, '아군일'), buildGeneral(3, 10, 100, '아군이')];
const destTargets = [buildGeneral(4, 20, 200, '적군일'), buildGeneral(5, 20, 200, '적군이')];
return {
actor,
friendlyTargets,
destTargets,
nation: buildNation(10, '촉', actor.id),
destNation: buildNation(20, '위', destTargets[0]!.id),
destCity: buildCity(200, 20, '업'),
safeDestCity: buildCity(201, 20, '평원'),
};
};
const createActorLogSink = (actorId: number, logs: LogEntryDraft[]): GeneralActionResolveContext['addLog'] => {
return (text, options = {}) => {
const entry: LogEntryDraft = {
scope: options.scope ?? LogScope.GENERAL,
category: options.category ?? LogCategory.ACTION,
text,
format: options.format ?? LogFormat.MONTH,
...options,
};
if (entry.scope === LogScope.GENERAL && entry.generalId === undefined) {
entry.generalId = actorId;
}
logs.push(entry);
};
};
const collectLogs = (
actorId: number,
resolve: (addLog: GeneralActionResolveContext['addLog']) => GeneralActionOutcome
): LogEntryDraft[] => {
const logs: LogEntryDraft[] = [];
const outcome = resolve(createActorLogSink(actorId, logs));
for (const effect of outcome.effects) {
if (effect.type === 'log') {
logs.push(effect.entry);
}
}
return logs;
};
interface RefFlushExpectation {
actorId: number;
sourceNationId: number;
destNationId?: number;
friendlyTargetIds: number[];
destTargetIds: number[];
friendlyText: string;
destText?: string;
destNationText?: string;
destNationFormat?: LogFormat;
actorHistoryText: string;
actorActionText: string;
sourceNationText: string;
}
const projectLog = (entry: LogEntryDraft) => ({
scope: entry.scope,
category: entry.category,
owner:
entry.generalId !== undefined
? `general:${entry.generalId}`
: entry.nationId !== undefined
? `nation:${entry.nationId}`
: 'none',
text: entry.text,
format: entry.format,
group: entry.legacyFlushGroup ?? 0,
});
const expectRefFlush = (logs: LogEntryDraft[], expected: RefFlushExpectation): void => {
const internalEpochCount =
expected.friendlyTargetIds.length + expected.destTargetIds.length + (expected.destNationText ? 1 : 0);
const firstInternalGroup = -internalEpochCount;
const expectedLogs = [
...expected.friendlyTargetIds.map((generalId, index) => ({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
owner: `general:${generalId}`,
text: expected.friendlyText,
format: LogFormat.PLAIN,
group: firstInternalGroup + index,
})),
...expected.destTargetIds.map((generalId, index) => ({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
owner: `general:${generalId}`,
text: expected.destText,
format: LogFormat.PLAIN,
group: firstInternalGroup + expected.friendlyTargetIds.length + index,
})),
...(expected.destNationText
? [
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
owner: `nation:${expected.destNationId}`,
text: expected.destNationText,
format: expected.destNationFormat,
group: -1,
},
]
: []),
{
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
owner: `general:${expected.actorId}`,
text: expected.actorHistoryText,
format: LogFormat.YEAR_MONTH,
group: 0,
},
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
owner: `general:${expected.actorId}`,
text: expected.actorActionText,
format: LogFormat.MONTH,
group: 0,
},
{
scope: LogScope.NATION,
category: LogCategory.HISTORY,
owner: `nation:${expected.sourceNationId}`,
text: expected.sourceNationText,
format: LogFormat.YEAR_MONTH,
group: 0,
},
];
expect(orderLegacyActionLoggerFlush(logs).map(projectLog)).toEqual(expectedLogs);
};
describe('nation deception command Ref log parity', () => {
it('preserves che_이호경식 logger epochs, texts, categories, and formats', () => {
const fixture = buildFixture();
const logs = collectLogs(fixture.actor.id, (addLog) =>
new DegradeRelationsResolver([]).resolve(
{
general: fixture.actor,
nation: fixture.nation,
destNation: fixture.destNation,
diplomacy: { state: 0, term: 3 },
reverseDiplomacy: { state: 0, term: 3 },
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
destNationGenerals: fixture.destTargets,
rng,
addLog,
} satisfies DegradeRelationsResolveContext,
{ destNationId: fixture.destNation.id }
)
);
expectRefFlush(logs, {
actorId: fixture.actor.id,
sourceNationId: fixture.nation.id,
destNationId: fixture.destNation.id,
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
destTargetIds: fixture.destTargets.map((general) => general.id),
friendlyText: '<Y>가람</>이 <G><b>위</b></>에 <M>이호경식</>을 발동하였습니다.',
destText: '<D><b>촉</b></>이 아국에 <M>이호경식</>을 발동하였습니다.',
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국에 <M>이호경식</>을 발동',
destNationFormat: LogFormat.YEAR_MONTH,
actorHistoryText: '<D><b>위</b></>에 <M>이호경식</>을 발동',
actorActionText: '이호경식 발동!',
sourceNationText: '<Y>가람</>이 <D><b>위</b></>에 <M>이호경식</>을 발동',
});
});
it('preserves che_급습 logger epochs, texts, categories, and formats', () => {
const fixture = buildFixture();
const logs = collectLogs(fixture.actor.id, (addLog) =>
new RaidResolver([]).resolve(
{
general: fixture.actor,
nation: fixture.nation,
destNation: fixture.destNation,
diplomacy: { state: 1, term: 18 },
reverseDiplomacy: { state: 1, term: 18 },
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
destNationGenerals: fixture.destTargets,
rng,
addLog,
} satisfies RaidResolveContext,
{ destNationId: fixture.destNation.id }
)
);
expectRefFlush(logs, {
actorId: fixture.actor.id,
sourceNationId: fixture.nation.id,
destNationId: fixture.destNation.id,
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
destTargetIds: fixture.destTargets.map((general) => general.id),
friendlyText: '<Y>가람</>이 <G><b>위</b></>에 <M>급습</>을 발동하였습니다.',
destText: '아국에 <M>급습</>이 발동되었습니다.',
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국에 <M>급습</>을 발동',
destNationFormat: LogFormat.YEAR_MONTH,
actorHistoryText: '<D><b>위</b></>에 <M>급습</>을 발동',
actorActionText: '급습 발동!',
sourceNationText: '<Y>가람</>이 <D><b>위</b></>에 <M>급습</>을 발동',
});
});
it('preserves che_필사즉생 target applyDB epochs before the actor logger', () => {
const fixture = buildFixture();
const logs = collectLogs(fixture.actor.id, (addLog) =>
new LastStandResolver([]).resolve(
{
general: fixture.actor,
nation: fixture.nation,
nationGenerals: [fixture.actor, ...fixture.friendlyTargets],
rng,
addLog,
} satisfies DesperateFightResolveContext,
{}
)
);
expectRefFlush(logs, {
actorId: fixture.actor.id,
sourceNationId: fixture.nation.id,
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
destTargetIds: [],
friendlyText: '<Y>가람</>이 <M>필사즉생</>을 발동하였습니다.',
actorHistoryText: '<M>필사즉생</>을 발동',
actorActionText: '필사즉생 발동!',
sourceNationText: '<Y>가람</>이 <M>필사즉생</>을 발동',
});
});
it('preserves che_허보 per-general applyDB epochs and plain target-nation history', () => {
const fixture = buildFixture();
const deceptionRng: RandomGenerator = { ...rng, nextInt: () => 1 };
const logs = collectLogs(fixture.actor.id, (addLog) =>
new DeceptionResolver([]).resolve(
{
general: fixture.actor,
nation: fixture.nation,
destNation: fixture.destNation,
destCity: fixture.destCity,
destCityGenerals: fixture.destTargets,
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
destNationSupplyCities: [fixture.destCity, fixture.safeDestCity],
rng: deceptionRng,
addLog,
} satisfies DeceptionResolveContext,
{ destCityId: fixture.destCity.id }
)
);
expectRefFlush(logs, {
actorId: fixture.actor.id,
sourceNationId: fixture.nation.id,
destNationId: fixture.destNation.id,
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
destTargetIds: fixture.destTargets.map((general) => general.id),
friendlyText: '<Y>가람</>이 <G><b>업</b></>에 <M>허보</>를 발동하였습니다.',
destText: '상대의 <M>허보</>에 당했다!',
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국의 <G><b>업</b></>에 <M>허보</>를 발동',
destNationFormat: LogFormat.PLAIN,
actorHistoryText: '<G><b>업</b></>에 <M>허보</>를 발동',
actorActionText: '허보 발동!',
sourceNationText: '<Y>가람</>이 <G><b>업</b></>에 <M>허보</>를 발동',
});
});
it('preserves che_피장파장 logger epochs and year-month target-nation history', () => {
const fixture = buildFixture();
const logs = collectLogs(fixture.actor.id, (addLog) =>
new CounterStrategyResolver([]).resolve(
{
general: fixture.actor,
nation: fixture.nation,
destNation: fixture.destNation,
friendlyGenerals: [fixture.actor, ...fixture.friendlyTargets],
destNationGenerals: fixture.destTargets,
currentYearMonth: 2_231,
rng,
addLog,
} satisfies CounterStrategyResolveContext,
{ destNationId: fixture.destNation.id, commandType: 'che_허보' }
)
);
expectRefFlush(logs, {
actorId: fixture.actor.id,
sourceNationId: fixture.nation.id,
destNationId: fixture.destNation.id,
friendlyTargetIds: fixture.friendlyTargets.map((general) => general.id),
destTargetIds: fixture.destTargets.map((general) => general.id),
friendlyText: '<Y>가람</>이 <G><b>위</b></>에 <G><b>허보</b></> 전략의 <M>피장파장</>을 발동하였습니다.',
destText: '아국에 <G><b>허보</b></> 전략의 <M>피장파장</>이 발동되었습니다.',
destNationText: '<D><b>촉</b></>의 <Y>가람</>이 아국에 <G><b>허보</b></> <M>피장파장</>을 발동',
destNationFormat: LogFormat.YEAR_MONTH,
actorHistoryText: '<D><b>위</b></>에 <G><b>허보</b></> <M>피장파장</>을 발동',
actorActionText: '<G><b>허보</b></> 전략의 피장파장 발동!',
sourceNationText: '<Y>가람</>이 <D><b>위</b></>에 <G><b>허보</b></> <M>피장파장</>을 발동',
});
});
});