fix: 은퇴 명예 기록과 유산 수명주기 정합성을 복원한다
은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다. 유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user