은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다. 유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
1354 lines
52 KiB
TypeScript
1354 lines
52 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { asRecord, normalizeArchivedGeneral, RANK_DATA_TYPES, type ArchivedJsonValue } from '@sammo-ts/common';
|
|
import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
|
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
|
|
|
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
|
|
import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js';
|
|
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
|
import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js';
|
|
import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js';
|
|
import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js';
|
|
import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js';
|
|
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
|
import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js';
|
|
|
|
const databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL;
|
|
const integration = describe.skipIf(!databaseUrl);
|
|
const generalIds = [990_001, 990_002, 990_003, 990_004, 990_005, 990_006, 990_007];
|
|
const userIds = [
|
|
'integration-lifecycle-dead',
|
|
'integration-lifecycle-retired',
|
|
'integration-lifecycle-possessed',
|
|
'integration-lifecycle-explicit-retired',
|
|
'integration-lifecycle-automatic-retired',
|
|
'integration-lifecycle-death-archive',
|
|
'integration-lifecycle-retire-before-unification',
|
|
];
|
|
const serverId = 'lifecycle-int';
|
|
const sameFlushServerId = `${serverId}-retire-before-unification`;
|
|
const worldId = 990_004;
|
|
const deathArchiveWorldId = 990_006;
|
|
const sameFlushWorldId = 990_007;
|
|
const nationId = 990_004;
|
|
const cityId = 990_004;
|
|
const sameFlushNationId = 990_007;
|
|
const sameFlushCityId = 990_007;
|
|
const archiveServerIds = [serverId, sameFlushServerId];
|
|
const historyServerIds = [serverId, `${serverId}-completed`, `${serverId}-abandoned`, sameFlushServerId];
|
|
|
|
const makeGeneral = (id: number, userId: string, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
|
|
id,
|
|
userId,
|
|
name: `lifecycle-${id}`,
|
|
nationId: 0,
|
|
cityId: 0,
|
|
troopId: 0,
|
|
stats: { leadership: 80, strength: 70, intelligence: 60 },
|
|
experience: 1_000,
|
|
dedication: 800,
|
|
officerLevel: 1,
|
|
role: {
|
|
personality: null,
|
|
specialDomestic: null,
|
|
specialWar: null,
|
|
items: { horse: null, weapon: null, book: null, item: null },
|
|
},
|
|
injury: 0,
|
|
gold: 1_000,
|
|
rice: 1_000,
|
|
crew: 0,
|
|
crewTypeId: 1,
|
|
train: 0,
|
|
atmos: 0,
|
|
age: 80,
|
|
npcState: 0,
|
|
bornYear: 170,
|
|
deadYear: 260,
|
|
affinity: 50,
|
|
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
|
meta: {
|
|
killturn: 0,
|
|
inherit_lived_month: 10,
|
|
inherit_active_action: 2,
|
|
dex1: 1_000,
|
|
},
|
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
|
...patch,
|
|
});
|
|
|
|
const event = (general: TurnGeneral, outcome: GeneralLifecycleEvent['outcome']): GeneralLifecycleEvent => ({
|
|
generalId: general.id,
|
|
outcome,
|
|
before: general,
|
|
...(outcome === 'deleted' ? {} : { after: general }),
|
|
year: 200,
|
|
month: 1,
|
|
});
|
|
|
|
integration('general turn lifecycle persistence', () => {
|
|
let db: GamePrismaClient;
|
|
let close: (() => Promise<void>) | undefined;
|
|
|
|
const cleanup = async () => {
|
|
await db.logEntry.deleteMany({ where: { generalId: { in: generalIds } } });
|
|
await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } });
|
|
await db.generalTurnRevision.deleteMany({ where: { generalId: { in: generalIds } } });
|
|
await db.generalTurn.deleteMany({ where: { generalId: { in: generalIds } } });
|
|
await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } });
|
|
await db.unificationFinalization.deleteMany({ where: { serverId: sameFlushServerId } });
|
|
await db.emperor.deleteMany({ where: { serverId: sameFlushServerId } });
|
|
await db.oldNation.deleteMany({ where: { serverId: sameFlushServerId } });
|
|
await db.oldGeneral.deleteMany({
|
|
where: { serverId: { in: archiveServerIds }, generalNo: { in: generalIds } },
|
|
});
|
|
await db.hallOfFame.deleteMany({
|
|
where: { serverId: { in: archiveServerIds }, generalNo: { in: generalIds } },
|
|
});
|
|
await db.inheritanceResult.deleteMany({
|
|
where: { serverId: { in: archiveServerIds }, owner: { in: userIds } },
|
|
});
|
|
await db.inheritanceLog.deleteMany({ where: { userId: { in: userIds } } });
|
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: userIds } } });
|
|
await db.general.deleteMany({ where: { id: { in: generalIds } } });
|
|
await db.city.deleteMany({ where: { id: { in: [cityId, sameFlushCityId] } } });
|
|
await db.nation.deleteMany({ where: { id: { in: [nationId, sameFlushNationId] } } });
|
|
await db.worldState.deleteMany({
|
|
where: { id: { in: [worldId, deathArchiveWorldId, sameFlushWorldId] } },
|
|
});
|
|
await db.gameHistory.deleteMany({ where: { serverId: { in: historyServerIds } } });
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
|
await connector.connect();
|
|
db = connector.prisma;
|
|
close = () => connector.disconnect();
|
|
await cleanup();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await cleanup();
|
|
await close?.();
|
|
});
|
|
|
|
it('archives deletion, removes access state, and settles inheritance', async () => {
|
|
const general = makeGeneral(generalIds[0]!, userIds[0]!, {
|
|
meta: {
|
|
killturn: 0,
|
|
inherit_lived_month: 10,
|
|
inherit_active_action: 2,
|
|
max_belong: 9,
|
|
inheritRandomUnique: true,
|
|
dex1: 1_000,
|
|
dex2: 1,
|
|
dex3: 1,
|
|
dex4: 1,
|
|
dex5: 1,
|
|
betwin: 2,
|
|
betgold: 2_000,
|
|
betwingold: 1_000,
|
|
},
|
|
});
|
|
await db.generalAccessLog.create({
|
|
data: { generalId: general.id, userId: general.userId, refreshScore: 99 },
|
|
});
|
|
await db.inheritancePoint.createMany({
|
|
data: [
|
|
{ userId: general.userId!, key: 'previous', value: 100 },
|
|
{ userId: general.userId!, key: 'max_domestic_critical', value: 80 },
|
|
{ userId: general.userId!, key: 'unifier', value: 250 },
|
|
{ userId: general.userId!, key: 'tournament', value: 50 },
|
|
],
|
|
});
|
|
await db.rankData.createMany({
|
|
data: [
|
|
{ generalId: general.id, nationId: 0, type: 'warnum', value: 2 },
|
|
{ generalId: general.id, nationId: 0, type: 'firenum', value: 1 },
|
|
{ generalId: general.id, nationId: 0, type: 'killnum', value: 1 },
|
|
{ generalId: general.id, nationId: 0, type: 'deathnum', value: 1 },
|
|
{ generalId: general.id, nationId: 0, type: 'killcrew', value: 400 },
|
|
{ generalId: general.id, nationId: 0, type: 'deathcrew', value: 300 },
|
|
],
|
|
});
|
|
await db.logEntry.createMany({
|
|
data: [
|
|
{
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.HISTORY,
|
|
year: 199,
|
|
month: 12,
|
|
generalId: general.id,
|
|
text: '<C>●</>첫 기록',
|
|
},
|
|
{
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.HISTORY,
|
|
year: 200,
|
|
month: 1,
|
|
generalId: general.id,
|
|
text: '<Y>●</>둘째 기록',
|
|
},
|
|
{
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.ACTION,
|
|
year: 200,
|
|
month: 1,
|
|
generalId: general.id,
|
|
text: '보존하지 않을 개인 기록',
|
|
},
|
|
{
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.BATTLE_DETAIL,
|
|
year: 200,
|
|
month: 1,
|
|
generalId: general.id,
|
|
text: '보존하지 않을 전투 기록',
|
|
},
|
|
{
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.BATTLE_BRIEF,
|
|
year: 200,
|
|
month: 1,
|
|
generalId: general.id,
|
|
text: '보존할 전투 결과',
|
|
},
|
|
],
|
|
});
|
|
|
|
await db.$transaction((tx) =>
|
|
persistGeneralLifecycleEvents(
|
|
tx,
|
|
[event(general, 'deleted')],
|
|
{ serverId },
|
|
{ inheritItemRandomPoint: 3_000, inheritSpecificSpecialPoint: 4_000 }
|
|
)
|
|
);
|
|
|
|
expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toBeNull();
|
|
const archived = await db.oldGeneral.findUniqueOrThrow({
|
|
where: { by_no: { serverId, generalNo: general.id } },
|
|
});
|
|
const archivedData = asRecord(archived.data);
|
|
expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
|
|
expect(asRecord(archivedData.meta)).toMatchObject({ inheritRandomUnique: true });
|
|
const snapshot = normalizeArchivedGeneral(archived.data as ArchivedJsonValue, archived.name).snapshot;
|
|
expect(snapshot).toMatchObject({
|
|
mastery: { infantry: 1_000, archery: 1, cavalry: 1, special: 1, siege: 1 },
|
|
battle: {
|
|
battles: 2,
|
|
wins: 1,
|
|
losses: 1,
|
|
fireSuccesses: 1,
|
|
killedCrew: 400,
|
|
lostCrew: 300,
|
|
},
|
|
records: { battleResult: ['보존할 전투 결과'] },
|
|
availability: { battleResultLogs: true, battleDetailLogs: false },
|
|
});
|
|
expect(JSON.stringify(archived.data)).not.toContain('보존하지 않을 개인 기록');
|
|
expect(JSON.stringify(archived.data)).not.toContain('보존하지 않을 전투 기록');
|
|
expect(
|
|
await db.inheritancePoint.findUnique({
|
|
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
|
})
|
|
).toMatchObject({ value: 3_622 });
|
|
expect(
|
|
(
|
|
await db.inheritanceLog.findMany({
|
|
where: { userId: general.userId! },
|
|
orderBy: { id: 'asc' },
|
|
select: { text: true },
|
|
})
|
|
).map(({ text }) => text)
|
|
).toEqual([
|
|
'사망으로 랜덤 유니크 구입 3000 포인트 반환',
|
|
'기존 보유 포인트 3100 증가',
|
|
'최대 임관년 수 포인트 90 증가',
|
|
'최대 연속 내정 성공 포인트 80 증가',
|
|
'전투 횟수 포인트 10 증가',
|
|
'계략 성공 횟수 포인트 20 증가',
|
|
'천통 기여 포인트 250 증가',
|
|
'숙련도 포인트 1.004 증가',
|
|
'토너먼트 포인트 50 증가',
|
|
'베팅 당첨 포인트 5 증가',
|
|
'포인트 3100 => 3622',
|
|
]);
|
|
});
|
|
|
|
it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => {
|
|
const general = makeGeneral(generalIds[1]!, userIds[1]!, {
|
|
meta: {
|
|
killturn: 0,
|
|
inherit_lived_month: 10,
|
|
inherit_active_action: 2,
|
|
max_domestic_critical: 20,
|
|
max_belong: 7,
|
|
dex1: 1_000,
|
|
betwin: 2,
|
|
betgold: 2_000,
|
|
betwingold: 1_000,
|
|
},
|
|
});
|
|
await db.generalAccessLog.create({
|
|
data: { generalId: general.id, userId: general.userId, refreshScore: 77 },
|
|
});
|
|
await db.inheritancePoint.createMany({
|
|
data: [
|
|
{ userId: general.userId!, key: 'previous', value: 50 },
|
|
{ userId: general.userId!, key: 'max_domestic_critical', value: 80 },
|
|
{ userId: general.userId!, key: 'unifier', value: 250 },
|
|
{ userId: general.userId!, key: 'tournament', value: 50 },
|
|
],
|
|
});
|
|
await db.rankData.create({
|
|
data: { generalId: general.id, nationId: 0, type: 'warnum', value: 10 },
|
|
});
|
|
await db.rankData.create({
|
|
data: { generalId: general.id, nationId: 0, type: 'inherit_earned', value: 4_321 },
|
|
});
|
|
|
|
const retirementEvent = event(general, 'retired');
|
|
retirementEvent.after = {
|
|
...general,
|
|
meta: { ...general.meta, rank_warnum: 0, inherit_earned: 0 },
|
|
};
|
|
await db.$transaction((tx) =>
|
|
persistGeneralLifecycleEvents(
|
|
tx,
|
|
[retirementEvent],
|
|
{ serverId, season: 1, scenarioId: 2, isUnited: 0 },
|
|
{}
|
|
)
|
|
);
|
|
|
|
expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toMatchObject({
|
|
refreshScore: 0,
|
|
});
|
|
expect(
|
|
await db.rankData.findUnique({ where: { generalId_type: { generalId: general.id, type: 'warnum' } } })
|
|
).toMatchObject({ value: 0 });
|
|
expect(
|
|
await db.hallOfFame.findUnique({
|
|
where: {
|
|
serverId_type_generalNo: {
|
|
serverId,
|
|
type: 'experience',
|
|
generalNo: general.id,
|
|
},
|
|
},
|
|
})
|
|
).toMatchObject({ value: 1_000 });
|
|
expect(
|
|
await db.hallOfFame.findUnique({
|
|
where: {
|
|
serverId_type_generalNo: {
|
|
serverId,
|
|
type: 'inherit_earned',
|
|
generalNo: general.id,
|
|
},
|
|
},
|
|
})
|
|
).toMatchObject({ value: 4_321 });
|
|
expect(
|
|
await db.inheritancePoint.findUnique({
|
|
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
|
})
|
|
).toMatchObject({ value: 171 });
|
|
expect(
|
|
await db.inheritancePoint.findMany({
|
|
where: { userId: general.userId! },
|
|
orderBy: { key: 'asc' },
|
|
select: { key: true, value: true },
|
|
})
|
|
).toEqual([
|
|
{ key: 'max_belong', value: 70 },
|
|
{ key: 'max_domestic_critical', value: 80 },
|
|
{ key: 'previous', value: 171 },
|
|
{ key: 'unifier', value: 250 },
|
|
]);
|
|
});
|
|
|
|
it('archives the death turn history and battle brief through execute and database flush', async () => {
|
|
const general = makeGeneral(generalIds[5]!, userIds[5]!, {
|
|
npcState: 2,
|
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
|
});
|
|
const scenarioConfig = {
|
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
|
iconPath: '.',
|
|
map: {},
|
|
const: { killturn: 0 },
|
|
environment: { mapName: 'che', unitSet: 'che' },
|
|
};
|
|
const scenarioMeta = {
|
|
title: '사망 기록 archive integration',
|
|
startYear: 200,
|
|
life: null,
|
|
fiction: 0,
|
|
history: [],
|
|
ignoreDefaultEvents: false,
|
|
};
|
|
const worldMeta = { serverId, killturn: 0, scenarioMeta };
|
|
await db.worldState.create({
|
|
data: {
|
|
id: deathArchiveWorldId,
|
|
scenarioCode: 'death-archive-integration',
|
|
currentYear: 200,
|
|
currentMonth: 1,
|
|
tickSeconds: 600,
|
|
config: scenarioConfig as GamePrisma.InputJsonValue,
|
|
meta: worldMeta as GamePrisma.InputJsonValue,
|
|
},
|
|
});
|
|
await db.general.create({
|
|
data: {
|
|
id: general.id,
|
|
userId: general.userId,
|
|
name: general.name,
|
|
nationId: general.nationId,
|
|
cityId: general.cityId,
|
|
npcState: general.npcState,
|
|
leadership: general.stats.leadership,
|
|
strength: general.stats.strength,
|
|
intel: general.stats.intelligence,
|
|
experience: general.experience,
|
|
dedication: general.dedication,
|
|
officerLevel: general.officerLevel,
|
|
injury: general.injury,
|
|
gold: general.gold,
|
|
rice: general.rice,
|
|
crew: general.crew,
|
|
crewTypeId: general.crewTypeId,
|
|
train: general.train,
|
|
atmos: general.atmos,
|
|
turnTime: general.turnTime,
|
|
age: general.age,
|
|
bornYear: general.bornYear,
|
|
deadYear: general.deadYear,
|
|
meta: general.meta as GamePrisma.InputJsonValue,
|
|
},
|
|
});
|
|
await db.logEntry.createMany({
|
|
data: [
|
|
{
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.HISTORY,
|
|
year: 199,
|
|
month: 12,
|
|
generalId: general.id,
|
|
text: '이전 열전',
|
|
},
|
|
{
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.BATTLE_BRIEF,
|
|
year: 199,
|
|
month: 12,
|
|
generalId: general.id,
|
|
text: '이전 전투 결과',
|
|
},
|
|
],
|
|
});
|
|
const state: TurnWorldState = {
|
|
id: deathArchiveWorldId,
|
|
currentYear: 200,
|
|
currentMonth: 1,
|
|
tickSeconds: 600,
|
|
lastTurnTime: general.turnTime,
|
|
meta: worldMeta,
|
|
};
|
|
const snapshot: TurnWorldSnapshot = {
|
|
scenarioConfig,
|
|
scenarioMeta,
|
|
map: {
|
|
id: 'death-archive',
|
|
name: '사망 기록 archive',
|
|
cities: [],
|
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
|
},
|
|
generals: [general],
|
|
nations: [],
|
|
cities: [],
|
|
troops: [],
|
|
diplomacy: [],
|
|
events: [],
|
|
initialEvents: [],
|
|
};
|
|
const world = new InMemoryTurnWorld(state, snapshot, {
|
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
|
generalTurnHandler: {
|
|
execute: ({ general: currentGeneral, world: currentWorld }) => ({
|
|
deleted: { general: true },
|
|
lifecycleEvent: {
|
|
generalId: currentGeneral.id,
|
|
outcome: 'deleted',
|
|
before: currentGeneral,
|
|
year: currentWorld.currentYear,
|
|
month: currentWorld.currentMonth,
|
|
},
|
|
logs: [
|
|
{
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.HISTORY,
|
|
generalId: currentGeneral.id,
|
|
text: '마지막 열전 첫째',
|
|
},
|
|
{
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.BATTLE_BRIEF,
|
|
generalId: currentGeneral.id,
|
|
text: '마지막 전투 결과 첫째',
|
|
},
|
|
{
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.HISTORY,
|
|
generalId: currentGeneral.id,
|
|
text: '마지막 열전 둘째',
|
|
},
|
|
{
|
|
scope: LogScope.GENERAL,
|
|
category: LogCategory.BATTLE_BRIEF,
|
|
generalId: currentGeneral.id,
|
|
text: '마지막 전투 결과 둘째',
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
|
|
world.executeGeneralTurn(world.getGeneralById(general.id)!);
|
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
|
try {
|
|
await hooks.hooks.flushChanges?.({
|
|
lastTurnTime: state.lastTurnTime.toISOString(),
|
|
processedGenerals: 1,
|
|
processedTurns: 1,
|
|
durationMs: 0,
|
|
partial: false,
|
|
});
|
|
} finally {
|
|
await hooks.close();
|
|
}
|
|
|
|
const archived = await db.oldGeneral.findUniqueOrThrow({
|
|
where: { by_no: { serverId, generalNo: general.id } },
|
|
});
|
|
const archivedData = asRecord(archived.data);
|
|
expect(archivedData.history).toEqual(['마지막 열전 둘째', '마지막 열전 첫째', '이전 열전']);
|
|
expect(asRecord(archivedData.records).battleResult).toEqual([
|
|
'마지막 전투 결과 둘째',
|
|
'마지막 전투 결과 첫째',
|
|
'이전 전투 결과',
|
|
]);
|
|
await expect(
|
|
db.logEntry.count({
|
|
where: {
|
|
generalId: general.id,
|
|
category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] },
|
|
},
|
|
})
|
|
).resolves.toBe(6);
|
|
});
|
|
|
|
it('settles a pre-month retirement before same-flush unification without losing Hall or repaying stored points', async () => {
|
|
const turnTime = new Date('0200-01-01T00:05:00.000Z');
|
|
const monthBoundary = new Date('0200-01-01T00:10:00.000Z');
|
|
const general = makeGeneral(generalIds[6]!, userIds[6]!, {
|
|
nationId: sameFlushNationId,
|
|
cityId: sameFlushCityId,
|
|
age: 80,
|
|
officerLevel: 1,
|
|
turnTime,
|
|
meta: {
|
|
killturn: 24,
|
|
owner_name: '월경계 은퇴 사용자',
|
|
rank_warnum: 11,
|
|
firenum: 2,
|
|
inherit_lived_month: 10,
|
|
inherit_active_action: 4,
|
|
dex1: 200,
|
|
dex2: 0,
|
|
dex3: 0,
|
|
dex4: 0,
|
|
dex5: 0,
|
|
event100_allstar: { granted: { dex1: 80 } },
|
|
},
|
|
inheritancePoints: {
|
|
previous: 100,
|
|
lived_month: 10,
|
|
active_action: 4,
|
|
tournament: 11,
|
|
},
|
|
});
|
|
const scenarioConfig = {
|
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
|
iconPath: '.',
|
|
map: {},
|
|
const: {
|
|
retirementYear: 80,
|
|
minPushHallAge: 30,
|
|
incDefSettingChange: 3,
|
|
maxDefSettingChange: 9,
|
|
},
|
|
environment: { mapName: 'che', unitSet: 'che' },
|
|
};
|
|
const scenarioMeta = {
|
|
title: '월경계 은퇴 후 통일 integration',
|
|
startYear: 200,
|
|
life: null,
|
|
fiction: 0,
|
|
history: [],
|
|
ignoreDefaultEvents: false,
|
|
};
|
|
const worldMeta = {
|
|
serverId: sameFlushServerId,
|
|
serverName: '월경계 서버',
|
|
season: 9,
|
|
scenarioId: 77,
|
|
gameIdx: 12,
|
|
isUnited: 0,
|
|
isunited: 0,
|
|
killturn: 24,
|
|
scenarioMeta,
|
|
};
|
|
const nation = {
|
|
id: sameFlushNationId,
|
|
name: '월경계국',
|
|
color: '#224466',
|
|
capitalCityId: sameFlushCityId,
|
|
chiefGeneralId: general.id,
|
|
gold: 10_000,
|
|
rice: 10_000,
|
|
power: 1_000,
|
|
level: 1,
|
|
typeCode: 'che_중립',
|
|
meta: { gennum: 1, tech: 0 },
|
|
};
|
|
const city = {
|
|
id: sameFlushCityId,
|
|
name: '월경계성',
|
|
nationId: sameFlushNationId,
|
|
level: 5,
|
|
state: 0,
|
|
population: 10_000,
|
|
populationMax: 20_000,
|
|
agriculture: 1_000,
|
|
agricultureMax: 2_000,
|
|
commerce: 1_000,
|
|
commerceMax: 2_000,
|
|
security: 1_000,
|
|
securityMax: 2_000,
|
|
supplyState: 1,
|
|
frontState: 0,
|
|
defence: 1_000,
|
|
defenceMax: 2_000,
|
|
wall: 1_000,
|
|
wallMax: 2_000,
|
|
meta: { trust: 50, trade: 100, region: 1 },
|
|
};
|
|
const map = {
|
|
id: 'retire-before-unification',
|
|
name: '월경계 은퇴 후 통일',
|
|
cities: [],
|
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
|
};
|
|
await db.worldState.create({
|
|
data: {
|
|
id: sameFlushWorldId,
|
|
scenarioCode: 'retire-before-unification-integration',
|
|
currentYear: 200,
|
|
currentMonth: 1,
|
|
tickSeconds: 600,
|
|
config: scenarioConfig as GamePrisma.InputJsonValue,
|
|
meta: worldMeta as GamePrisma.InputJsonValue,
|
|
},
|
|
});
|
|
await db.nation.create({
|
|
data: {
|
|
id: nation.id,
|
|
name: nation.name,
|
|
color: nation.color,
|
|
capitalCityId: nation.capitalCityId,
|
|
chiefGeneralId: nation.chiefGeneralId,
|
|
gold: nation.gold,
|
|
rice: nation.rice,
|
|
tech: 0,
|
|
level: nation.level,
|
|
typeCode: nation.typeCode,
|
|
meta: nation.meta,
|
|
},
|
|
});
|
|
await db.city.create({
|
|
data: {
|
|
id: city.id,
|
|
name: city.name,
|
|
nationId: city.nationId,
|
|
level: city.level,
|
|
population: city.population,
|
|
populationMax: city.populationMax,
|
|
agriculture: city.agriculture,
|
|
agricultureMax: city.agricultureMax,
|
|
commerce: city.commerce,
|
|
commerceMax: city.commerceMax,
|
|
security: city.security,
|
|
securityMax: city.securityMax,
|
|
defence: city.defence,
|
|
defenceMax: city.defenceMax,
|
|
wall: city.wall,
|
|
wallMax: city.wallMax,
|
|
supplyState: city.supplyState,
|
|
frontState: city.frontState,
|
|
region: 1,
|
|
meta: city.meta,
|
|
},
|
|
});
|
|
await db.general.create({
|
|
data: {
|
|
id: general.id,
|
|
userId: general.userId,
|
|
name: general.name,
|
|
nationId: general.nationId,
|
|
cityId: general.cityId,
|
|
npcState: general.npcState,
|
|
leadership: general.stats.leadership,
|
|
strength: general.stats.strength,
|
|
intel: general.stats.intelligence,
|
|
experience: general.experience,
|
|
dedication: general.dedication,
|
|
officerLevel: general.officerLevel,
|
|
injury: general.injury,
|
|
gold: general.gold,
|
|
rice: general.rice,
|
|
crew: general.crew,
|
|
crewTypeId: general.crewTypeId,
|
|
train: general.train,
|
|
atmos: general.atmos,
|
|
turnTime: general.turnTime,
|
|
age: general.age,
|
|
bornYear: general.bornYear,
|
|
deadYear: general.deadYear,
|
|
meta: general.meta as GamePrisma.InputJsonValue,
|
|
},
|
|
});
|
|
await db.rankData.createMany({
|
|
data: RANK_DATA_TYPES.map((type) => ({
|
|
generalId: general.id,
|
|
nationId: general.nationId,
|
|
type,
|
|
value: type === 'warnum' ? 11 : type === 'firenum' ? 2 : 0,
|
|
})),
|
|
});
|
|
await db.inheritancePoint.createMany({
|
|
data: [
|
|
{ userId: general.userId!, key: 'previous', value: 100 },
|
|
{ userId: general.userId!, key: 'lived_month', value: 10 },
|
|
{ userId: general.userId!, key: 'active_action', value: 4 },
|
|
{ userId: general.userId!, key: 'tournament', value: 11 },
|
|
],
|
|
});
|
|
await db.gameHistory.create({
|
|
data: {
|
|
serverId: sameFlushServerId,
|
|
date: new Date('0200-01-01T00:00:00.000Z'),
|
|
season: 9,
|
|
scenario: 77,
|
|
scenarioName: scenarioMeta.title,
|
|
status: 'OPEN',
|
|
},
|
|
});
|
|
|
|
const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 3, maxNationTurns: 3 });
|
|
await reservedTurns.loadAll();
|
|
reservedTurns.setGeneralTurn(general.id, 0, { action: '휴식', args: {} });
|
|
const state: TurnWorldState = {
|
|
id: sameFlushWorldId,
|
|
currentYear: 200,
|
|
currentMonth: 1,
|
|
tickSeconds: 600,
|
|
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
|
|
meta: worldMeta,
|
|
};
|
|
const snapshot: TurnWorldSnapshot = {
|
|
scenarioConfig,
|
|
scenarioMeta,
|
|
map,
|
|
generals: [general],
|
|
nations: [nation],
|
|
cities: [city],
|
|
troops: [],
|
|
diplomacy: [],
|
|
events: [],
|
|
initialEvents: [],
|
|
};
|
|
let world: InMemoryTurnWorld | null = null;
|
|
const handler = await createReservedTurnHandler({
|
|
reservedTurns,
|
|
scenarioConfig,
|
|
scenarioMeta,
|
|
map,
|
|
getWorld: () => world,
|
|
});
|
|
world = new InMemoryTurnWorld(state, snapshot, {
|
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
|
generalTurnHandler: handler,
|
|
calendarHandler: {
|
|
onMonthChanged: (context) => {
|
|
if (!world) throw new Error('world is unavailable');
|
|
const reborn = world.getGeneralById(general.id);
|
|
if (!reborn?.userId) throw new Error('reborn general is unavailable');
|
|
world.queueInheritancePointAdjustment(reborn.userId, 'unifier', 250, 'after_lifecycle');
|
|
world.updateGeneral(reborn.id, {
|
|
inheritancePoints: {
|
|
...reborn.inheritancePoints,
|
|
unifier: (reborn.inheritancePoints?.unifier ?? 0) + 250,
|
|
},
|
|
});
|
|
world.updateWorldMeta({ isUnited: 2, isunited: 2 });
|
|
world.queueUnificationFinalization({
|
|
generationKey: `unification:${sameFlushServerId}`,
|
|
serverId: sameFlushServerId,
|
|
profileName: 'che',
|
|
winnerNationId: sameFlushNationId,
|
|
year: context.currentYear,
|
|
month: context.currentMonth,
|
|
completedAt: new Date(context.turnTime.getTime()),
|
|
auctionCancellations: [],
|
|
});
|
|
},
|
|
},
|
|
});
|
|
world.advanceGameClockTo(monthBoundary, monthBoundary);
|
|
const processor = new InMemoryTurnProcessor(world);
|
|
const result = await processor.run(monthBoundary, {
|
|
budgetMs: 10_000,
|
|
maxGenerals: 10,
|
|
catchUpCap: 1,
|
|
});
|
|
expect(result).toMatchObject({ processedGenerals: 1, processedTurns: 1, partial: false });
|
|
expect(world.getState().meta).toMatchObject({ isUnited: 2, isunited: 2 });
|
|
expect(world.peekDirtyState().lifecycleEvents).toContainEqual(
|
|
expect.objectContaining({
|
|
generalId: general.id,
|
|
outcome: 'retired',
|
|
isUnitedAtEvent: 0,
|
|
})
|
|
);
|
|
expect(world.getGeneralById(general.id)).toMatchObject({
|
|
age: 20,
|
|
inheritancePoints: { tournament: 11, lived_month: 11, active_action: 4 },
|
|
meta: { rank_warnum: 0, inherit_lived_month: 0, inherit_active_action: 0, dex1: 100 },
|
|
});
|
|
|
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns, profileName: 'che' });
|
|
try {
|
|
await hooks.hooks.flushChanges?.(result);
|
|
} finally {
|
|
await hooks.close();
|
|
}
|
|
|
|
await expect(
|
|
db.hallOfFame.findUniqueOrThrow({
|
|
where: {
|
|
serverId_type_generalNo: {
|
|
serverId: sameFlushServerId,
|
|
type: 'warnum',
|
|
generalNo: general.id,
|
|
},
|
|
},
|
|
})
|
|
).resolves.toMatchObject({ value: 11 });
|
|
const dexHall = await db.hallOfFame.findUniqueOrThrow({
|
|
where: {
|
|
serverId_type_generalNo: {
|
|
serverId: sameFlushServerId,
|
|
type: 'dex1',
|
|
generalNo: general.id,
|
|
},
|
|
},
|
|
});
|
|
expect(dexHall).toMatchObject({ value: 120 });
|
|
expect(asRecord(dexHall.aux)).toMatchObject({ unitedTime: monthBoundary.toISOString() });
|
|
|
|
const results = await db.inheritanceResult.findMany({
|
|
where: { serverId: sameFlushServerId, owner: general.userId! },
|
|
orderBy: { id: 'asc' },
|
|
select: { value: true },
|
|
});
|
|
expect(results).toHaveLength(2);
|
|
const rebirth = asRecord(results[0]!.value);
|
|
const unification = asRecord(results[1]!.value);
|
|
expect(rebirth).toMatchObject({ rebirth: true, tournament: 11 });
|
|
expect(asRecord(rebirth.retained)).toMatchObject({ unifier: 0 });
|
|
expect(unification).toMatchObject({
|
|
generationKey: `unification:${sameFlushServerId}`,
|
|
previous: rebirth.total,
|
|
lived_month: 0,
|
|
active_action: 0,
|
|
tournament: 0,
|
|
unifier: 250,
|
|
unifierBeforeAward: 250,
|
|
unifierAward: 0,
|
|
});
|
|
await expect(
|
|
db.inheritancePoint.findUniqueOrThrow({
|
|
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
|
})
|
|
).resolves.toMatchObject({ value: Math.floor(Number(unification.total)) });
|
|
});
|
|
|
|
it('does not settle a possessed NPC before the legacy minimum possession period', async () => {
|
|
const general = makeGeneral(generalIds[2]!, userIds[2]!, {
|
|
npcState: 1,
|
|
meta: {
|
|
killturn: 0,
|
|
inherit_lived_month: 10,
|
|
inherit_active_action: 2,
|
|
pickYearMonth: 190 * 12,
|
|
},
|
|
});
|
|
await db.inheritancePoint.create({
|
|
data: { userId: general.userId!, key: 'previous', value: 100 },
|
|
});
|
|
|
|
await db.$transaction((tx) =>
|
|
persistGeneralLifecycleEvents(tx, [event(general, 'deleted')], { serverId, startYear: 180 }, {})
|
|
);
|
|
|
|
expect(
|
|
await db.inheritancePoint.findUnique({
|
|
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
|
})
|
|
).toMatchObject({ value: 100 });
|
|
expect(
|
|
await db.inheritanceResult.count({
|
|
where: { serverId, owner: general.userId! },
|
|
})
|
|
).toBe(0);
|
|
});
|
|
|
|
it('executes explicit retirement, flushes pre-reset settlement values, and reloads only the reborn state', async () => {
|
|
const general = makeGeneral(generalIds[3]!, userIds[3]!, {
|
|
nationId,
|
|
cityId,
|
|
age: 65,
|
|
experience: 1_001,
|
|
dedication: 801,
|
|
turnTime: new Date('0200-01-01T00:10:00.000Z'),
|
|
role: {
|
|
personality: null,
|
|
specialDomestic: null,
|
|
specialWar: null,
|
|
items: { horse: null, weapon: 'che_무기_12_칠성검', book: null, item: null },
|
|
},
|
|
meta: {
|
|
killturn: 24,
|
|
rank_warnum: 11,
|
|
firenum: 9,
|
|
inherit_earned: 4_321,
|
|
inherit_lived_month: 10,
|
|
inherit_active_action: 4,
|
|
inheritRandomUnique: 1,
|
|
inherit_spent_dyn: 3_000,
|
|
dex1: 200,
|
|
dex2: 0,
|
|
dex3: 0,
|
|
dex4: 0,
|
|
dex5: 0,
|
|
event100_allstar: { granted: { dex1: 80 } },
|
|
},
|
|
inheritancePoints: { previous: 50, lived_month: 10, active_action: 4 },
|
|
});
|
|
const automaticGeneral = makeGeneral(generalIds[4]!, userIds[4]!, {
|
|
nationId,
|
|
cityId,
|
|
age: 80,
|
|
crew: 100,
|
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
|
role: {
|
|
personality: null,
|
|
specialDomestic: null,
|
|
specialWar: null,
|
|
items: { horse: null, weapon: 'che_무기_12_칠성검', book: null, item: null },
|
|
},
|
|
meta: {
|
|
killturn: 24,
|
|
rank_warnum: 6,
|
|
inherit_lived_month: 10,
|
|
inherit_active_action: 4,
|
|
inheritRandomUnique: 1,
|
|
inherit_spent_dyn: 3_000,
|
|
dex1: 40,
|
|
},
|
|
inheritancePoints: { previous: 70, lived_month: 10, active_action: 4 },
|
|
});
|
|
const scenarioConfig = {
|
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
|
|
iconPath: '.',
|
|
map: {},
|
|
const: {
|
|
retirementYear: 80,
|
|
incDefSettingChange: 3,
|
|
maxDefSettingChange: 9,
|
|
inheritItemRandomPoint: 3_000,
|
|
allItems: { weapon: { che_무기_12_칠성검: 1 } },
|
|
},
|
|
environment: { mapName: 'che', unitSet: 'che' },
|
|
};
|
|
const scenarioMeta = {
|
|
title: '명시적 은퇴 integration',
|
|
startYear: 200,
|
|
life: null,
|
|
fiction: 0,
|
|
history: [],
|
|
ignoreDefaultEvents: false,
|
|
};
|
|
const worldMeta = {
|
|
serverId,
|
|
season: 4,
|
|
scenarioId: 22,
|
|
gameIdx: 7,
|
|
isUnited: 0,
|
|
killturn: 24,
|
|
scenarioMeta,
|
|
};
|
|
await db.worldState.create({
|
|
data: {
|
|
id: worldId,
|
|
scenarioCode: 'explicit-retirement-integration',
|
|
currentYear: 200,
|
|
currentMonth: 1,
|
|
tickSeconds: 600,
|
|
config: scenarioConfig as GamePrisma.InputJsonValue,
|
|
meta: worldMeta as GamePrisma.InputJsonValue,
|
|
},
|
|
});
|
|
await db.nation.create({
|
|
data: { id: nationId, name: '은퇴국', color: '#330000', level: 1, capitalCityId: cityId },
|
|
});
|
|
await db.city.create({
|
|
data: {
|
|
id: cityId,
|
|
name: '은퇴성',
|
|
level: 5,
|
|
nationId,
|
|
population: 10_000,
|
|
populationMax: 20_000,
|
|
agriculture: 1_000,
|
|
agricultureMax: 2_000,
|
|
commerce: 1_000,
|
|
commerceMax: 2_000,
|
|
security: 1_000,
|
|
securityMax: 2_000,
|
|
defence: 1_000,
|
|
defenceMax: 2_000,
|
|
wall: 1_000,
|
|
wallMax: 2_000,
|
|
region: 1,
|
|
},
|
|
});
|
|
await db.general.create({
|
|
data: {
|
|
id: general.id,
|
|
userId: general.userId,
|
|
name: general.name,
|
|
nationId,
|
|
cityId,
|
|
npcState: 0,
|
|
leadership: general.stats.leadership,
|
|
strength: general.stats.strength,
|
|
intel: general.stats.intelligence,
|
|
experience: general.experience,
|
|
dedication: general.dedication,
|
|
officerLevel: general.officerLevel,
|
|
injury: general.injury,
|
|
gold: general.gold,
|
|
rice: general.rice,
|
|
crew: general.crew,
|
|
crewTypeId: general.crewTypeId,
|
|
train: general.train,
|
|
atmos: general.atmos,
|
|
turnTime: general.turnTime,
|
|
age: general.age,
|
|
bornYear: general.bornYear,
|
|
deadYear: general.deadYear,
|
|
meta: general.meta as GamePrisma.InputJsonValue,
|
|
},
|
|
});
|
|
await db.general.create({
|
|
data: {
|
|
id: automaticGeneral.id,
|
|
userId: automaticGeneral.userId,
|
|
name: automaticGeneral.name,
|
|
nationId,
|
|
cityId,
|
|
npcState: 0,
|
|
leadership: automaticGeneral.stats.leadership,
|
|
strength: automaticGeneral.stats.strength,
|
|
intel: automaticGeneral.stats.intelligence,
|
|
experience: automaticGeneral.experience,
|
|
dedication: automaticGeneral.dedication,
|
|
officerLevel: automaticGeneral.officerLevel,
|
|
injury: automaticGeneral.injury,
|
|
gold: automaticGeneral.gold,
|
|
rice: automaticGeneral.rice,
|
|
crew: automaticGeneral.crew,
|
|
crewTypeId: automaticGeneral.crewTypeId,
|
|
train: automaticGeneral.train,
|
|
atmos: automaticGeneral.atmos,
|
|
turnTime: automaticGeneral.turnTime,
|
|
age: automaticGeneral.age,
|
|
bornYear: automaticGeneral.bornYear,
|
|
deadYear: automaticGeneral.deadYear,
|
|
meta: automaticGeneral.meta as GamePrisma.InputJsonValue,
|
|
},
|
|
});
|
|
await db.rankData.createMany({
|
|
data: [
|
|
...RANK_DATA_TYPES.map((type) => ({
|
|
generalId: general.id,
|
|
nationId,
|
|
type,
|
|
value: type === 'warnum' ? 10 : type === 'firenum' ? 8 : type === 'inherit_earned' ? 123 : 0,
|
|
})),
|
|
...RANK_DATA_TYPES.map((type) => ({
|
|
generalId: automaticGeneral.id,
|
|
nationId,
|
|
type,
|
|
value: type === 'warnum' ? 5 : type === 'inherit_spent_dyn' ? 3_000 : 0,
|
|
})),
|
|
],
|
|
});
|
|
await db.inheritancePoint.createMany({
|
|
data: [
|
|
{ userId: general.userId!, key: 'previous', value: 50 },
|
|
{ userId: general.userId!, key: 'lived_month', value: 10 },
|
|
{ userId: general.userId!, key: 'active_action', value: 4 },
|
|
{ userId: automaticGeneral.userId!, key: 'previous', value: 70 },
|
|
{ userId: automaticGeneral.userId!, key: 'lived_month', value: 10 },
|
|
{ userId: automaticGeneral.userId!, key: 'active_action', value: 4 },
|
|
],
|
|
});
|
|
await db.gameHistory.createMany({
|
|
data: [
|
|
{
|
|
serverId,
|
|
date: new Date('2026-08-24T00:00:00.000Z'),
|
|
season: 4,
|
|
scenario: 22,
|
|
scenarioName: scenarioMeta.title,
|
|
status: 'OPEN',
|
|
},
|
|
{
|
|
serverId: `${serverId}-completed`,
|
|
date: new Date('2026-08-23T00:00:00.000Z'),
|
|
season: 3,
|
|
scenario: 22,
|
|
scenarioName: '완료',
|
|
status: 'COMPLETED',
|
|
},
|
|
{
|
|
serverId: `${serverId}-abandoned`,
|
|
date: new Date('2026-08-22T00:00:00.000Z'),
|
|
season: 3,
|
|
scenario: 22,
|
|
scenarioName: '취소',
|
|
status: 'ABANDONED',
|
|
},
|
|
],
|
|
});
|
|
|
|
const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 3, maxNationTurns: 3 });
|
|
await reservedTurns.loadAll();
|
|
reservedTurns.setGeneralTurn(general.id, 0, { action: 'che_은퇴', args: {} });
|
|
reservedTurns.setGeneralTurn(general.id, 1, { action: 'che_은퇴', args: {} });
|
|
reservedTurns.setGeneralTurn(automaticGeneral.id, 0, { action: 'che_훈련', args: {} });
|
|
const state: TurnWorldState = {
|
|
id: worldId,
|
|
currentYear: 200,
|
|
currentMonth: 1,
|
|
tickSeconds: 600,
|
|
lastTurnTime: new Date('0200-01-01T00:00:00.000Z'),
|
|
meta: worldMeta,
|
|
};
|
|
const nation = {
|
|
id: nationId,
|
|
name: '은퇴국',
|
|
color: '#330000',
|
|
capitalCityId: cityId,
|
|
chiefGeneralId: general.id,
|
|
gold: 10_000,
|
|
rice: 10_000,
|
|
power: 0,
|
|
level: 1,
|
|
typeCode: 'che_중립',
|
|
meta: { gennum: 2, tech: 0 },
|
|
};
|
|
const city = {
|
|
id: cityId,
|
|
name: '은퇴성',
|
|
nationId,
|
|
level: 5,
|
|
state: 0,
|
|
population: 10_000,
|
|
populationMax: 20_000,
|
|
agriculture: 1_000,
|
|
agricultureMax: 2_000,
|
|
commerce: 1_000,
|
|
commerceMax: 2_000,
|
|
security: 1_000,
|
|
securityMax: 2_000,
|
|
supplyState: 1,
|
|
frontState: 0,
|
|
defence: 1_000,
|
|
defenceMax: 2_000,
|
|
wall: 1_000,
|
|
wallMax: 2_000,
|
|
meta: { trust: 50, trade: 100, region: 1 },
|
|
};
|
|
const map = {
|
|
id: 'explicit-retirement',
|
|
name: '명시적 은퇴',
|
|
cities: [],
|
|
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
|
|
};
|
|
let world: InMemoryTurnWorld | null = null;
|
|
const handler = await createReservedTurnHandler({
|
|
reservedTurns,
|
|
scenarioConfig,
|
|
scenarioMeta,
|
|
map,
|
|
getWorld: () => world,
|
|
});
|
|
const snapshot: TurnWorldSnapshot = {
|
|
scenarioConfig,
|
|
scenarioMeta,
|
|
map,
|
|
generals: [general, automaticGeneral],
|
|
nations: [nation],
|
|
cities: [city],
|
|
troops: [],
|
|
diplomacy: [],
|
|
events: [],
|
|
initialEvents: [],
|
|
};
|
|
world = new InMemoryTurnWorld(state, snapshot, {
|
|
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
|
|
generalTurnHandler: handler,
|
|
});
|
|
world.executeGeneralTurn(world.getGeneralById(automaticGeneral.id)!);
|
|
world.executeGeneralTurn(world.getGeneralById(general.id)!);
|
|
world.executeGeneralTurn(world.getGeneralById(general.id)!);
|
|
expect(world.peekDirtyState().lifecycleEvents.some((entry) => entry.outcome === 'retired')).toBe(true);
|
|
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
|
|
userId: general.userId,
|
|
key: 'previous',
|
|
amount: 3_000,
|
|
phase: 'after_lifecycle',
|
|
});
|
|
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
|
|
userId: automaticGeneral.userId,
|
|
key: 'previous',
|
|
amount: 3_000,
|
|
});
|
|
|
|
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
|
|
try {
|
|
await hooks.hooks.flushChanges?.({
|
|
lastTurnTime: state.lastTurnTime.toISOString(),
|
|
processedGenerals: 2,
|
|
processedTurns: 3,
|
|
durationMs: 0,
|
|
partial: false,
|
|
});
|
|
} finally {
|
|
await hooks.close();
|
|
}
|
|
|
|
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
|
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === general.id)).toMatchObject({
|
|
age: 20,
|
|
experience: 501,
|
|
dedication: 401,
|
|
meta: {
|
|
rank_warnum: 0,
|
|
firenum: 0,
|
|
inherit_earned: 0,
|
|
inherit_lived_month: 0,
|
|
inherit_active_action: 0,
|
|
inherit_spent_dyn: -3_000,
|
|
dex1: 100,
|
|
},
|
|
});
|
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === general.id)?.meta).not.toHaveProperty(
|
|
'inheritRandomUnique'
|
|
);
|
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === automaticGeneral.id)).toMatchObject({
|
|
age: 20,
|
|
meta: {
|
|
rank_warnum: 0,
|
|
inherit_lived_month: 0,
|
|
inherit_active_action: 0,
|
|
inherit_spent_dyn: 0,
|
|
},
|
|
});
|
|
expect(reloaded.snapshot.generals.find((entry) => entry.id === automaticGeneral.id)?.meta).not.toHaveProperty(
|
|
'inheritRandomUnique'
|
|
);
|
|
await expect(
|
|
db.hallOfFame.findUniqueOrThrow({
|
|
where: { serverId_type_generalNo: { serverId, type: 'warnum', generalNo: general.id } },
|
|
})
|
|
).resolves.toMatchObject({ value: 11, aux: expect.objectContaining({ serverIdx: 7 }) });
|
|
await expect(
|
|
db.hallOfFame.findUniqueOrThrow({
|
|
where: { serverId_type_generalNo: { serverId, type: 'firenum', generalNo: general.id } },
|
|
})
|
|
).resolves.toMatchObject({ value: 9 });
|
|
await expect(
|
|
db.hallOfFame.findUniqueOrThrow({
|
|
where: { serverId_type_generalNo: { serverId, type: 'inherit_earned', generalNo: general.id } },
|
|
})
|
|
).resolves.toMatchObject({ value: 4_321 });
|
|
await expect(
|
|
db.hallOfFame.findUniqueOrThrow({
|
|
where: { serverId_type_generalNo: { serverId, type: 'dex1', generalNo: general.id } },
|
|
})
|
|
).resolves.toMatchObject({ value: 120 });
|
|
const result = await db.inheritanceResult.findFirstOrThrow({
|
|
where: { serverId, owner: general.userId! },
|
|
orderBy: { id: 'desc' },
|
|
});
|
|
expect(result.value).toMatchObject({ combat: 55, sabotage: 180, dex: 0.06, rebirth: true });
|
|
const inheritanceLogs = await db.inheritanceLog.findMany({
|
|
where: { userId: general.userId! },
|
|
orderBy: { id: 'asc' },
|
|
select: { text: true },
|
|
});
|
|
const settlementLogIndex = inheritanceLogs.findIndex(({ text }) => text.startsWith('포인트 '));
|
|
const refundLogIndex = inheritanceLogs.findIndex(
|
|
({ text }) => text === '유니크를 얻을 공간이 없어 3000 포인트 반환'
|
|
);
|
|
expect(settlementLogIndex).toBeGreaterThanOrEqual(0);
|
|
expect(refundLogIndex).toBeGreaterThan(settlementLogIndex);
|
|
const persistedPrevious = await db.inheritancePoint.findUniqueOrThrow({
|
|
where: { userId_key: { userId: general.userId!, key: 'previous' } },
|
|
});
|
|
expect(persistedPrevious.value).toBeGreaterThan(3_000);
|
|
const automaticInheritanceLogs = await db.inheritanceLog.findMany({
|
|
where: { userId: automaticGeneral.userId! },
|
|
orderBy: { id: 'asc' },
|
|
select: { text: true },
|
|
});
|
|
const automaticRefundLogIndex = automaticInheritanceLogs.findIndex(
|
|
({ text }) => text === '유니크를 얻을 공간이 없어 3000 포인트 반환'
|
|
);
|
|
const automaticSettlementLogIndex = automaticInheritanceLogs.findIndex(({ text }) =>
|
|
text.startsWith('포인트 ')
|
|
);
|
|
expect(automaticRefundLogIndex).toBeGreaterThanOrEqual(0);
|
|
expect(automaticSettlementLogIndex).toBeGreaterThan(automaticRefundLogIndex);
|
|
await expect(db.oldGeneral.count({ where: { serverId, generalNo: general.id } })).resolves.toBe(0);
|
|
await expect(db.oldGeneral.count({ where: { serverId, generalNo: automaticGeneral.id } })).resolves.toBe(0);
|
|
});
|
|
});
|