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
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { ActionResolver } from '../../../src/actions/turn/general/che_등용.js';
import { ActionResolver, actionContextBuilder } from '../../../src/actions/turn/general/che_등용.js';
describe('che_등용 recruitment message', () => {
it('queues the Ref scout prompt with sender and receiver snapshots', () => {
@@ -30,6 +30,23 @@ describe('che_등용 recruitment message', () => {
const sourceNation = { id: 1, name: '위', color: '#ffffff' };
const destinationNation = { id: 2, name: '촉', color: '#000000' };
const messageTime = new Date('0200-01-01T00:10:00.000Z');
const actorTurnTime = new Date('0200-01-01T00:00:00.000Z');
const builtContext = actionContextBuilder(
{
general: { ...general, turnTime: actorTurnTime },
nation: sourceNation,
rng: {},
} as never,
{
gameNow: messageTime,
messageSharedIconBaseUrl: 'https://ref.example/image/icons',
actionArgs: { destGeneralId: destination.id },
worldRef: { getGeneralById: () => destination },
scenarioConfig: { const: {} },
} as never
);
expect(builtContext).toMatchObject({ messageTime });
const result = new ActionResolver().resolve(
{
@@ -54,7 +71,7 @@ describe('che_등용 recruitment message', () => {
nationId: sourceNation.id,
nationName: sourceNation.name,
color: sourceNation.color,
icon: '',
icon: 'https://sam-image.hided.net/icons/default.jpg',
},
dest: {
generalId: destination.id,
@@ -62,13 +79,16 @@ describe('che_등용 recruitment message', () => {
nationId: destinationNation.id,
nationName: destinationNation.name,
color: destinationNation.color,
icon: '',
icon: 'https://sam-image.hided.net/icons/default.jpg',
},
text: '위로 망명 권유 서신',
time: messageTime,
validUntil: new Date('9999-12-31T12:59:59.000Z'),
option: { action: 'scout' },
sendDestOnly: true,
},
});
expect(result.effects.filter((effect) => effect.type === 'log')).toEqual([]);
expect(logs).toHaveLength(1);
});
});
@@ -5,6 +5,9 @@ import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
import type { TurnCommandEnv } from '../../../src/actions/turn/commandEnv.js';
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
import { resolveGeneralAction } from '../../../src/actions/engine.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import { LogCategory, LogFormat, LogScope } from '../../../src/logging/types.js';
const MOCK_SCENARIO_BASE = {
title: 'Test',
@@ -224,6 +227,57 @@ describe('che_등용수락', () => {
const { commandSpec } = await import('../../../src/actions/turn/general/che_등용수락.js');
const orderingResolution = resolveGeneralAction(
commandSpec.createDefinition(systemEnv),
{
general: structuredClone(neutralGen),
city: structuredClone(world.getCity(neutralGen.cityId)),
nation: null,
destNation: structuredClone(nation2),
destGeneral: structuredClone(recruiterGen),
rng: {} as any,
addLog: () => {},
} as any,
{
now: new Date('2026-08-23T00:00:00.000Z'),
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
},
{ destNationId: 2, destGeneralId: 2 }
);
const recruiterLogs = orderLegacyActionLoggerFlush(orderingResolution.logs).filter(
(log) => log.generalId === recruiterGen.id
);
expect(recruiterLogs.map((log) => [log.category, log.legacyFlushGroup])).toEqual([
[LogCategory.HISTORY, 1],
[LogCategory.ACTION, 1],
[LogCategory.ACTION, 1],
[LogCategory.ACTION, 1],
]);
expect(recruiterLogs.map((log) => log.text)).toEqual([
expect.stringContaining('등용에 성공'),
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('등용에 성공했습니다.'),
]);
expect(recruiterLogs.map((log) => log.format)).toEqual([
LogFormat.YEAR_MONTH,
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
]);
expect(
orderingResolution.logs.find(
(log) =>
log.scope === LogScope.GENERAL &&
log.category === LogCategory.ACTION &&
log.text.includes('망명하여 수도로')
)?.format
).toBe(LogFormat.MONTH);
expect(
orderingResolution.logs.find((log) => log.scope === LogScope.SYSTEM && log.category === LogCategory.SUMMARY)
?.format
).toBe(LogFormat.MONTH);
await runner.runTurn([
{
generalId: neutralGen.id,
@@ -3,6 +3,8 @@ import type { General, Nation } from '../../../src/domain/entities.js';
import { buildScenarioBootstrap } from '../../../src/world/bootstrap.js';
import { InMemoryWorld, TestGameRunner } from '../../testEnv.js';
import type { MapDefinition } from '../../../src/world/types.js';
import { resolveGeneralAction } from '../../../src/actions/engine.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
const MOCK_SCENARIO_BASE = {
title: 'Test',
@@ -56,6 +58,49 @@ const LINEAR_MAP: MapDefinition = {
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
};
const buildGeneral = (id: number, name: string): General => ({
id,
name,
nationId: 1,
cityId: 101,
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: null },
},
injury: 0,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 10,
age: 20,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
});
const buildNation = (level = 1): Nation => ({
id: 1,
name: 'MyNation',
color: '#000',
capitalCityId: 101,
chiefGeneralId: 1,
gold: 0,
rice: 0,
power: 0,
level,
typeCode: 'che_def',
meta: {},
});
describe('che_이동', () => {
it('applies movement side effects (gold/atmos/exp/leadership_exp)', async () => {
const bootstrapResult = buildScenarioBootstrap({
@@ -66,47 +111,8 @@ describe('che_이동', () => {
const world = new InMemoryWorld(bootstrapResult.snapshot);
const runner = new TestGameRunner(world, 200, 1);
const general: General = {
id: 1,
name: 'Mover',
nationId: 1,
cityId: 101,
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: null },
},
injury: 0,
gold: 1000,
rice: 1000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 10,
age: 20,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
};
const nation: Nation = {
id: 1,
name: 'MyNation',
color: '#000',
capitalCityId: 101,
chiefGeneralId: 1,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
};
const general = buildGeneral(1, 'Mover');
const nation = buildNation();
world.snapshot.generals.push(general);
world.snapshot.nations.push(nation);
@@ -134,4 +140,46 @@ describe('che_이동', () => {
expect(updated?.experience).toBe(50);
expect(updated?.meta.leadership_exp).toBe(1);
});
it('logs roaming followers before the actor logger flush', async () => {
const leader = { ...buildGeneral(1, 'Leader'), officerLevel: 12 };
const follower = buildGeneral(2, 'Follower');
const nation = buildNation(0);
const moveDef = (await import('../../../src/actions/turn/general/che_이동.js')).commandSpec.createDefinition(
{} as any
);
const resolution = resolveGeneralAction(
moveDef,
{
general: leader,
nation,
moveGenerals: [leader, follower],
map: LINEAR_MAP,
develCost: 100,
rng: {} as any,
addLog: () => {},
} as any,
{
now: new Date('2026-08-23T00:00:00.000Z'),
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
},
{ destCityId: 102 }
);
expect(resolution.patches?.generals).toContainEqual({
id: follower.id,
patch: expect.objectContaining({ cityId: 102 }),
});
const orderedLogs = orderLegacyActionLoggerFlush(resolution.logs);
expect(orderedLogs.map((log) => log.generalId)).toEqual([follower.id, leader.id]);
expect(orderedLogs[0]).toEqual(
expect.objectContaining({
text: '방랑군 세력이 <G><b>City2</b></>로 이동했습니다.',
generalId: follower.id,
legacyFlushGroup: -1,
})
);
expect(orderedLogs[1]?.legacyFlushGroup).toBeUndefined();
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { ActionResolver } from '../../../src/actions/turn/general/che_하야.js';
describe('che_하야 Ref ordering', () => {
it('resets belong before refreshing max_belong', () => {
const general = {
id: 1,
name: '하야자',
nationId: 1,
cityId: 1,
troopId: 0,
npcState: 0,
experience: 100,
dedication: 100,
officerLevel: 5,
gold: 100,
rice: 100,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
injury: 0,
age: 30,
stats: { leadership: 70, strength: 60, intelligence: 50 },
role: { items: {} },
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { belong: 10 },
};
const nation = {
id: 1,
name: '소속국',
gold: 1_000,
rice: 1_000,
meta: { gennum: 1 },
};
const result = new ActionResolver({ defaultNpcGold: 1_000, defaultNpcRice: 1_000 } as never).resolve(
{
general,
nation,
troopMembers: [],
addLog: () => {},
} as never,
{}
);
const generalPatch = result.effects.find(
(effect) => effect.type === 'general:patch' && effect.targetId === general.id
);
expect(generalPatch).toMatchObject({
type: 'general:patch',
patch: { meta: { belong: 0, max_belong: 0 } },
});
});
});
@@ -6,12 +6,16 @@ import { MINIMAL_MAP } from '../../fixtures/minimalMap.js';
import type { TurnCommandEnv, TurnCommandItemCatalogEntry } from '../../../src/actions/turn/commandEnv.js';
import { commandSpec as rebellionSpec } from '../../../src/actions/turn/general/che_모반시도.js';
import { commandSpec as abdicationSpec } from '../../../src/actions/turn/general/che_선양.js';
import { commandSpec as uprisingSpec } from '../../../src/actions/turn/general/che_거병.js';
import { commandSpec as giftSpec } from '../../../src/actions/turn/general/che_증여.js';
import { commandSpec as disbandSpec } from '../../../src/actions/turn/general/che_해산.js';
import { commandSpec as foundNationSpec } from '../../../src/actions/turn/general/cr_건국.js';
import { commandSpec as tradeItemSpec } from '../../../src/actions/turn/general/che_장비매매.js';
import type { ConstraintContext, RequirementKey, StateView } from '../../../src/constraints/types.js';
import { evaluateActionConstraints } from '../../../src/constraints/evaluate.js';
import { resolveGeneralAction } from '../../../src/actions/engine.js';
import { orderLegacyActionLoggerFlush } from '../../../src/logging/actionLogger.js';
import { LogCategory, LogFormat, LogScope } from '../../../src/logging/types.js';
const SYSTEM_ENV: TurnCommandEnv = {
develCost: 100,
@@ -168,6 +172,31 @@ const makeSnapshot = (params: {
initialEvents: [],
});
const resolveForLogOrdering = (
resolver: any,
general: General,
city: City,
nation: Nation | null,
args: unknown,
context: Record<string, unknown>
) =>
resolveGeneralAction(
resolver,
{
general,
city,
nation,
rng: {} as any,
addLog: () => {},
...context,
} as any,
{
now: new Date('2026-08-23T00:00:00.000Z'),
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
},
args
);
describe('migrated general commands', () => {
it('che_모반시도: 군주를 찬탈한다', async () => {
const lord = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12, experience: 1000 });
@@ -319,6 +348,208 @@ describe('migrated general commands', () => {
expect(world.getNation(1)!.meta.collapsed).toBe(true);
});
it('별도 General logger를 actor applyDB 전후 순서로 flush한다', () => {
const city = makeCity({ id: 1, nationId: 1 });
const nation = makeNation({ id: 1, name: '위', chiefGeneralId: 1, capitalCityId: 1 });
const rebel = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '중신', officerLevel: 2 });
const displacedLord = makeGeneral({
id: 1,
nationId: 1,
cityId: 1,
name: '군주',
officerLevel: 12,
experience: 1000,
});
const rebellionLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
rebellionSpec.createDefinition(SYSTEM_ENV),
rebel,
city,
nation,
{},
{
nationGenerals: [displacedLord, rebel],
}
).logs
);
expect(
rebellionLogs
.filter((log) => log.generalId === displacedLord.id)
.map((log) => [log.category, log.legacyFlushGroup])
).toEqual([
[LogCategory.HISTORY, 1],
[LogCategory.ACTION, 1],
]);
const abdicator = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12 });
const recipient = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '후계자' });
const abdicationLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
abdicationSpec.createDefinition(SYSTEM_ENV),
abdicator,
city,
nation,
{ destGeneralID: recipient.id },
{ destGeneral: recipient }
).logs
);
expect(
abdicationLogs
.filter((log) => log.generalId === recipient.id)
.map((log) => [log.category, log.legacyFlushGroup])
).toEqual([
[LogCategory.HISTORY, 1],
[LogCategory.ACTION, 1],
]);
const giver = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '증여자', gold: 1300 });
const receiver = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '수령자', gold: 200 });
const giftLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
giftSpec.createDefinition(SYSTEM_ENV),
giver,
city,
nation,
{ isGold: true, amount: 500, destGeneralID: receiver.id },
{ destGeneral: receiver }
).logs
);
expect(giftLogs.map((log) => [log.generalId, log.legacyFlushGroup ?? 0])).toEqual([
[giver.id, 0],
[receiver.id, 1],
]);
const disbandActor = makeGeneral({ id: 10, nationId: 1, cityId: 1, name: '방랑군주', officerLevel: 12 });
const member2 = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '부하2' });
const member3 = makeGeneral({ id: 3, nationId: 1, cityId: 1, name: '부하3' });
const wanderingNation = makeNation({
id: 1,
name: '방랑군',
chiefGeneralId: disbandActor.id,
capitalCityId: 1,
level: 0,
typeCode: 'None',
});
const disbandLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
disbandSpec.createDefinition(SYSTEM_ENV),
disbandActor,
city,
wanderingNation,
{},
{
nationGenerals: [disbandActor, member3, member2],
nationCities: [city],
currentYearMonth: 201 * 12 + 2 - 1,
initYearMonth: 201 * 12 + 1 - 1,
}
).logs
);
const nonActorDestructionLogs = disbandLogs.filter(
(log) =>
log.scope === LogScope.GENERAL && log.generalId !== disbandActor.id && log.text.includes('<R>멸망</>')
);
expect(nonActorDestructionLogs.map((log) => [log.generalId, log.category, log.legacyFlushGroup])).toEqual([
[member2.id, LogCategory.HISTORY, -2],
[member2.id, LogCategory.ACTION, -2],
[member3.id, LogCategory.HISTORY, -1],
[member3.id, LogCategory.ACTION, -1],
]);
const actorLogs = disbandLogs.filter(
(log) => log.scope === LogScope.GENERAL && log.generalId === disbandActor.id
);
expect(actorLogs).toHaveLength(4);
expect(actorLogs.every((log) => (log.legacyFlushGroup ?? 0) === 0)).toBe(true);
});
it('선양·모반시도의 Ref logger format과 거병 중립 nation flush 계약을 보존한다', () => {
const city = makeCity({ id: 1, nationId: 1 });
const nation = makeNation({ id: 1, name: '위', chiefGeneralId: 1, capitalCityId: 1 });
const projectRoute = (logs: ReturnType<typeof orderLegacyActionLoggerFlush>) =>
logs.map((log) => [
log.scope,
log.category,
log.generalId ?? null,
log.nationId ?? null,
log.format,
log.legacyFlushGroup ?? 0,
]);
const rebel = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '중신', officerLevel: 2 });
const displacedLord = makeGeneral({
id: 1,
nationId: 1,
cityId: 1,
name: '군주',
officerLevel: 12,
experience: 1_000,
});
const rebellionLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
rebellionSpec.createDefinition(SYSTEM_ENV),
rebel,
city,
nation,
{},
{ nationGenerals: [displacedLord, rebel] }
).logs
);
expect(projectRoute(rebellionLogs)).toEqual([
[LogScope.GENERAL, LogCategory.HISTORY, rebel.id, null, LogFormat.YEAR_MONTH, 0],
[LogScope.GENERAL, LogCategory.ACTION, rebel.id, null, LogFormat.MONTH, 0],
[LogScope.NATION, LogCategory.HISTORY, null, nation.id, LogFormat.YEAR_MONTH, 0],
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0],
[LogScope.GENERAL, LogCategory.HISTORY, displacedLord.id, null, LogFormat.YEAR_MONTH, 1],
[LogScope.GENERAL, LogCategory.ACTION, displacedLord.id, null, LogFormat.MONTH, 1],
]);
const abdicator = makeGeneral({ id: 1, nationId: 1, cityId: 1, name: '군주', officerLevel: 12 });
const recipient = makeGeneral({ id: 2, nationId: 1, cityId: 1, name: '후계자' });
const abdicationLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
abdicationSpec.createDefinition(SYSTEM_ENV),
abdicator,
city,
nation,
{ destGeneralID: recipient.id },
{ destGeneral: recipient }
).logs
);
expect(projectRoute(abdicationLogs)).toEqual([
[LogScope.GENERAL, LogCategory.HISTORY, abdicator.id, null, LogFormat.YEAR_MONTH, 0],
[LogScope.GENERAL, LogCategory.ACTION, abdicator.id, null, LogFormat.MONTH, 0],
[LogScope.NATION, LogCategory.HISTORY, null, nation.id, LogFormat.YEAR_MONTH, 0],
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0],
[LogScope.GENERAL, LogCategory.HISTORY, recipient.id, null, LogFormat.YEAR_MONTH, 1],
[LogScope.GENERAL, LogCategory.ACTION, recipient.id, null, LogFormat.MONTH, 1],
]);
const founder = makeGeneral({ id: 7, nationId: 0, cityId: 1, name: '운영자' });
const uprisingLogs = orderLegacyActionLoggerFlush(
resolveForLogOrdering(
uprisingSpec.createDefinition(SYSTEM_ENV),
founder,
city,
null,
{},
{
createNationId: () => 2,
listNations: () => [],
scenarioId: 1,
baseRice: 1_000,
}
).logs
);
expect(projectRoute(uprisingLogs)).toEqual([
[LogScope.GENERAL, LogCategory.HISTORY, founder.id, null, LogFormat.YEAR_MONTH, 0],
[LogScope.GENERAL, LogCategory.ACTION, founder.id, null, LogFormat.MONTH, 0],
[LogScope.SYSTEM, LogCategory.HISTORY, null, null, LogFormat.YEAR_MONTH, 0],
[LogScope.SYSTEM, LogCategory.SUMMARY, null, null, LogFormat.MONTH, 0],
]);
expect(uprisingLogs.some((log) => log.scope === LogScope.NATION)).toBe(false);
});
it('cr_건국: 국가 정보를 건국 상태로 갱신한다', async () => {
const lord = makeGeneral({
id: 1,