은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다. 유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
400 lines
17 KiB
TypeScript
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);
|
|
});
|