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
@@ -375,7 +375,7 @@ describe('unique auction inheritance log compatibility', () => {
);
});
it('builds all four Ref award logs with the original formats and labels', () => {
it('builds all four Ref award logs in the original flush order with the original formats and labels', () => {
const bidder = {
id: 7,
name: '관우',
@@ -390,13 +390,6 @@ describe('unique auction inheritance log compatibility', () => {
itemRawName: '칠성검',
})
).toEqual([
expect.objectContaining({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
generalId: 7,
text: '<C>칠성검(+12)</>을 습득했습니다!',
}),
expect.objectContaining({
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
@@ -405,10 +398,11 @@ describe('unique auction inheritance log compatibility', () => {
text: '<C>칠성검(+12)</>을 습득',
}),
expect.objectContaining({
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
format: LogFormat.MONTH,
text: '<Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
generalId: 7,
text: '<C>칠성검(+12)</>을 습득했습니다!',
}),
expect.objectContaining({
scope: LogScope.SYSTEM,
@@ -416,6 +410,12 @@ describe('unique auction inheritance log compatibility', () => {
format: LogFormat.YEAR_MONTH,
text: '<C><b>【보물수배】</b></><D><b>촉</b></>의 <Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
}),
expect.objectContaining({
scope: LogScope.SYSTEM,
category: LogCategory.SUMMARY,
format: LogFormat.MONTH,
text: '<Y>관우</>가 <C>칠성검(+12)</>을 습득했습니다!',
}),
]);
});
@@ -194,7 +194,7 @@ describe('scenario general pool within one reserved turn', () => {
state: buildState(),
schedule,
map,
reservedTurnStoreOptions: { maxGeneralTurns: 10, maxNationTurns: 12 },
reservedTurnStoreOptions: { maxGeneralTurns: 30, maxNationTurns: 12 },
commandRngFactory: ({ actionKey }) =>
actionKey === 'che_의병모집'
? new RandUtil(new SequenceRNG([0, 0.26, 0.51, 0.76]))
@@ -210,5 +210,29 @@ describe('scenario general pool within one reserved turn', () => {
expect(created.map((general) => general.npcState).sort()).toEqual([3, 4, 4, 4]);
expect(claims.every(Boolean)).toBe(true);
expect(new Set(claims.map((claim) => claim?.poolEntryId))).toEqual(new Set([1, 2, 3, 4]));
const createdIds = created.map((general) => general.id);
expect(harness.reservedTurnStore.peekDirtyState().generalInitializationIds).toEqual(createdIds);
for (const generalId of createdIds) {
expect(harness.reservedTurnStore.getGeneralTurns(generalId)).toEqual(
Array.from({ length: 30 }, () => ({ action: '휴식', args: {} }))
);
}
await harness.reservedTurnStore.flushChanges();
const persistedRows = harness.mockPrisma.generalTurn.createMany.mock.calls.flatMap(([input]) => input.data);
const persistedCreatedRows = persistedRows.filter((row) => createdIds.includes(row.generalId));
expect(persistedCreatedRows).toHaveLength(createdIds.length * 30);
for (const generalId of createdIds) {
expect(persistedCreatedRows.filter((row) => row.generalId === generalId)).toEqual(
Array.from({ length: 30 }, (_, turnIdx) => ({
generalId,
turnIdx,
actionCode: '휴식',
arg: {},
}))
);
}
});
});
@@ -193,6 +193,34 @@ describe('legacy general-turn execution contract', () => {
expect(resolved.meta).toMatchObject({ explevel: 25, dedlevel: 8 });
});
it('preserves procurement-computed levels while emitting their Ref progression logs', () => {
const previous = makeGeneral({
experience: 995,
dedication: 899,
meta: { killturn: 24, explevel: 9, dedlevel: 3 },
});
const afterProcurement = makeGeneral({
experience: 1_005,
dedication: 901,
meta: { killturn: 24, explevel: 10, dedlevel: 4 },
});
const logs: Array<{ text: string }> = [];
const resolved = applyLegacyGeneralProgression(
afterProcurement,
previous,
'che_물자조달',
{ maxStatLevel: 255, maxDedicationLevel: 30 } as never,
logs as never
);
expect(resolved.meta).toMatchObject({ explevel: 10, dedlevel: 4 });
expect(logs.map((entry) => entry.text)).toEqual([
'<C>Lv 10</>으로 <C>레벨업</>!',
'<Y>27품관</>으로 <C>승급</>하여 봉록이 <C>1,200</>으로 <C>상승</>했습니다!',
]);
});
it('quantizes integer general columns at each in-memory DB mutation boundary', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(makeGeneral()),
@@ -218,6 +246,23 @@ describe('legacy general-turn execution contract', () => {
});
});
it('fails closed instead of silently resting on an unknown queued command', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(makeGeneral()),
state: makeState(),
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'unknown-command', args: {} };
const beforeGeneral = structuredClone(harness.world.getGeneralById(1));
const beforeTurns = structuredClone(harness.reservedTurnStore.getGeneralTurns(1));
await expect(harness.runOneTick()).rejects.toThrow('Unknown reserved general turn command: unknown-command');
expect(harness.world.getGeneralById(1)).toEqual(beforeGeneral);
expect(harness.reservedTurnStore.getGeneralTurns(1)).toEqual(beforeTurns);
expect(harness.world.peekDirtyState()).toMatchObject({ logs: [], messages: [] });
});
it('keeps fractional nation rewards in the same general object until the following command is persisted', async () => {
const twoCityMap = {
...map,
@@ -200,6 +200,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
return {
world,
worldRef,
mockPrisma,
reservedTurnStore,
handler,
processor,
@@ -1,7 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { MapDefinition, ScenarioEffectKey, TurnSchedule } from '@sammo-ts/logic';
import {
LogCategory,
LogFormat,
LogScope,
type MapDefinition,
type ScenarioEffectKey,
type TurnSchedule,
} from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
@@ -562,6 +569,25 @@ describe('my information world commands', () => {
experience: recruiter.experience + 100,
dedication: recruiter.dedication + 100,
});
const actionLogs = fixture.world
.consumeDirtyState()
.logs.filter((log) => log.scope === LogScope.GENERAL && log.category === LogCategory.ACTION);
expect(actionLogs.map((log) => log.text)).toEqual([
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('망명하여 수도로'),
expect.stringContaining('레벨업'),
expect.stringContaining('승급'),
expect.stringContaining('등용에 성공했습니다.'),
]);
expect(actionLogs.map((log) => log.format)).toEqual([
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
LogFormat.PLAIN,
LogFormat.PLAIN,
LogFormat.MONTH,
]);
});
it('preserves the Ref uprising precheck order and messages after the game starts', async () => {
@@ -90,6 +90,8 @@ describe('도시 점령 시 국가 멸망 처리', () => {
strongFrontCity.supplyState = 1;
const conflictCity = cities.find((city) => city.id === 3)!;
conflictCity.conflict = { 2: 100, 1: 50 };
const emptiedConflictCity = cities.find((city) => city.id === 4)!;
emptiedConflictCity.conflict = { 2: 25 };
const unitSet: UnitSetDefinition = {
id: 'test_unit_set',
@@ -291,6 +293,7 @@ describe('도시 점령 시 국가 멸망 처리', () => {
expect(world.listEvents('destroy_nation')).toEqual([]);
expect(world.getState().meta.block_change_scout).toBeUndefined();
expect(world.getCityById(conflictCity.id)?.conflict).toEqual({ 1: 50 });
expect(world.getCityById(emptiedConflictCity.id)?.conflict).toEqual([]);
const updatedWeakGeneral = world.getGeneralById(weakGeneral.id);
expect(updatedWeakGeneral?.nationId).toBe(0);
@@ -251,6 +251,7 @@ describe('NPC 일반 내정 턴', () => {
});
await reservedTurnStore.loadAll();
const logicalGameNow = addMinutes(mockDate, 3);
const wrapper = { world: null as InMemoryTurnWorld | null };
const handler = await createReservedTurnHandler({
reservedTurns: reservedTurnStore,
@@ -259,6 +260,7 @@ describe('NPC 일반 내정 턴', () => {
map: MINIMAL_MAP as any,
unitSet: snapshot.unitSet,
getWorld: () => wrapper.world,
now: () => logicalGameNow,
});
const world = new InMemoryTurnWorld(state, snapshot, {
@@ -296,6 +298,7 @@ describe('NPC 일반 내정 턴', () => {
msgType: 'public',
text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다',
src: expect.objectContaining({ generalId: 1, generalName: 'NPC_무장', nationId: 1 }),
time: logicalGameNow,
})
);
});
+34 -6
View File
@@ -3,6 +3,8 @@ import { LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common';
import {
applyPersistedRankRowsToMeta,
buildInitialRankRows,
buildLegacyComparableInitialRankRows,
buildLegacyComparableRankRows,
buildPersistedRankRows,
rankMetaKey,
@@ -34,13 +36,39 @@ describe('rank data projection', () => {
{ generalId: 7, nationId: 2, type: 'dex1', value: 12 },
])
);
expect(buildLegacyComparableRankRows({
id: 7,
expect(
buildLegacyComparableRankRows({
id: 7,
nationId: 2,
experience: 10.5,
dedication: 20.49,
meta: {},
})
).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
const initialRows = buildInitialRankRows({
id: 8,
nationId: 2,
experience: 10.5,
dedication: 20.49,
meta: {},
})).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
experience: 100,
dedication: 200,
meta: { rank_warnum: 9, inherit_spent_dyn: 7 },
});
expect(initialRows).toEqual(
expect.arrayContaining([
{ generalId: 8, nationId: 0, type: 'experience', value: 0 },
{ generalId: 8, nationId: 0, type: 'warnum', value: 0 },
{ generalId: 8, nationId: 0, type: 'inherit_spent_dyn', value: 7 },
])
);
expect(
buildLegacyComparableInitialRankRows({
id: 8,
nationId: 2,
experience: 100,
dedication: 200,
meta: {},
})
).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
});
it('loads persisted rows into the same raw and prefixed meta keys used by commands', () => {
@@ -0,0 +1,281 @@
import { describe, expect, it } from 'vitest';
import { loadScenarioDefinitionById } from '../src/scenario/scenarioLoader.js';
import { loadScenarioTurnCommandProfile } from '../src/turn/turnCommandProfile.js';
type CommandGroupManifest = ReadonlyArray<{
category: string;
commands: readonly string[];
}>;
const defaultGeneralProfileManifest = [
'che_거병',
'che_임관',
'che_장수대상임관',
'che_랜덤임관',
'che_귀환',
'che_건국',
'che_훈련',
'che_단련',
'che_숙련전환',
'che_사기진작',
'che_요양',
'che_견문',
'che_은퇴',
'che_내정특기초기화',
'che_전투특기초기화',
'che_장비매매',
'che_출병',
'che_주민선정',
'che_정착장려',
'che_농지개간',
'che_상업투자',
'che_기술연구',
'che_치안강화',
'che_수비강화',
'che_성벽보수',
'che_선동',
'che_탈취',
'che_파괴',
'che_화계',
'che_집합',
'che_인재탐색',
'che_등용',
'che_징병',
'che_모병',
'che_소집해제',
'che_첩보',
'che_군량매매',
'che_물자조달',
'che_증여',
'che_헌납',
'che_이동',
'che_강행',
'che_하야',
'che_선양',
'che_해산',
'휴식',
] as const;
const generalPersonalCommands = [
'휴식',
'che_요양',
'che_단련',
'che_숙련전환',
'che_견문',
'che_은퇴',
'che_장비매매',
'che_군량매매',
'che_내정특기초기화',
'che_전투특기초기화',
] as const;
const generalDomesticCommands = [
'che_농지개간',
'che_상업투자',
'che_기술연구',
'che_수비강화',
'che_성벽보수',
'che_치안강화',
'che_정착장려',
'che_주민선정',
'che_물자조달',
] as const;
const generalMilitaryCommands = [
'che_징병',
'che_모병',
'che_훈련',
'che_사기진작',
'che_출병',
'che_집합',
'che_소집해제',
'che_첩보',
] as const;
const generalPersonnelCommands = ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_랜덤임관'] as const;
const generalSchemeCommands = ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'] as const;
const nationDiplomacyCommands = [
'che_물자원조',
'che_불가침제의',
'che_선전포고',
'che_종전제의',
'che_불가침파기제의',
] as const;
const scenarioProfileManifest: Record<
number,
{ generalGroups: CommandGroupManifest | null; nationGroups: CommandGroupManifest }
> = {
904: {
generalGroups: [
{ category: '개인', commands: generalPersonalCommands },
{ category: '내정', commands: generalDomesticCommands },
{ category: '군사', commands: generalMilitaryCommands },
{ category: '인사', commands: generalPersonnelCommands },
{ category: '계략', commands: generalSchemeCommands },
{ category: '국가', commands: ['che_증여', 'che_헌납', 'che_하야'] },
],
nationGroups: [
{ category: '휴식', commands: ['휴식'] },
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] },
{ category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] },
{
category: '전략',
commands: [
'che_필사즉생',
'che_백성동원',
'che_수몰',
'che_허보',
'che_의병모집',
'che_이호경식',
'che_급습',
],
},
{ category: '기타', commands: ['che_피장파장', 'che_국기변경', 'che_국호변경'] },
],
},
905: {
generalGroups: [
{ category: '개인', commands: generalPersonalCommands },
{ category: '내정', commands: generalDomesticCommands },
{ category: '군사', commands: generalMilitaryCommands },
{ category: '인사', commands: generalPersonnelCommands },
{ category: '계략', commands: generalSchemeCommands },
{
category: '국가',
commands: ['che_증여', 'che_헌납', 'che_하야', 'che_거병', 'che_무작위건국', 'che_선양', 'che_해산'],
},
],
nationGroups: [
{ category: '휴식', commands: ['휴식'] },
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] },
{ category: '외교', commands: nationDiplomacyCommands },
{ category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] },
{
category: '전략',
commands: [
'che_필사즉생',
'che_백성동원',
'che_수몰',
'che_허보',
'che_의병모집',
'che_이호경식',
'che_급습',
'che_피장파장',
],
},
{ category: '기타', commands: ['che_국기변경', 'che_국호변경', 'che_무작위수도이전'] },
],
},
910: {
generalGroups: [
{ category: '개인', commands: generalPersonalCommands },
{ category: '내정', commands: generalDomesticCommands },
{
category: '군사',
commands: [
'che_징병',
'che_모병',
'che_훈련',
'che_사기진작',
'cr_맹훈련',
'che_출병',
'che_집합',
'che_소집해제',
'che_첩보',
],
},
{ category: '인사', commands: generalPersonnelCommands },
{ category: '계략', commands: generalSchemeCommands },
{
category: '국가',
commands: ['che_증여', 'che_헌납', 'che_하야', 'che_거병', 'cr_건국', 'che_선양', 'che_해산'],
},
],
nationGroups: [
{ category: '휴식', commands: ['휴식'] },
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수'] },
{ category: '외교', commands: nationDiplomacyCommands },
{ category: '특수', commands: ['che_초토화', 'che_천도', 'cr_인구이동'] },
{
category: '전략',
commands: [
'che_필사즉생',
'che_백성동원',
'che_수몰',
'che_허보',
'che_의병모집',
'che_이호경식',
'che_급습',
],
},
{ category: '기타', commands: ['che_피장파장', 'che_국기변경', 'che_국호변경'] },
],
},
912: {
generalGroups: null,
nationGroups: [
{ category: '휴식', commands: ['휴식'] },
{ category: '인사', commands: ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] },
{ category: '외교', commands: nationDiplomacyCommands },
{ category: '특수', commands: ['che_초토화', 'che_천도', 'che_증축', 'che_감축'] },
{
category: '전략',
commands: [
'che_필사즉생',
'che_백성동원',
'che_수몰',
'che_허보',
'che_의병모집',
'che_이호경식',
'che_급습',
'che_피장파장',
],
},
{ category: '기타', commands: ['che_국기변경', 'che_국호변경'] },
{
category: '연구',
commands: [
'event_대검병연구',
'event_극병연구',
'event_화시병연구',
'event_원융노병연구',
'event_산저병연구',
'event_음귀병연구',
'event_무희연구',
'event_상병연구',
'event_화륜차연구',
],
},
],
},
};
const loadScenarioProfile = async (scenarioId: number) => {
const scenario = await loadScenarioDefinitionById(scenarioId);
return loadScenarioTurnCommandProfile({ scenarioConst: scenario.config.const });
};
describe('scenario command profile resources', () => {
it('fails closed when the configured base profile file is missing', async () => {
await expect(
loadScenarioTurnCommandProfile({ filePath: '/tmp/core2026-command-profile-does-not-exist.json' })
).rejects.toMatchObject({ code: 'ENOENT' });
});
it.each(Object.entries(scenarioProfileManifest))(
'preserves scenario %s general/chief group order and the exact flattened product profile',
async (scenarioId, manifest) => {
const result = await loadScenarioProfile(Number(scenarioId));
const expectedGeneral = manifest.generalGroups
? manifest.generalGroups.flatMap((group) => group.commands)
: defaultGeneralProfileManifest;
const expectedNation = manifest.nationGroups.flatMap((group) => group.commands);
expect(result.generalGroups).toEqual(manifest.generalGroups);
expect(result.nationGroups).toEqual(manifest.nationGroups);
expect(result.profile.general).toEqual(expectedGeneral);
expect(result.profile.nation).toEqual(expectedNation);
expect(new Set(result.profile.general).size).toBe(result.profile.general.length);
expect(new Set(result.profile.nation).size).toBe(result.profile.nation.length);
}
);
});
@@ -39,7 +39,11 @@ const buildGeneral = (id: number): TurnGeneral => ({
describe('unique lottery on general commands', () => {
it('awards a unique item for eligible commands', async () => {
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const generals = [buildGeneral(1)];
const lotteryGeneral = buildGeneral(1);
lotteryGeneral.experience = 995;
lotteryGeneral.dedication = 899;
lotteryGeneral.meta = { killturn: 24, explevel: 9, dedlevel: 3 };
const generals = [lotteryGeneral];
const snapshot: TurnWorldSnapshot = {
generals: generals as any,
cities: [
@@ -172,6 +176,14 @@ describe('unique lottery on general commands', () => {
expect(result.general?.role.items.weapon).toBe('che_무기_12_칠성검');
const logTexts = (result.logs ?? []).map((entry) => entry.text);
expect(logTexts.some((text) => text.includes('【아이템】'))).toBe(true);
const actionIndex = logTexts.findIndex((text) => text.includes('훈련'));
const levelIndex = logTexts.findIndex((text) => text.includes('레벨업'));
const dedicationIndex = logTexts.findIndex((text) => text.includes('승급'));
const uniqueIndex = logTexts.findIndex((text) => text.includes('습득했습니다'));
expect([actionIndex, levelIndex, dedicationIndex, uniqueIndex].every((index) => index >= 0)).toBe(true);
expect(actionIndex).toBeLessThan(levelIndex);
expect(levelIndex).toBeLessThan(dedicationIndex);
expect(dedicationIndex).toBeLessThan(uniqueIndex);
});
it('does not award a unique item reserved by an active auction', async () => {