fix: 턴 실패 사유를 실행 장수의 개인 기록에 남김

개인턴과 수뇌턴 공통 로그 생성 경로에서 generalId를 필수로 전달해 PostgreSQL 영속화 단계의 누락을 막는다. 비교 도구의 소유자 추론을 제거하고 단위, PostgreSQL, Chromium 회귀 검증을 추가한다.
This commit is contained in:
2026-08-20 16:23:40 +00:00
parent d1731fdf59
commit e9d8961386
5 changed files with 308 additions and 37 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import type { TurnSchedule } from '@sammo-ts/logic';
import { finalizeLogEntry, type TurnSchedule } from '@sammo-ts/logic';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
@@ -41,8 +41,9 @@ const mockDate = new Date('0189-01-01T00:00:00Z');
// We need a mock Prisma client that satisfies the shape required by InMemoryReservedTurnStore
// It expects { generalTurn: { findMany, deleteMany, createMany }, nationTurn: { ... } }
const createMockPrisma = (initialGeneralRows: any[] = []) => {
const createMockPrisma = (initialGeneralRows: any[] = [], initialNationRows: any[] = []) => {
let generalRows = [...initialGeneralRows];
let nationRows = [...initialNationRows];
return {
generalTurn: {
findMany: vi.fn(async ({ where } = {}) => {
@@ -67,9 +68,28 @@ const createMockPrisma = (initialGeneralRows: any[] = []) => {
}),
},
nationTurn: {
findMany: vi.fn(async () => []),
deleteMany: vi.fn(async () => ({ count: 0 })),
createMany: vi.fn(async () => ({ count: 0 })),
findMany: vi.fn(async ({ where } = {}) => {
if (where?.nationId && where?.officerLevel) {
return nationRows
.filter((row) => row.nationId === where.nationId && row.officerLevel === where.officerLevel)
.sort((left, right) => left.turnIdx - right.turnIdx);
}
return nationRows;
}),
deleteMany: vi.fn(async ({ where } = {}) => {
if (where?.nationId && where?.officerLevel) {
nationRows = nationRows.filter(
(row) => row.nationId !== where.nationId || row.officerLevel !== where.officerLevel
);
}
return { count: 0 };
}),
createMany: vi.fn(async ({ data }) => {
if (Array.isArray(data)) {
nationRows.push(...data);
}
return { count: data.length };
}),
},
};
};
@@ -423,8 +443,17 @@ describe('Reserved Turn Execution Integration', () => {
};
const invalidRows = [{ generalId: 1, turnIdx: 0, actionCode: 'che_이동', arg: { destCityId: 'bad' } }];
const invalidNationRows = [
{
nationId: 1,
officerLevel: 5,
turnIdx: 0,
actionCode: 'che_천도',
arg: { destCityId: 'bad' },
},
];
const mockPrisma = createMockPrisma(invalidRows);
const mockPrisma = createMockPrisma(invalidRows, invalidNationRows);
const reservedTurnStore = new InMemoryReservedTurnStore(mockPrisma as any, {
maxGeneralTurns: 10,
maxNationTurns: 10,
@@ -456,7 +485,26 @@ describe('Reserved Turn Execution Integration', () => {
const dirty = world.consumeDirtyState();
expect(world.getGeneralById(1)!.cityId).toBe(1);
expect(dirty.logs.some((log) => log.text.includes('인자가 올바르지 않습니다. 이동 실패.'))).toBe(true);
expect(dirty.logs.find((log) => log.text.includes('인자가 올바르지 않습니다. 천도 실패.'))).toMatchObject({
scope: 'GENERAL',
category: 'ACTION',
generalId: 1,
});
expect(dirty.logs.find((log) => log.text.includes('인자가 올바르지 않습니다. 이동 실패.'))).toMatchObject({
scope: 'GENERAL',
category: 'ACTION',
generalId: 1,
});
const personalActionLogs = dirty.logs.filter(
(log) => log.scope === 'GENERAL' && log.category === 'ACTION'
);
expect(personalActionLogs.length).toBeGreaterThan(0);
expect(personalActionLogs.every((log) => log.generalId === 1)).toBe(true);
expect(
personalActionLogs.map((log) =>
finalizeLogEntry(log, { year: invalidState.currentYear, month: invalidState.currentMonth })
)
).not.toContain(null);
expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true);
});
@@ -620,6 +668,7 @@ describe('Reserved Turn Execution Integration', () => {
const denyLog = dirty.logs.find((log) => log.text.includes('같은 도시입니다.'));
expect(denyLog?.text).toContain('이동 실패.');
expect(denyLog?.meta?.constraintName).toBe('notSameDestCity');
expect(denyLog?.generalId).toBe(1);
expect(dirty.logs.some((log) => log.text.includes('아무것도 실행하지 않았습니다.'))).toBe(true);
});
@@ -0,0 +1,137 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGamePostgresConnector, type GamePrismaClient, type InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope, type TurnSchedule } from '@sammo-ts/logic';
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const worldId = 2_146_200_820;
const generalId = 2_146_200_821;
const turnTime = new Date('0190-01-01T00:00:00.000Z');
const turnRunResult = {
lastTurnTime: turnTime.toISOString(),
processedGenerals: 1,
processedTurns: 1,
durationMs: 0,
partial: false,
} as const;
const schedule: TurnSchedule = {
entries: [{ startMinute: 0, tickMinutes: 10 }],
};
const state: TurnWorldState = {
id: worldId,
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: turnTime,
meta: {},
};
const snapshot: TurnWorldSnapshot = {
generals: [],
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: {
id: 'turn-failure-log-persistence',
name: '턴 실패 로그 영속화',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'che', unitSet: 'che' },
},
scenarioMeta: {
title: '턴 실패 로그 영속화',
startYear: 190,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
},
};
integration('turn failure personal-record persistence', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
let databaseHooks: DatabaseTurnHooks | undefined;
const cleanup = async () => {
await db.logEntry.deleteMany({ where: { generalId } });
await db.worldState.deleteMany({ where: { id: worldId } });
};
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
await cleanup();
});
afterAll(async () => {
await databaseHooks?.close();
await cleanup();
await disconnect?.();
});
it('stores personal and nation-turn failure reasons under the acting general', async () => {
await db.worldState.create({
data: {
id: worldId,
scenarioCode: 'turn-failure-log-persistence',
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
config: snapshot.scenarioConfig as unknown as InputJsonValue,
meta: {},
},
});
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId,
format: LogFormat.MONTH,
text: '대상 도시가 아국이 아닙니다. 발령 실패.',
});
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId,
format: LogFormat.MONTH,
text: '같은 도시입니다. 이동 실패.',
});
databaseHooks = await createDatabaseTurnHooks(databaseUrl!, world);
await databaseHooks.hooks.flushChanges?.(turnRunResult);
const records = await db.logEntry.findMany({
where: {
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId,
},
orderBy: { id: 'asc' },
select: { generalId: true, text: true },
});
expect(records).toEqual([
{ generalId, text: '<C>●</>1월:대상 도시가 아국이 아닙니다. 발령 실패.' },
{ generalId, text: '<C>●</>1월:같은 도시입니다. 이동 실패.' },
]);
});
});