Files
core2026/app/game-engine/test/inheritanceActionPersistence.integration.test.ts
T
Hide_D 4fc20de8d4 fix: 은퇴 명예 기록과 유산 수명주기 정합성을 복원한다
은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다.

유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
2026-08-24 03:38:12 +00:00

400 lines
17 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic';
import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { EngineStateManager } from '../src/turn/engineStateManager.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const worldId = 992_310;
const actorGeneralId = 7_310;
const targetGeneralId = 7_311;
const nationId = 7_312;
const actorUserId = 'inheritance-atomic-actor';
const targetUserId = 'inheritance-atomic-target';
const requestPrefix = 'integration:inheritance-atomic';
const pointConstraint = 'inheritance_atomic_point_failure';
const rankConstraint = 'inheritance_atomic_rank_failure';
const logConstraint = 'inheritance_atomic_log_failure';
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const scenarioConfig: ScenarioConfig = {
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: {},
const: {
inheritBornStatPoint: 1_000,
inheritItemRandomPoint: 3_000,
inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000],
inheritSpecificSpecialPoint: 4_000,
inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000],
inheritCheckOwnerPoint: 1_000,
availableSpecialWar: ['che_의술'],
},
environment: { mapName: 'che', unitSet: 'che' },
};
const scenarioMeta: ScenarioMeta = {
title: '유산 원자성 통합',
startYear: 200,
life: null,
fiction: null,
history: [],
ignoreDefaultEvents: false,
};
const map: MapDefinition = { id: 'inheritance-atomic', name: scenarioMeta.title, cities: [] };
const state: TurnWorldState = {
id: worldId,
currentYear: 200,
currentMonth: 4,
tickSeconds: 600,
lastTurnTime: new Date('2026-08-24T00:00:00.000Z'),
meta: { hiddenSeed: 'inheritance-atomic-seed', season: 77, isunited: 0, scenarioMeta },
};
const buildGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
id: actorGeneralId,
userId: actorUserId,
name: '확인장수',
nationId,
cityId: 0,
troopId: 0,
stats: { leadership: 70, strength: 45, intelligence: 85 },
turnTime: new Date('2026-08-24T00:10:00.000Z'),
recentWarTime: null,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, inherit_spent_dyn: 17 },
inheritancePoints: { previous: 10_000 },
penalty: {},
officerLevel: 1,
experience: 0,
dedication: 0,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
...overrides,
});
const actorGeneral = buildGeneral({});
const targetGeneral = buildGeneral({
id: targetGeneralId,
userId: targetUserId,
name: '피확인장수',
meta: { killturn: 24, owner_name: '레거시 소유자' },
inheritancePoints: { previous: 0 },
});
const generals = [actorGeneral, targetGeneral];
const assertDedicatedDatabase = (rawUrl: string): void => {
const schema = new URL(rawUrl).searchParams.get('schema');
if (!schema?.endsWith('immediate_action_integration')) {
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
}
};
const toGeneralCreate = (general: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({
id: general.id,
userId: general.userId,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
troopId: general.troopId,
npcState: general.npcState,
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
officerLevel: general.officerLevel,
experience: general.experience,
dedication: general.dedication,
injury: general.injury,
gold: general.gold,
rice: general.rice,
crew: general.crew,
crewTypeId: general.crewTypeId,
train: general.train,
atmos: general.atmos,
turnTime: general.turnTime,
recentWarTime: general.recentWarTime,
age: general.age,
meta: general.meta as GamePrisma.InputJsonValue,
penalty: general.penalty as GamePrisma.InputJsonValue,
});
const buildCommand = (
suffix: string,
input: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>['input']
): Extract<TurnDaemonCommand, { type: 'inheritanceAction' }> => ({
type: 'inheritanceAction',
requestId: `${requestPrefix}:${suffix}`,
userId: actorUserId,
input,
});
integration('inheritance action PostgreSQL atomic persistence', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
let hooks: DatabaseTurnHooks | undefined;
const dropFailureConstraints = async (): Promise<void> => {
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT IF EXISTS ${pointConstraint}`);
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT IF EXISTS ${rankConstraint}`);
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT IF EXISTS ${logConstraint}`);
};
beforeAll(async () => {
assertDedicatedDatabase(databaseUrl!);
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
await dropFailureConstraints();
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritanceUserState.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [actorGeneralId, targetGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, targetGeneralId] } } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { id: worldId } });
await db.worldState.create({
data: {
id: worldId,
scenarioCode: 'inheritance-atomic',
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
meta: state.meta as GamePrisma.InputJsonValue,
},
});
await db.nation.create({ data: { id: nationId, name: '통합국', color: '#123456', level: 1 } });
await db.general.createMany({ data: generals.map(toGeneralCreate) });
await db.inheritancePoint.create({ data: { userId: actorUserId, key: 'previous', value: 10_000 } });
await db.rankData.create({
data: { generalId: actorGeneralId, nationId, type: 'inherit_spent_dyn', value: 17 },
});
});
afterAll(async () => {
await hooks?.close();
if (db) {
await dropFailureConstraints();
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } });
await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritanceUserState.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } });
await db.rankData.deleteMany({ where: { generalId: { in: [actorGeneralId, targetGeneralId] } } });
await db.general.deleteMany({ where: { id: { in: [actorGeneralId, targetGeneralId] } } });
await db.nation.deleteMany({ where: { id: nationId } });
await db.worldState.deleteMany({ where: { id: worldId } });
}
await disconnect?.();
});
it('rolls patch/point/rank/log/messages back at each injected failure and reloads one committed mutation', async () => {
const snapshot: TurnWorldSnapshot = {
generals,
cities: [],
nations: [
{
id: nationId,
name: '통합국',
color: '#123456',
capitalCityId: null,
chiefGeneralId: actorGeneralId,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig,
scenarioMeta,
map,
};
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
const handler = createTurnDaemonCommandHandler({ world });
hooks = await createDatabaseTurnHooks(databaseUrl!, world);
const stateManager = new EngineStateManager();
stateManager.register('world', {
capture: () => world.captureState(),
restore: (captured) => world.restoreState(captured),
});
const execute = async (
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
): Promise<TurnDaemonCommandResult> => {
if (!hooks?.hooks.executeCommand || !command.requestId) {
throw new Error('Database command execution hook is unavailable.');
}
return stateManager.transaction(() =>
hooks!.hooks.executeCommand!(command.requestId!, async (context) => {
const result = await handler.handle(command, context);
if (!result) throw new Error('inheritanceAction command was not handled.');
return result;
})
);
};
const createInputEvent = async (
command: Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>
): Promise<void> => {
await db.inputEvent.create({
data: {
requestId: command.requestId!,
target: 'ENGINE',
eventType: command.type,
actorUserId: command.userId,
status: 'PROCESSING',
lockedBy: 'inheritance-atomic-worker',
leaseUntil: new Date('2026-08-24T01:00:00.000Z'),
attempts: 1,
payload: command as GamePrisma.InputJsonValue,
},
});
};
const assertStored = async (point: number, spent: number, logCount: number, messageCount: number) => {
await expect(
db.inheritancePoint.findUniqueOrThrow({
where: { userId_key: { userId: actorUserId, key: 'previous' } },
})
).resolves.toMatchObject({ value: point });
await expect(
db.rankData.findUniqueOrThrow({
where: { generalId_type: { generalId: actorGeneralId, type: 'inherit_spent_dyn' } },
})
).resolves.toMatchObject({ value: spent });
await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(logCount);
await expect(
db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } })
).resolves.toBe(messageCount);
};
const pointCommand = buildCommand('point', {
action: 'buyHiddenBuff',
buffType: 'warAvoidRatio',
level: 1,
});
await createInputEvent(pointCommand);
await db.$executeRawUnsafe(`
ALTER TABLE inheritance_point
ADD CONSTRAINT ${pointConstraint}
CHECK (user_id <> '${actorUserId}' OR key <> 'previous' OR value = 10000)
`);
await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`);
expect(world.getGeneralById(actorGeneralId)?.meta).toMatchObject({ inherit_spent_dyn: 17 });
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff');
await assertStored(10_000, 17, 0, 0);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } })
).resolves.toMatchObject({
status: 'PROCESSING',
result: null,
});
await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`);
await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 });
await assertStored(9_800, 217, 1, 0);
const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId });
await createInputEvent(rankCommand);
await db.$executeRawUnsafe(`
ALTER TABLE rank_data
ADD CONSTRAINT ${rankConstraint}
CHECK (general_id <> ${actorGeneralId} OR type <> 'inherit_spent_dyn' OR value = 217)
`);
await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`);
expect(world.peekDirtyState().messages).toEqual([]);
await assertStored(9_800, 217, 1, 0);
await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`);
await expect(execute(rankCommand)).resolves.toMatchObject({
ok: true,
remainPoint: 8_800,
ownerName: '레거시 소유자',
});
await assertStored(8_800, 1_217, 2, 2);
const currentLog = await db.inheritanceLog.findFirstOrThrow({
where: { userId: actorUserId },
orderBy: { id: 'desc' },
select: { id: true },
});
const logCommand = buildCommand('log', { action: 'buyRandomUnique' });
await createInputEvent(logCommand);
await db.$executeRawUnsafe(`
ALTER TABLE inheritance_log
ADD CONSTRAINT ${logConstraint}
CHECK (user_id <> '${actorUserId}' OR id <= ${currentLog.id})
`);
await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`);
expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique');
await assertStored(8_800, 1_217, 2, 2);
await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`);
await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
await assertStored(5_800, 4_217, 3, 2);
const freeStatCommand = buildCommand('free-stat', {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [0, 0, 0],
});
await createInputEvent(freeStatCommand);
await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 });
await assertStored(5_800, 4_217, 5, 2);
const messages = await db.message.findMany({
where: { mailbox: { in: [actorGeneralId, targetGeneralId] } },
orderBy: { id: 'asc' },
select: { mailbox: true, message: true },
});
expect(messages.map((entry) => [entry.mailbox, (entry.message as { text: string }).text])).toEqual([
[actorGeneralId, '피확인장수의 소유자는 레거시 소유자 입니다.'],
[targetGeneralId, '소유자명이 누군가에 의해 확인되었습니다.'],
]);
await expect(
db.inputEvent.findUniqueOrThrow({ where: { requestId: rankCommand.requestId! } })
).resolves.toMatchObject({
status: 'SUCCEEDED',
attempts: 1,
result: expect.objectContaining({ type: 'inheritanceAction', ok: true, action: 'checkOwner' }),
lockedBy: null,
});
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(reloaded.snapshot.generals.find((general) => general.id === actorGeneralId)).toMatchObject({
stats: { leadership: 71, strength: 47, intelligence: 86 },
meta: {
inherit_spent_dyn: 4_217,
inheritRandomUnique: 1,
inheritBuff: JSON.stringify({ warAvoidRatio: 1 }),
},
inheritancePoints: { previous: 5_800 },
});
}, 30_000);
});