fix: 커맨드 차등 생명주기와 로그 그래프를 보강

장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다.

실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
2026-08-23 21:48:29 +00:00
parent 85591c68ad
commit c63a49bd07
128 changed files with 8615 additions and 848 deletions
@@ -116,5 +116,8 @@ describe('appointment global summary log format', () => {
);
expectMonthlySummary(logs);
expect(
logs.find((entry) => entry.scope === LogScope.GENERAL && entry.category === LogCategory.HISTORY)?.format
).toBe(LogFormat.YEAR_MONTH);
});
});
@@ -27,18 +27,21 @@ describe('processGeneralActionWithFallback', () => {
const fallbackResolver: GeneralActionResolver = {
key: 'fallbackCmd',
resolve: () => ({
effects: [
{
type: 'log',
entry: { text: 'Primary failed', scope: 'general', category: 'action', format: 'month' },
} as any,
],
alternative: {
commandKey: 'alternativeCmd',
args: { foo: 'bar' },
},
}),
resolve: (context) => {
context.addPostProgressionLog?.('Primary post-progression');
return {
effects: [
{
type: 'log',
entry: { text: 'Primary failed', scope: 'general', category: 'action', format: 'month' },
} as any,
],
alternative: {
commandKey: 'alternativeCmd',
args: { foo: 'bar' },
},
};
},
};
const alternativeResolver: GeneralActionResolver = {
@@ -100,18 +103,8 @@ describe('processGeneralActionWithFallback', () => {
mockLoader
);
// It should eventually execute alternativeResolver
// BUT resolveGeneralAction creates a FRESH resolution from the FINAL resolver.
// It does NOT merge logs currently. (As per my implementation comment)
// Wait, did I implement log merging? No.
// I implemented a simple loop that re-runs `resolveGeneralAction`.
// So the final resolution comes from `alternativeResolver`.
// Let's verify what we expect.
// If we want legacy parity, we might expect logs from the first attempt too.
// But for now, let's verify the loop works.
expect(resolution.logs).toHaveLength(2); // 'Primary failed' + 'Alternative executed...'
expect(resolution.postProgressionLogs.map((entry) => entry.text)).toEqual(['Primary post-progression']);
expect(resolution.alternative).toBeUndefined(); // The final one succeeded
expect(mockLoader.load).toHaveBeenCalledWith('alternativeCmd');
});
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '../../../src/domain/entities.js';
import type { GeneralActionResolveContext } from '../../../src/actions/engine.js';
import { ActionDefinition as TradeAction } from '../../../src/actions/turn/general/che_군량매매.js';
import { ActionResolver as ResignAction } from '../../../src/actions/turn/general/che_하야.js';
import { ActionResolver as RetireAction } from '../../../src/actions/turn/general/che_은퇴.js';
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
import { finalizeLogEntry } from '../../../src/logging/entries.js';
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js';
const makeGeneral = (overrides: Partial<General> = {}): General =>
({
id: 1,
name: '검증장수',
nationId: 2,
cityId: 3,
troopId: 0,
npcState: 0,
officerLevel: 1,
experience: 100,
dedication: 100,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 1,
train: 100,
atmos: 100,
injury: 0,
age: 60,
stats: { leadership: 70, strength: 60, intelligence: 50 },
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {},
...overrides,
}) as General;
const nation = {
id: 2,
name: '검증국',
color: '#ff0000',
capitalCityId: 3,
chiefGeneralId: 1,
gold: 10_000,
rice: 10_000,
power: 0,
level: 1,
typeCode: 'che_def',
meta: { gennum: 1 },
} satisfies Nation;
const city = {
id: 3,
name: '검증도시',
nationId: nation.id,
level: 1,
state: 0,
population: 20_000,
populationMax: 50_000,
agriculture: 500,
agricultureMax: 1_000,
commerce: 500,
commerceMax: 1_000,
security: 500,
securityMax: 1_000,
defence: 300,
defenceMax: 1_000,
wall: 300,
wallMax: 1_000,
supplyState: 1,
frontState: 0,
meta: { trade: 100 },
} satisfies City;
const createLogSink =
(logs: LogEntryDraft[]): GeneralActionResolveContext['addLog'] =>
(text, options = {}) => {
const entry: LogEntryDraft = {
scope: options.scope ?? LogScope.GENERAL,
category: options.category ?? LogCategory.ACTION,
text,
...options,
};
if (entry.scope === LogScope.GENERAL && entry.generalId === undefined) {
entry.generalId = 1;
}
logs.push(entry);
};
const expectMonthlyPersistence = (entry: LogEntryDraft, legacyWrongFormat: LogFormat): void => {
expect(entry.format).toBe(LogFormat.MONTH);
const persisted = finalizeLogEntry(entry, { year: 186, month: 9 });
const mutant = finalizeLogEntry({ ...entry, format: legacyWrongFormat }, { year: 186, month: 9 });
expect(persisted?.text).toMatch(/^<C><\/>9:/u);
expect(mutant?.text).not.toBe(persisted?.text);
expect(mutant?.text).not.toMatch(/^<C><\/>9:/u);
};
describe('general command Ref log format parity', () => {
it.each([
{ buyRice: true, amount: 100 },
{ buyRice: false, amount: 100 },
])('keeps che_군량매매 action logs on the Ref monthly format for $buyRice', (args) => {
const logs: LogEntryDraft[] = [];
const action = new TradeAction();
action.resolve(
{
general: makeGeneral(),
city,
nation: { ...nation },
rng: { nextFloat1: () => 0 },
addLog: createLogSink(logs),
} as unknown as Parameters<typeof action.resolve>[0],
args
);
expect(logs).toHaveLength(1);
expectMonthlyPersistence(logs[0]!, LogFormat.PLAIN);
});
it('keeps the che_하야 system summary on the Ref monthly format', () => {
const logs: LogEntryDraft[] = [];
const action = new ResignAction({ defaultNpcGold: 1_000, defaultNpcRice: 1_000 } as TurnCommandEnv);
action.resolve(
{
general: makeGeneral(),
nation,
troopMembers: [],
rng: {},
addLog: createLogSink(logs),
} as unknown as Parameters<typeof action.resolve>[0],
{}
);
const summary = logs.find((entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY);
expect(summary).toBeDefined();
expectMonthlyPersistence(summary!, LogFormat.RAWTEXT);
});
it('keeps the che_은퇴 system summary on the Ref monthly format', () => {
const logs: LogEntryDraft[] = [];
const action = new RetireAction();
action.resolve(
{
general: makeGeneral(),
rng: {},
addLog: createLogSink(logs),
} as unknown as Parameters<typeof action.resolve>[0],
{}
);
const summary = logs.find((entry) => entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY);
expect(summary).toBeDefined();
expectMonthlyPersistence(summary!, LogFormat.RAWTEXT);
});
});
@@ -0,0 +1,312 @@
import { describe, expect, it } from 'vitest';
import type { City, General, GeneralTriggerState, Nation } from '../../../src/domain/entities.js';
import {
resolveGeneralAction,
type GeneralActionResolveInputContext,
type GeneralActionResolver,
} from '../../../src/actions/engine.js';
import { ActionDefinition as MoveCapitalAction } from '../../../src/actions/turn/nation/che_천도.js';
import { ActionDefinition as ExpandCityAction } from '../../../src/actions/turn/nation/che_증축.js';
import { ActionDefinition as ReduceCityAction } from '../../../src/actions/turn/nation/che_감축.js';
import { ActionDefinition as RandomMoveCapitalAction } from '../../../src/actions/turn/nation/che_무작위수도이전.js';
import { ActionDefinition as ScorchedEarthAction } from '../../../src/actions/turn/nation/che_초토화.js';
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '../../../src/logging/types.js';
import type { TurnSchedule } from '../../../src/turn/calendar.js';
import type { MapDefinition } from '../../../src/world/types.js';
const ENV: TurnCommandEnv = {
develCost: 100,
trainDelta: 30,
atmosDelta: 30,
maxTrainByCommand: 100,
maxAtmosByCommand: 100,
sabotageDefaultProb: 0.5,
sabotageProbCoefByStat: 0.1,
sabotageDefenceCoefByGeneralCount: 0.1,
sabotageDamageMin: 10,
sabotageDamageMax: 30,
openingPartYear: 3,
maxGeneral: 500,
defaultNpcGold: 1_000,
defaultNpcRice: 1_000,
defaultCrewTypeId: 1_100,
defaultSpecialDomestic: null,
defaultSpecialWar: null,
initialNationGenLimit: 10,
maxTechLevel: 12,
baseGold: 1_000,
baseRice: 2_000,
maxResourceActionAmount: 10_000,
};
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 60 }] };
const rng = {
nextFloat1: () => 0,
nextBool: () => false,
nextInt: () => 0,
};
const makeGeneral = (id: number, name = '운영자'): General => ({
id,
name,
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 80, strength: 70, intelligence: 60 },
experience: 1_000,
dedication: 1_000,
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_100,
train: 100,
atmos: 100,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, betray: 0 },
});
const makeNation = (): Nation => ({
id: 1,
name: '위',
color: '#111111',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 1_000_000,
rice: 1_000_000,
power: 1_000,
level: 1,
typeCode: 'che_명가',
meta: { capset: 0, can_무작위수도이전: 1, surlimit: 0 },
});
const makeCity = (id: number, name: string, nationId: number): City => ({
id,
name,
nationId,
level: 5,
state: 0,
population: 200_000,
populationMax: 300_000,
agriculture: 3_000,
agricultureMax: 4_000,
commerce: 3_000,
commerceMax: 4_000,
security: 3_000,
securityMax: 4_000,
supplyState: 1,
frontState: 0,
defence: 3_000,
defenceMax: 4_000,
wall: 3_000,
wallMax: 4_000,
conflict: {},
meta: { trust: 80, trade: 100 },
});
const mapStats = {
population: 200_000,
agriculture: 3_000,
commerce: 3_000,
security: 3_000,
defence: 3_000,
wall: 3_000,
};
const map: MapDefinition = {
id: 'nation-capital-log-parity',
name: '국가 명령 로그',
cities: [
{
id: 1,
name: '허창',
level: 5,
region: 1,
position: { x: 0, y: 0 },
connections: [2],
initial: mapStats,
max: mapStats,
},
{
id: 2,
name: '낙양',
level: 5,
region: 1,
position: { x: 1, y: 0 },
connections: [1],
initial: mapStats,
max: mapStats,
},
],
};
const resolveLogs = <Args>(
resolver: GeneralActionResolver<GeneralTriggerState, Args>,
context: GeneralActionResolveInputContext & Record<string, unknown>,
args: Args
): LogEntryDraft[] =>
orderLegacyActionLoggerFlush(
resolveGeneralAction(resolver, context, { now: new Date('2026-08-23T00:00:00.000Z'), schedule }, args).logs
);
const projectLogs = (logs: readonly LogEntryDraft[]) =>
logs.map((log) => [
log.scope,
log.category,
log.generalId ?? null,
log.nationId ?? null,
log.format,
log.legacyFlushGroup ?? 0,
log.text,
]);
const expectedActorLoggerFlush = (params: {
actorId?: number;
nationId?: number;
generalHistory: string;
generalAction: string;
nationHistory: string;
globalHistory: string;
globalSummary: string;
}) => [
[LogScope.GENERAL, LogCategory.HISTORY, params.actorId ?? 1, null, LogFormat.YEAR_MONTH, 0, params.generalHistory],
[LogScope.GENERAL, LogCategory.ACTION, params.actorId ?? 1, null, LogFormat.MONTH, 0, params.generalAction],
[LogScope.NATION, LogCategory.HISTORY, null, params.nationId ?? 1, LogFormat.YEAR_MONTH, 0, params.nationHistory],
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0, params.globalHistory],
[LogScope.SYSTEM, LogCategory.SUMMARY, null, null, LogFormat.MONTH, 0, params.globalSummary],
];
describe('nation capital command Ref ActionLogger parity', () => {
it('che_천도', () => {
const general = makeGeneral(1);
const nation = makeNation();
const capitalCity = makeCity(1, '허창', 1);
const destCity = makeCity(2, '낙양', 1);
const logs = resolveLogs(
new MoveCapitalAction(ENV),
{ general, city: capitalCity, nation, destCity, map, nationCities: [capitalCity, destCity], rng },
{ destCityID: destCity.id }
);
expect(projectLogs(logs)).toEqual(
expectedActorLoggerFlush({
generalHistory: '<G><b>낙양</b></>으로 <M>천도</>명령',
generalAction: '<G><b>낙양</b></>으로 천도했습니다.',
nationHistory: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>천도</> 명령',
globalHistory: '<S><b>【천도】</b></><D><b>위</b></>가 <G><b>낙양</b></>으로 <M>천도</>하였습니다.',
globalSummary: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>천도</>를 명령하였습니다.',
})
);
});
it.each([
{
action: '증축',
resolver: new ExpandCityAction(ENV),
globalHistoryPrefix: '<C><b>【증축】</b></>',
},
{
action: '감축',
resolver: new ReduceCityAction(ENV),
globalHistoryPrefix: '<M><b>【감축】</b></>',
},
])('che_$action', ({ action, resolver, globalHistoryPrefix }) => {
const general = makeGeneral(1);
const nation = makeNation();
const capitalCity = makeCity(1, '낙양', 1);
const logs = resolveLogs(resolver, { general, city: capitalCity, nation, capitalCity, rng }, {});
expect(projectLogs(logs)).toEqual(
expectedActorLoggerFlush({
generalHistory: `<G><b>낙양</b></>을 <M>${action}</>`,
generalAction: `<G><b>낙양</b></>을 ${action}했습니다.`,
nationHistory: `<Y>운영자</>가 <G><b>낙양</b></>을 <M>${action}</>`,
globalHistory: `${globalHistoryPrefix}<D><b>위</b></>가 <G><b>낙양</b></>을 <M>${action}</>하였습니다.`,
globalSummary: `<Y>운영자</>가 <G><b>낙양</b></>을 <M>${action}</>하였습니다.`,
})
);
});
it('che_무작위수도이전', () => {
const general = makeGeneral(1);
const follower = makeGeneral(2, '부하');
const nation = makeNation();
const capitalCity = makeCity(1, '허창', 1);
const destCity = makeCity(2, '낙양', 0);
const logs = resolveLogs(
new RandomMoveCapitalAction(),
{
general,
city: capitalCity,
nation,
neutralCandidateCities: [destCity],
nationGenerals: [general, follower],
oldCapitalCity: capitalCity,
rng,
},
{}
);
expect(projectLogs(logs)).toEqual([
[
LogScope.GENERAL,
LogCategory.ACTION,
follower.id,
null,
LogFormat.PLAIN,
-1,
'국가 수도를 <G><b>낙양</b></>으로 옮겼습니다.',
],
...expectedActorLoggerFlush({
generalHistory: '<G><b>낙양</b></>으로 <M>무작위 수도 이전</>',
generalAction: '<G><b>낙양</b></>으로 국가를 옮겼습니다.',
nationHistory: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>무작위 수도 이전</>',
globalHistory:
'<S><b>【무작위 수도 이전】</b></><D><b>위</b></>가 <G><b>낙양</b></>으로 <M>수도 이전</>하였습니다.',
globalSummary: '<Y>운영자</>가 <G><b>낙양</b></>으로 <M>수도 이전</>하였습니다.',
}),
]);
});
it('che_초토화', () => {
const general = makeGeneral(1);
const follower = makeGeneral(2, '부하');
const nation = makeNation();
const capitalCity = makeCity(1, '허창', 1);
const destCity = makeCity(2, '낙양', 1);
const logs = resolveLogs(
new ScorchedEarthAction(),
{
general,
city: capitalCity,
nation,
destCity,
destNation: nation,
friendlyGenerals: [general, follower],
rng,
},
{ destCityId: destCity.id }
);
expect(projectLogs(logs)).toEqual(
expectedActorLoggerFlush({
generalHistory: '<G><b>낙양</b></>을 <M>초토화</> 명령',
generalAction: '<G><b>낙양</b></>을 초토화했습니다.',
nationHistory: '<Y>운영자</>가 <G><b>낙양</b></>을 <M>초토화</> 명령',
globalHistory: '<S><b>【초토화】</b></><D><b>위</b></>가 <G><b>낙양</b></>을 <M>초토화</>하였습니다.',
globalSummary: '<Y>운영자</>가 <G><b>낙양</b></>을 <M>초토화</>하였습니다.',
})
);
});
});
@@ -0,0 +1,267 @@
import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import { describe, expect, it } from 'vitest';
import type { City, General, Nation } from '../../../src/domain/entities.js';
import { resolveGeneralAction, type TurnScheduleContext } from '../../../src/actions/engine.js';
import { ActionDefinition as TroopKickAction } from '../../../src/actions/turn/nation/che_부대탈퇴지시.js';
import { ActionDefinition as PopulationMoveAction } from '../../../src/actions/turn/nation/cr_인구이동.js';
import { ActionResolver as MobilizePeopleAction } from '../../../src/actions/turn/nation/che_백성동원.js';
import {
ActionResolver as VolunteerRecruitAction,
type VolunteerRecruitEnvironment,
} from '../../../src/actions/turn/nation/che_의병모집.js';
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import { finalizeLogEntry } from '../../../src/logging/entries.js';
import { LogCategory, type LogEntryDraft, LogFormat, LogScope } from '../../../src/logging/types.js';
const scheduleContext: TurnScheduleContext = {
now: new Date('2026-08-23T00:00:00.000Z'),
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
};
const makeGeneral = (overrides: Partial<General> = {}): General =>
({
id: 1,
name: '군주',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
officerLevel: 12,
experience: 1_000,
dedication: 1_000,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 1,
train: 100,
atmos: 100,
injury: 0,
age: 30,
stats: { leadership: 70, strength: 60, intelligence: 50 },
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {},
...overrides,
}) as General;
const makeNation = (overrides: Partial<Nation> = {}): Nation => ({
id: 1,
name: '검증국',
color: '#ff0000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 10_000,
rice: 10_000,
power: 0,
level: 1,
typeCode: 'che_def',
meta: { gennum: 2, strategic_cmd_limit: 0 },
...overrides,
});
const makeCity = (overrides: Partial<City> = {}): City => ({
id: 1,
name: '성도',
nationId: 1,
level: 1,
state: 0,
population: 50_000,
populationMax: 100_000,
agriculture: 500,
agricultureMax: 1_000,
commerce: 500,
commerceMax: 1_000,
security: 500,
securityMax: 1_000,
defence: 300,
defenceMax: 1_000,
wall: 300,
wallMax: 1_000,
supplyState: 1,
frontState: 0,
meta: {},
...overrides,
});
const makeRng = (): RandUtil => new RandUtil(new ConstantRNG(0));
const expectMonthlyPersistence = (entry: LogEntryDraft, wrongFormat: LogFormat): void => {
expect(entry.format).toBe(LogFormat.MONTH);
const persisted = finalizeLogEntry(entry, { year: 186, month: 9 });
const mutant = finalizeLogEntry({ ...entry, format: wrongFormat }, { year: 186, month: 9 });
expect(persisted?.text).toMatch(/^<C><\/>9:/u);
expect(mutant?.text).not.toBe(persisted?.text);
expect(mutant?.text).not.toMatch(/^<C><\/>9:/u);
};
describe('nation command Ref log parity', () => {
it('flushes che_부대탈퇴지시 actor then target with the Ref monthly format', () => {
const actor = makeGeneral();
const target = makeGeneral({ id: 2, name: '부대원', troopId: 3 });
const resolution = resolveGeneralAction(
new TroopKickAction(),
{
general: actor,
nation: makeNation(),
city: makeCity(),
destGeneral: target,
rng: makeRng(),
} as never,
scheduleContext,
{ destGeneralId: target.id }
);
const logs = orderLegacyActionLoggerFlush(resolution.logs);
expect(logs).toHaveLength(2);
expect(logs.map((entry) => entry.generalId)).toEqual([actor.id, target.id]);
expect(logs.map((entry) => entry.text)).toEqual([
'<Y>부대원</>에게 부대 탈퇴를 지시했습니다.',
'<Y>군주</>에게 부대 탈퇴를 지시 받았습니다.',
]);
expect(logs[1]?.legacyFlushGroup).toBe(1);
expectMonthlyPersistence(logs[1]!, LogFormat.PLAIN);
});
it('keeps cr_인구이동 population text ungrouped like PHP integer interpolation', () => {
const actor = makeGeneral();
const source = makeCity();
const destination = makeCity({ id: 2, name: '락양', population: 10_000 });
const resolution = resolveGeneralAction(
new PopulationMoveAction({ develCost: 100, baseGold: 1_000, baseRice: 1_000 } as TurnCommandEnv),
{
general: actor,
nation: makeNation(),
city: source,
destCity: destination,
destNation: makeNation(),
rng: makeRng(),
} as never,
scheduleContext,
{ destCityId: destination.id, amount: 10_000 }
);
const [entry] = resolution.logs;
expect(entry).toMatchObject({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: actor.id,
format: LogFormat.MONTH,
text: '<G><b>락양</b></>으로 인구 <C>10000</>명을 옮겼습니다.',
});
expect(entry?.text).not.toBe('<G><b>락양</b></>으로 인구 <C>10,000</>명을 옮겼습니다.');
});
it('keeps che_백성동원 notification and history streams distinct in Ref flush order', () => {
const actor = makeGeneral();
const target = makeGeneral({ id: 2, name: '동료' });
const nation = makeNation();
const destination = makeCity();
const resolution = resolveGeneralAction(
new MobilizePeopleAction([], 10),
{
general: actor,
nation,
city: makeCity(),
destCity: destination,
friendlyGenerals: [actor, target],
rng: makeRng(),
} as never,
scheduleContext,
{ destCityId: destination.id }
);
const logs = orderLegacyActionLoggerFlush(resolution.logs);
expect(logs.map((entry) => [entry.scope, entry.category, entry.generalId, entry.nationId])).toEqual([
[LogScope.GENERAL, LogCategory.ACTION, target.id, undefined],
[LogScope.GENERAL, LogCategory.HISTORY, actor.id, undefined],
[LogScope.GENERAL, LogCategory.ACTION, actor.id, undefined],
[LogScope.NATION, LogCategory.HISTORY, undefined, nation.id],
]);
expect(logs[0]).toMatchObject({
text: '<Y>군주</>가 <G><b>성도</b></>에 <M>백성동원</>을 하였습니다.',
format: LogFormat.PLAIN,
legacyFlushGroup: -1,
});
expect(logs[3]).toMatchObject({
text: '<Y>군주</>가 <G><b>성도</b></>에 <M>백성동원</>을 발동',
format: LogFormat.YEAR_MONTH,
});
expect(logs[3]?.text).not.toBe(logs[0]?.text);
});
it('preserves che_의병모집 actor history markup and Ref flush order', () => {
const actor = makeGeneral();
const target = makeGeneral({ id: 2, name: '동료' });
const nation = makeNation();
const environment: VolunteerRecruitEnvironment = {
openingPartYear: 0,
initialNationGenLimit: 10,
defaultNpcGold: 1_000,
defaultNpcRice: 1_000,
defaultCrewTypeId: 1,
defaultSpecialDomestic: null,
defaultSpecialWar: null,
createCountBase: 0,
createCountDivisor: 8,
};
const resolution = resolveGeneralAction(
new VolunteerRecruitAction([], environment),
{
general: actor,
nation,
city: makeCity(),
rng: makeRng(),
currentYear: 190,
currentMonth: 1,
startYear: 180,
centennialRules: {
defaultStatMin: 15,
defaultStatMax: 80,
defaultStatTotal: 165,
maxStatLevel: 255,
defaultSpecialDomestic: null,
dexLimit: 1_000_000,
},
centennialNpcDexTargetRatio: 0.4,
averageNationGeneralCount: 0,
nationAverageStats: { leadership: 50, strength: 50, intelligence: 50 },
nationAverageExperience: 0,
nationAverageDedication: 0,
nationAverageDex: [100, 100, 100, 100, 100],
friendlyGenerals: [actor, target],
createGeneralId: () => 3,
turnTermSeconds: 60,
turnTimeBase: new Date('0190-01-01T00:00:00.000Z'),
ticksPerSecond: 1,
} as never,
scheduleContext,
{}
);
const logs = orderLegacyActionLoggerFlush(resolution.logs);
expect(logs.map((entry) => [entry.scope, entry.category, entry.generalId, entry.nationId])).toEqual([
[LogScope.GENERAL, LogCategory.ACTION, target.id, undefined],
[LogScope.GENERAL, LogCategory.HISTORY, actor.id, undefined],
[LogScope.GENERAL, LogCategory.ACTION, actor.id, undefined],
[LogScope.NATION, LogCategory.HISTORY, undefined, nation.id],
]);
expect(logs[0]).toMatchObject({
text: '<Y>군주</>가 <M>의병모집</>을 발동하였습니다.',
format: LogFormat.PLAIN,
legacyFlushGroup: -1,
});
expect(logs[1]).toMatchObject({
text: '<M>의병모집</>을 발동',
format: LogFormat.YEAR_MONTH,
});
expect(logs[1]?.text).not.toBe('의병모집 발동');
});
});
@@ -0,0 +1,402 @@
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>피장파장</>을 발동',
});
});
});
@@ -3,6 +3,7 @@ import type { City, General, Nation } from '../../../src/domain/entities.js';
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
import { evaluateConstraints } from '../../../src/constraints/evaluate.js';
import { resolveGeneralAction } from '../../../src/actions/engine.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import type { MapDefinition } from '../../../src/world/types.js';
import type { TurnSchedule } from '../../../src/turn/calendar.js';
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
@@ -355,6 +356,8 @@ describe('Nation Missing Actions', () => {
expect(definition.parseArgs({ destNationId: 2, amountList: [-1, 10] })).toBeNull();
const general = buildGeneral(1, 1, 1);
const sourceChief = buildGeneral(2, 1, 1, 'SourceChief');
const destChief = buildGeneral(3, 2, 2, 'DestChief');
const nation = { ...buildNation(1), gold: 1000, rice: 1000 };
const destNation = { ...buildNation(2), gold: 100, rice: 100 };
const resolution = resolveGeneralAction(
@@ -364,8 +367,8 @@ describe('Nation Missing Actions', () => {
city: buildCity(1, 1),
nation,
destNation,
friendlyChiefs: [general],
destNationChiefs: [],
friendlyChiefs: [general, sourceChief],
destNationChiefs: [destChief],
rng: {} as any,
addLog: () => {},
} as any,
@@ -388,6 +391,16 @@ describe('Nation Missing Actions', () => {
}),
}),
});
const orderedLogs = orderLegacyActionLoggerFlush(resolution.logs);
expect(orderedLogs.map((log) => log.legacyFlushGroup ?? 0)).toEqual([-1, -1, 0, 0, 0, 0, 0, 1]);
expect(orderedLogs.slice(0, 2).map((log) => log.generalId)).toEqual([sourceChief.id, destChief.id]);
expect(orderedLogs.at(-1)).toEqual(
expect.objectContaining({
nationId: destNation.id,
legacyFlushGroup: 1,
})
);
});
it('che_초토화: blocks when diplomacy limit exists', () => {
@@ -109,16 +109,21 @@ describe('nation volunteer recruitment lifespan', () => {
return;
}
const created = createdEffect.general as General & { bornYear?: number; deadYear?: number };
const created = createdEffect.general as General & { affinity?: number; bornYear?: number; deadYear?: number };
expect(created).toMatchObject({
name: 'ⓖ장수',
affinity: 1,
bornYear: 170,
deadYear: 200,
experience: 2_000,
dedication: 2_000,
meta: {
affinity: 1,
birthYear: 170,
deathYear: 200,
npc_org: 4,
explevel: 0,
dedlevel: 1,
},
});
});
@@ -130,6 +130,7 @@ describe('talent scout scenario general pool', () => {
imageServer: 1,
role: { specialDomestic: null, specialWar: null },
meta: {
npc_org: 3,
dex1: 12,
dex2: 24,
dex3: 36,