fix: 커맨드 차등 생명주기와 로그 그래프를 보강
장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다. 실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
import { GameClock, MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
closeTurnSnapshotSelectorOverCreatedEntities,
|
||||
projectCoreDatabaseSnapshot,
|
||||
type CanonicalTurnSnapshot,
|
||||
} from '../src/turn-differential/canonical.js';
|
||||
import { compareTurnSnapshotDeltas, compareTurnSnapshots } from '../src/turn-differential/compare.js';
|
||||
import { projectCoreMessageDrafts, projectCoreMessageReadState } from '../src/turn-differential/coreCommandTrace.js';
|
||||
import { projectEffectiveCoreMessageValidUntil } from '../src/turn-differential/databaseSnapshot.js';
|
||||
import { projectFullLifecycleSnapshotGraph } from '../src/turn-differential/fullLifecycleFixture.js';
|
||||
|
||||
interface CommandStateFixture {
|
||||
generalMeta: Record<string, unknown>;
|
||||
generalFields?: Record<string, unknown>;
|
||||
nationMeta: Record<string, unknown>;
|
||||
nationFields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const databaseSnapshot = (
|
||||
latestReadPrivateMessage = 0,
|
||||
commandStateFixture?: CommandStateFixture
|
||||
): CanonicalTurnSnapshot =>
|
||||
projectCoreDatabaseSnapshot({
|
||||
world: {
|
||||
currentYear: 183,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 600,
|
||||
meta: { lastTurnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
|
||||
gameNow: new Date('0183-01-01T00:10:00.000Z'),
|
||||
lastTurnTick: 0,
|
||||
},
|
||||
generals: [
|
||||
{
|
||||
id: 1,
|
||||
name: '조조',
|
||||
nationId: 1,
|
||||
cityId: 1,
|
||||
troopId: 1,
|
||||
userId: 'owner-a',
|
||||
meta: commandStateFixture?.generalMeta ?? {},
|
||||
penalty: {},
|
||||
...commandStateFixture?.generalFields,
|
||||
},
|
||||
],
|
||||
rankData: [],
|
||||
cities: [],
|
||||
nations: commandStateFixture
|
||||
? [
|
||||
{
|
||||
id: 1,
|
||||
name: '위',
|
||||
color: '#111111',
|
||||
capitalCityId: 1,
|
||||
gold: 1_000,
|
||||
rice: 1_000,
|
||||
tech: 100,
|
||||
level: 1,
|
||||
typeCode: 'che_명가',
|
||||
meta: commandStateFixture.nationMeta,
|
||||
...commandStateFixture.nationFields,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
troops: [{ troopLeaderId: 1, nationId: 1, name: '조조군' }],
|
||||
diplomacy: [],
|
||||
generalTurns: [],
|
||||
nationTurns: [],
|
||||
logs: [],
|
||||
messages: [
|
||||
{
|
||||
id: 71,
|
||||
mailbox: 1,
|
||||
type: 'private',
|
||||
src: 2,
|
||||
dest: 1,
|
||||
time: new Date('0183-01-01T00:10:00.000Z'),
|
||||
validUntil: new Date('0183-04-01T00:10:00.000Z'),
|
||||
message: { text: '등용 서신' },
|
||||
},
|
||||
],
|
||||
messageReadStates: [
|
||||
{
|
||||
generalId: 1,
|
||||
latestPrivateMessage: latestReadPrivateMessage,
|
||||
latestDiplomacyMessage: 0,
|
||||
},
|
||||
],
|
||||
messageInboxRows: [{ id: 71, mailbox: 1, type: 'private', src: 2 }],
|
||||
messageWatermark: 71,
|
||||
});
|
||||
|
||||
describe('turn snapshot canonical blind-spot coverage', () => {
|
||||
it('projects troop rows and detects a troop mutant', () => {
|
||||
const reference = databaseSnapshot();
|
||||
const core = {
|
||||
...databaseSnapshot(),
|
||||
troops: [{ id: 1, nationId: 1, name: '변조된 부대' }],
|
||||
};
|
||||
|
||||
expect(reference.troops).toEqual([{ id: 1, nationId: 1, name: '조조군' }]);
|
||||
expect(reference.world.gameNow).toBe('0183-01-01T00:10:00.000Z');
|
||||
expect(compareTurnSnapshots(reference, core)).toContainEqual({
|
||||
path: 'troops[1].name',
|
||||
reference: '조조군',
|
||||
core: '변조된 부대',
|
||||
});
|
||||
});
|
||||
|
||||
it('projects persisted message rows and detects a message mutant', () => {
|
||||
const reference = databaseSnapshot();
|
||||
const core = {
|
||||
...databaseSnapshot(),
|
||||
messages: [{ ...databaseSnapshot().messages[0], sourceId: 9 }],
|
||||
};
|
||||
|
||||
expect(reference.messages).toEqual([
|
||||
{
|
||||
id: 71,
|
||||
mailbox: 1,
|
||||
type: 'private',
|
||||
sourceId: 2,
|
||||
destinationId: 1,
|
||||
createdAt: '0183-01-01T00:10:00.000Z',
|
||||
validUntil: '0183-04-01T00:10:00.000Z',
|
||||
payload: { text: '등용 서신' },
|
||||
},
|
||||
]);
|
||||
expect(reference.watermarks.messageId).toBe(71);
|
||||
expect(compareTurnSnapshots(reference, core)).toContainEqual({
|
||||
path: 'messages[0].sourceId',
|
||||
reference: 2,
|
||||
core: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('expands a Core message draft into the persisted receiver and sender rows', async () => {
|
||||
const messages = await projectCoreMessageDrafts(
|
||||
[
|
||||
{
|
||||
msgType: 'private',
|
||||
src: {
|
||||
generalId: 1,
|
||||
generalName: '조조',
|
||||
nationId: 1,
|
||||
nationName: '위',
|
||||
color: '#111111',
|
||||
icon: '1.webp',
|
||||
},
|
||||
dest: {
|
||||
generalId: 2,
|
||||
generalName: '유비',
|
||||
nationId: 2,
|
||||
nationName: '촉',
|
||||
color: '#222222',
|
||||
icon: '2.webp',
|
||||
},
|
||||
text: '등용 서신',
|
||||
time: new Date('0183-01-01T00:10:00.000Z'),
|
||||
validUntil: new Date('0183-04-01T00:10:00.000Z'),
|
||||
},
|
||||
],
|
||||
70
|
||||
);
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: 71,
|
||||
mailbox: 2,
|
||||
type: 'private',
|
||||
sourceId: 1,
|
||||
destinationId: 2,
|
||||
validUntil: '0183-04-01T00:10:00.000Z',
|
||||
payload: { text: '등용 서신' },
|
||||
});
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: 72,
|
||||
mailbox: 1,
|
||||
validUntil: '0183-04-01T00:10:00.000Z',
|
||||
payload: { option: { receiverMessageID: 71 } },
|
||||
});
|
||||
expect(projectCoreMessageReadState(2, 2, messages)).toEqual({
|
||||
unreadPrivateCount: 1,
|
||||
unreadDiplomacyCount: 0,
|
||||
hasUnreadMessage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a persisted validity tick ahead of the Date fallback and keeps infinity explicit', () => {
|
||||
const clock = new GameClock({
|
||||
baseTime: new Date('0183-01-01T00:00:00.000Z'),
|
||||
tick: 0,
|
||||
mode: 'manual',
|
||||
wallAnchor: new Date('0183-01-01T00:00:00.000Z'),
|
||||
turnSeconds: 600,
|
||||
});
|
||||
const oneMinuteTick = clock.dateToTick(new Date('0183-01-01T00:01:00.000Z'));
|
||||
|
||||
expect(
|
||||
projectEffectiveCoreMessageValidUntil(
|
||||
{
|
||||
validUntil: new Date('0183-01-02T00:00:00.000Z'),
|
||||
validUntilTick: BigInt(oneMinuteTick),
|
||||
},
|
||||
clock
|
||||
)
|
||||
).toBe('0183-01-01T00:01:00.000Z');
|
||||
expect(
|
||||
projectEffectiveCoreMessageValidUntil(
|
||||
{
|
||||
validUntil: new Date('0183-01-02T00:00:00.000Z'),
|
||||
validUntilTick: BigInt(MAX_SAFE_GAME_TICK),
|
||||
},
|
||||
clock
|
||||
)
|
||||
).toBe('infinite');
|
||||
expect(
|
||||
projectEffectiveCoreMessageValidUntil(
|
||||
{ validUntil: new Date('0183-01-02T00:00:00.000Z'), validUntilTick: null },
|
||||
clock
|
||||
)
|
||||
).toBe('0183-01-02T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('compares stable owner identity instead of only owner presence', () => {
|
||||
const reference = databaseSnapshot();
|
||||
const core = {
|
||||
...databaseSnapshot(),
|
||||
generals: [{ ...databaseSnapshot().generals[0], ownerIdentity: 'owner-b' }],
|
||||
};
|
||||
|
||||
expect(reference.generals[0]).toMatchObject({ hasOwner: true, ownerIdentity: 'owner-a' });
|
||||
expect(compareTurnSnapshots(reference, core)).toContainEqual({
|
||||
path: 'generals[1].ownerIdentity',
|
||||
reference: 'owner-a',
|
||||
core: 'owner-b',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a mutant that marks a generated incoming message as already read', () => {
|
||||
const reference = databaseSnapshot();
|
||||
const core = databaseSnapshot(71);
|
||||
|
||||
expect(reference.generals[0]?.messageReadState).toEqual({
|
||||
unreadPrivateCount: 1,
|
||||
unreadDiplomacyCount: 0,
|
||||
hasUnreadMessage: true,
|
||||
});
|
||||
expect(compareTurnSnapshots(reference, core)).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
path: 'generals[1].messageReadState.hasUnreadMessage',
|
||||
reference: true,
|
||||
core: false,
|
||||
},
|
||||
{
|
||||
path: 'generals[1].messageReadState.unreadPrivateCount',
|
||||
reference: 1,
|
||||
core: 0,
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the after selector over created entities and exposes an omission mutant', () => {
|
||||
const selector = closeTurnSnapshotSelectorOverCreatedEntities(
|
||||
{ generalIds: [1], cityIds: [1], nationIds: [1], troopIds: [] },
|
||||
{ generalIds: [1], cityIds: [1], nationIds: [1], troopIds: [] },
|
||||
{ generalIds: [1, 2], cityIds: [1], nationIds: [1, 2], troopIds: [2] }
|
||||
);
|
||||
expect(selector).toMatchObject({ generalIds: [1, 2], nationIds: [1, 2], troopIds: [2] });
|
||||
|
||||
const beforeReference = databaseSnapshot();
|
||||
const afterReference = {
|
||||
...databaseSnapshot(),
|
||||
generals: [...databaseSnapshot().generals, { id: 2, ownerIdentity: null }],
|
||||
};
|
||||
const beforeCore = databaseSnapshot();
|
||||
const afterCore = databaseSnapshot();
|
||||
expect(compareTurnSnapshotDeltas(beforeReference, afterReference, beforeCore, afterCore)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps persisted actor rank rows in the full-lifecycle graph and catches an upsert omission', () => {
|
||||
const snapshot = {
|
||||
...databaseSnapshot(),
|
||||
rankData: [
|
||||
{ generalId: 1, nationId: 1, type: 'dedication', value: 1_015 },
|
||||
{ generalId: 1, nationId: 1, type: 'experience', value: 1_015 },
|
||||
],
|
||||
};
|
||||
const omitted = {
|
||||
...snapshot,
|
||||
rankData: snapshot.rankData.filter((row) => row.type !== 'experience'),
|
||||
};
|
||||
|
||||
expect(projectFullLifecycleSnapshotGraph(snapshot).actorRankData).toEqual([
|
||||
{ nationId: 1, type: 'dedication', value: 1_015 },
|
||||
{ nationId: 1, type: 'experience', value: 1_015 },
|
||||
]);
|
||||
expect(projectFullLifecycleSnapshotGraph(omitted)).not.toEqual(projectFullLifecycleSnapshotGraph(snapshot));
|
||||
});
|
||||
|
||||
it('projects command-semantic meta outside the ignored raw meta graph and catches omission mutants', () => {
|
||||
const generalMeta = {
|
||||
armType: 3,
|
||||
explevel: 4,
|
||||
dedlevel: 2,
|
||||
npc_org: 4,
|
||||
text: '의병 소개',
|
||||
};
|
||||
const generalFields = {
|
||||
affinity: 37,
|
||||
bornYear: 170,
|
||||
deadYear: 210,
|
||||
npcState: 4,
|
||||
turnTick: 1_027_407n,
|
||||
};
|
||||
const nationMeta = {
|
||||
can_국기변경: 1,
|
||||
can_무작위수도이전: 1,
|
||||
spy: { 7: 3 },
|
||||
collapsed: true,
|
||||
rate: 20,
|
||||
bill: 100,
|
||||
secretlimit: 3,
|
||||
};
|
||||
const expectedFixture = { generalMeta, generalFields, nationMeta };
|
||||
const before = databaseSnapshot(0, { generalMeta: {}, nationMeta: {} });
|
||||
const expectedAfter = databaseSnapshot(0, expectedFixture);
|
||||
|
||||
expect(expectedAfter.generals[0]).toMatchObject({
|
||||
expLevel: 4,
|
||||
dedLevel: 2,
|
||||
affinity: 37,
|
||||
bornYear: 170,
|
||||
deadYear: 210,
|
||||
npcState: 4,
|
||||
npcOriginalState: 4,
|
||||
npcMessage: '의병 소개',
|
||||
turnTick: 1_027_407,
|
||||
turnSecond: 17,
|
||||
turnFraction: 123_450,
|
||||
});
|
||||
expect(expectedAfter.generals[0]?.commandState).toEqual({ recruitmentArmType: 3 });
|
||||
expect(expectedAfter.nations[0]?.commandState).toEqual({
|
||||
flagChangesRemaining: 1,
|
||||
randomCapitalMovesRemaining: 1,
|
||||
spy: [{ cityId: 7, remainingTurns: 3 }],
|
||||
collapsed: true,
|
||||
rate: 20,
|
||||
bill: 100,
|
||||
secretLimit: 3,
|
||||
});
|
||||
|
||||
const ignoredRawMeta = [/^generals\[[^\]]+\]\.meta(?:\.|$)/, /^nations\[[^\]]+\]\.meta(?:\.|$)/];
|
||||
const mutants: Array<{ path: string; snapshot: CanonicalTurnSnapshot }> = [
|
||||
{
|
||||
path: 'generals[1].commandState.recruitmentArmType',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalMeta: { ...generalMeta, armType: undefined },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'generals[1].expLevel',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalMeta: { ...generalMeta, explevel: 0 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'generals[1].dedLevel',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalMeta: { ...generalMeta, dedlevel: 0 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'generals[1].affinity',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalFields: { ...generalFields, affinity: null },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'generals[1].bornYear',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalFields: { ...generalFields, bornYear: 171 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'generals[1].deadYear',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalFields: { ...generalFields, deadYear: 211 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'generals[1].npcState',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalFields: { ...generalFields, npcState: 3 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'generals[1].npcOriginalState',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalMeta: { ...generalMeta, npc_org: undefined },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'generals[1].npcMessage',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalMeta: { ...generalMeta, text: null },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'generals[1].turnSecond',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalFields: { ...generalFields, turnTick: 1_087_407n },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'generals[1].turnFraction',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalFields: { ...generalFields, turnTick: 1_027_408n },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'generals[1].turnTick',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
generalFields: { ...generalFields, turnTick: 1_027_408n },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'nations[1].commandState.flagChangesRemaining',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
generalMeta,
|
||||
generalFields,
|
||||
nationMeta: { ...nationMeta, can_국기변경: 0 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'nations[1].commandState.randomCapitalMovesRemaining',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
generalMeta,
|
||||
generalFields,
|
||||
nationMeta: { ...nationMeta, can_무작위수도이전: 0 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'nations[1].commandState.spy',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
generalMeta,
|
||||
generalFields,
|
||||
nationMeta: { ...nationMeta, spy: {} },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'nations[1].commandState.collapsed',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
generalMeta,
|
||||
generalFields,
|
||||
nationMeta: { ...nationMeta, collapsed: false },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'nations[1].commandState.rate',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
nationMeta: { ...nationMeta, rate: 0 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'nations[1].commandState.bill',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
nationMeta: { ...nationMeta, bill: 0 },
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: 'nations[1].commandState.secretLimit',
|
||||
snapshot: databaseSnapshot(0, {
|
||||
...expectedFixture,
|
||||
nationMeta: { ...nationMeta, secretlimit: 2 },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
for (const mutant of mutants) {
|
||||
const differences = compareTurnSnapshotDeltas(before, expectedAfter, before, mutant.snapshot, {
|
||||
ignoredPathPatterns: ignoredRawMeta,
|
||||
});
|
||||
expect(
|
||||
differences.some(
|
||||
(difference) => difference.path === mutant.path || difference.path.startsWith(`${mutant.path}[`)
|
||||
),
|
||||
mutant.path
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user