fix: 은퇴 명예 기록과 유산 수명주기 정합성을 복원한다

은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다.

유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
This commit is contained in:
2026-08-24 03:38:12 +00:00
parent 95cf68dffd
commit 4fc20de8d4
33 changed files with 4160 additions and 1166 deletions
@@ -399,4 +399,153 @@ describe('legacy general turn lifecycle', () => {
}
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired');
});
it('emits an explicit retirement lifecycle event with the pre-rebirth snapshot', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([
makeGeneral({
age: 65,
experience: 1_001,
dedication: 801,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: {
horse: 'che_명마_07_백마',
weapon: 'che_무기_07_동추',
book: 'che_서적_07_위료자',
item: 'che_의술_정력견혈산',
},
},
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: 101,
},
inheritancePoints: { previous: 50 },
}),
]),
state: makeState(),
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_은퇴', args: {} };
harness.reservedTurnStore.getGeneralTurns(1)[1] = { action: 'che_은퇴', args: {} };
await harness.runOneTick();
await harness.runOneTick();
const current = harness.world.getGeneralById(1)!;
const lifecycle = harness.world.peekDirtyState().lifecycleEvents.find((event) => event.outcome === 'retired');
expect(lifecycle).toMatchObject({
outcome: 'retired',
isUnitedAtEvent: 0,
before: {
age: 65,
experience: 1_001,
dedication: 801,
meta: {
rank_warnum: 11,
firenum: 9,
inherit_earned: 4_321,
inherit_lived_month: 12,
inherit_active_action: 4,
inheritRandomUnique: 1,
inherit_spent_dyn: 3_000,
dex1: 101,
},
},
after: {
age: 20,
meta: { inherit_lived_month: 0, inherit_active_action: 0 },
},
});
expect(current).toMatchObject({
age: 20,
experience: 501,
dedication: 401,
inheritancePoints: { previous: 3_050 },
meta: {
rank_warnum: 0,
firenum: 0,
inherit_earned: 0,
inherit_lived_month: 0,
inherit_active_action: 0,
inherit_spent_dyn: -3_000,
dex1: 51,
},
});
expect(current.meta).not.toHaveProperty('inheritRandomUnique');
expect(harness.world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: 'user-1',
key: 'previous',
amount: 3_000,
phase: 'after_lifecycle',
});
expect(harness.world.peekDirtyState().pendingInheritanceLogs).toContainEqual({
userId: 'user-1',
year: 200,
month: 2,
text: '유니크를 얻을 공간이 없어 3000 포인트 반환',
phase: 'after_lifecycle',
});
});
it('refunds a failed pending lottery before automatic retirement and resets the spent rank to zero', async () => {
const harness = await createTurnTestHarness({
snapshot: makeSnapshot([
makeGeneral({
age: 80,
crew: 100,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: {
horse: 'che_명마_07_백마',
weapon: 'che_무기_07_동추',
book: 'che_서적_07_위료자',
item: 'che_의술_정력견혈산',
},
},
meta: {
killturn: 24,
inheritRandomUnique: true,
inherit_spent_dyn: 3_000,
inherit_lived_month: 10,
},
inheritancePoints: { previous: 70 },
}),
]),
state: makeState(),
schedule,
map,
});
harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_훈련', args: {} };
await harness.runOneTick();
const current = harness.world.getGeneralById(1)!;
expect(current).toMatchObject({
age: 20,
inheritancePoints: { previous: 3_070 },
meta: { inherit_spent_dyn: 0, inherit_lived_month: 0 },
});
expect(current.meta).not.toHaveProperty('inheritRandomUnique');
expect(harness.world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: 'user-1',
key: 'previous',
amount: 3_000,
});
const lifecycle = harness.world.peekDirtyState().lifecycleEvents.find((event) => event.outcome === 'retired');
expect(lifecycle?.before.meta).toMatchObject({ inherit_spent_dyn: 0 });
expect(lifecycle?.before.meta).not.toHaveProperty('inheritRandomUnique');
});
});
@@ -1,17 +1,41 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { asRecord, normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
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 type { TurnGeneral } from '../src/turn/types.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];
const userIds = ['integration-lifecycle-dead', 'integration-lifecycle-retired', 'integration-lifecycle-possessed'];
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,
@@ -69,12 +93,30 @@ integration('general turn lifecycle persistence', () => {
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.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
await db.hallOfFame.deleteMany({ where: { serverId, generalNo: { in: generalIds } } });
await db.inheritanceResult.deleteMany({ where: { serverId, owner: { in: userIds } } });
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 () => {
@@ -189,8 +231,7 @@ integration('general turn lifecycle persistence', () => {
});
const archivedData = asRecord(archived.data);
expect(archivedData.history).toEqual(['<Y>●</>둘째 기록', '<C>●</>첫 기록']);
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique');
expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritSpecificSpecialWar');
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 },
@@ -220,7 +261,19 @@ integration('general turn lifecycle persistence', () => {
select: { text: true },
})
).map(({ text }) => text)
).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,622 포인트']);
).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 () => {
@@ -255,10 +308,15 @@ integration('general turn lifecycle persistence', () => {
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,
[event(general, 'retired')],
[retirementEvent],
{ serverId, season: 1, scenarioId: 2, isUnited: 0 },
{}
)
@@ -311,6 +369,533 @@ integration('general turn lifecycle persistence', () => {
]);
});
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,
@@ -340,4 +925,429 @@ integration('general turn lifecycle persistence', () => {
})
).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);
});
});
@@ -97,7 +97,14 @@ describe('general lifecycle archive history', () => {
history: ['<Y>●</>둘째 기록', '<C>●</>첫 기록'],
records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] },
availability: { battleResultLogs: true },
meta: { killturn: 0, dex1: 1_000, rank_warnum: 2, rank_killnum: 1 },
meta: expect.objectContaining({
killturn: 0,
dex1: 1_000,
rank_warnum: 2,
rank_killnum: 1,
inheritRandomUnique: true,
inheritSpecificSpecialWar: true,
}),
}),
}),
})
@@ -106,32 +113,60 @@ describe('general lifecycle archive history', () => {
it('stores the inheritance earned rank in the hall before a rebirth resets ranks', async () => {
const general = archivedGeneral();
const hallCreateMany = vi.fn(async () => ({ count: 1 }));
const hallCreate = vi.fn(async () => undefined);
general.userId = 'hall-owner';
general.meta = {
...general.meta,
rank_warnum: 11,
inherit_earned: 4_321,
dex1: 200,
event100_allstar: { granted: { dex1: 80 } },
};
const postRetirement = {
...general,
meta: {
...general.meta,
rank_warnum: 0,
inherit_earned: 0,
},
};
const rankUpsert = vi.fn(async () => undefined);
const prisma = {
generalAccessLog: {
updateMany: vi.fn(async () => ({ count: 1 })),
},
rankData: {
findMany: vi.fn(async () => [{ type: 'inherit_earned', value: 4_321 }]),
updateMany: vi.fn(async () => ({ count: 1 })),
findMany: vi.fn(async () => [
{ type: 'warnum', value: 10 },
{ type: 'inherit_earned', value: 123 },
]),
upsert: rankUpsert,
},
nation: {
findUnique: vi.fn(async () => null),
},
gameHistory: {
count: vi.fn(async () => 2),
count: vi.fn(async () => 99),
},
hallOfFame: {
findUnique: vi.fn(async () => null),
createMany: hallCreateMany,
findMany: vi.fn(async () => []),
create: hallCreate,
update: vi.fn(async () => undefined),
},
inheritancePoint: {
findMany: vi.fn(async () => [{ key: 'previous', value: 0 }]),
upsert: vi.fn(async () => undefined),
deleteMany: vi.fn(async () => ({ count: 0 })),
},
inheritanceResult: { create: vi.fn(async () => undefined) },
inheritanceLog: { create: vi.fn(async () => undefined) },
} as unknown as GamePrisma.TransactionClient;
const event: GeneralLifecycleEvent = {
generalId: general.id,
outcome: 'retired',
before: general,
after: general,
after: postRetirement,
isUnitedAtEvent: 0,
year: 200,
month: 1,
};
@@ -139,26 +174,39 @@ describe('general lifecycle archive history', () => {
await persistGeneralLifecycleEvents(
prisma,
[event],
{ serverId: 'hall-fixture', season: 4, scenarioId: 22, isUnited: 0 },
{}
{ serverId: 'hall-fixture', season: 4, scenarioId: 22, isUnited: 2, gameIdx: 7 },
{},
new Date('0200-02-01T00:00:00.000Z')
);
expect(hallCreateMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
serverId: 'hall-fixture',
season: 4,
scenario: 22,
generalNo: general.id,
type: 'inherit_earned',
value: 4_321,
}),
],
skipDuplicates: true,
expect(hallCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
serverId: 'hall-fixture',
season: 4,
scenario: 22,
generalNo: general.id,
type: 'inherit_earned',
value: 4_321,
}),
});
expect(prisma.rankData.updateMany).toHaveBeenCalledWith({
where: { generalId: general.id },
data: { value: 0 },
expect(hallCreate).toHaveBeenCalledWith({
data: expect.objectContaining({ type: 'warnum', value: 11 }),
});
expect(hallCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
type: 'dex1',
value: 120,
aux: expect.objectContaining({ serverIdx: 7, unitedTime: '0200-02-01T00:00:00.000Z' }),
}),
});
expect(prisma.gameHistory.count).not.toHaveBeenCalled();
expect(prisma.inheritanceLog.create).not.toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ text: expect.stringContaining('반환') }) })
);
expect(rankUpsert).toHaveBeenCalledWith({
where: { generalId_type: { generalId: general.id, type: 'warnum' } },
update: { nationId: general.nationId, value: 0 },
create: { generalId: general.id, nationId: general.nationId, type: 'warnum', value: 0 },
});
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest';
import type { GamePrisma } from '@sammo-ts/infra';
import { persistHallOfFameCandidate, resolveOfficialGameIndex } from '../src/turn/hallOfFamePersistence.js';
const candidate = {
serverId: 'hall-server',
season: 3,
scenario: 22,
generalNo: 20,
type: 'experience' as const,
value: 2_000,
owner: 'same-owner',
aux: { name: '새장수' },
};
describe('Hall of Fame persistence policy', () => {
it('preserves an owner record belonging to another general for both higher and lower new values', async () => {
const update = vi.fn(async () => undefined);
const prisma = {
hallOfFame: {
findMany: vi.fn(async () => [
{
id: 1,
serverId: candidate.serverId,
season: 3,
scenario: 22,
generalNo: 10,
type: candidate.type,
value: 1_000,
owner: candidate.owner,
aux: { name: '기존장수' },
},
]),
create: vi.fn(async () => undefined),
update,
},
} as unknown as GamePrisma.TransactionClient;
await expect(persistHallOfFameCandidate(prisma, candidate)).resolves.toBe('PRESERVED');
await expect(persistHallOfFameCandidate(prisma, { ...candidate, value: 500 })).resolves.toBe('PRESERVED');
expect(update).not.toHaveBeenCalled();
expect(prisma.hallOfFame.create).not.toHaveBeenCalled();
});
it('updates only value and aux for a higher same-general record, and preserves a lower value', async () => {
const existing = {
id: 2,
serverId: candidate.serverId,
season: 3,
scenario: 22,
generalNo: candidate.generalNo,
type: candidate.type,
value: 1_500,
owner: 'old-owner',
aux: { name: '기존장수' },
};
const update = vi.fn(async () => undefined);
const prisma = {
hallOfFame: {
findMany: vi.fn(async () => [existing]),
create: vi.fn(async () => undefined),
update,
},
} as unknown as GamePrisma.TransactionClient;
await expect(persistHallOfFameCandidate(prisma, candidate)).resolves.toBe('UPDATED');
expect(update).toHaveBeenCalledWith({
where: { id: existing.id },
data: { value: candidate.value, aux: candidate.aux },
});
update.mockClear();
await expect(persistHallOfFameCandidate(prisma, { ...candidate, value: 1_000 })).resolves.toBe('PRESERVED');
expect(update).not.toHaveBeenCalled();
});
it('uses persisted gameIdx and reconstructs fallback from COMPLETED games only', async () => {
const count = vi.fn(async () => 4);
const prisma = { gameHistory: { count } } as unknown as GamePrisma.TransactionClient;
await expect(resolveOfficialGameIndex(prisma, { gameIdx: 0 })).resolves.toBe(0);
expect(count).not.toHaveBeenCalled();
await expect(resolveOfficialGameIndex(prisma, { firstGameIdx: 0 })).resolves.toBe(4);
expect(count).toHaveBeenCalledWith({ where: { status: 'COMPLETED' } });
});
});
@@ -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);
});
@@ -0,0 +1,357 @@
import { describe, expect, it, vi } from 'vitest';
import { LiteHashDRBG, RandUtil, type TurnDaemonCommand } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import {
buildResetStatRandomBonus,
executeInheritanceAction,
resolveOwnerDisplayName,
} from '../src/turn/inheritanceActionService.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
type InheritanceCommand = Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>;
const buildGeneral = (overrides: Partial<TurnGeneral> = {}): TurnGeneral => ({
id: 1,
userId: 'user-1',
name: '유비',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 70, strength: 45, intelligence: 85 },
experience: 0,
dedication: 0,
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: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, inherit_spent_dyn: 17 },
inheritancePoints: { previous: 10_000 },
turnTime: new Date('0200-04-01T00:00:00.000Z'),
...overrides,
});
const buildWorld = (options: {
general?: TurnGeneral;
target?: TurnGeneral;
worldMeta?: Record<string, unknown>;
configConst?: Record<string, unknown>;
configMap?: Record<string, unknown>;
}) => {
const state: TurnWorldState = {
id: 1,
currentYear: 200,
currentMonth: 4,
tickSeconds: 3_600,
lastTurnTime: new Date('0200-04-01T00:00:00.000Z'),
meta: { hiddenSeed: 'test-seed', season: 7, isunited: 0, ...(options.worldMeta ?? {}) },
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '.',
map: options.configMap ?? {},
const: {
availableSpecialWar: ['che_의술'],
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,
...(options.configConst ?? {}),
},
environment: { mapName: 'test', unitSet: 'default' },
},
map: { id: 'test', name: 'test', cities: [] },
generals: [options.general ?? buildGeneral(), ...(options.target ? [options.target] : [])],
cities: [],
nations: [
{
id: 1,
name: '촉',
color: '#ff0000',
capitalCityId: null,
chiefGeneralId: 1,
gold: 0,
rice: 0,
power: 0,
level: 1,
typeCode: 'che_def',
meta: {},
},
],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
return new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
};
const buildDatabase = (options: { point?: number; resetSeasons?: number[] } = {}) => {
const createLog = vi.fn(async () => ({}));
const findUserState = vi.fn(async () =>
options.resetSeasons ? { meta: { last_stat_reset: options.resetSeasons } } : null
);
const upsertUserState = vi.fn(async () => ({}));
const queryRaw = vi.fn(async () => [{ value: options.point ?? 10_000 }]);
return {
db: {
$queryRaw: queryRaw,
inheritanceLog: { create: createLog },
inheritanceUserState: { findUnique: findUserState, upsert: upsertUserState },
} as unknown as GamePrisma.TransactionClient,
createLog,
findUserState,
upsertUserState,
queryRaw,
};
};
const execute = async (
world: InMemoryTurnWorld,
db: GamePrisma.TransactionClient,
input: InheritanceCommand['input']
) =>
executeInheritanceAction({
db,
world,
command: { type: 'inheritanceAction', userId: 'user-1', input },
gameNow: new Date('0200-04-01T00:00:00.000Z'),
});
describe('inheritance action service', () => {
it.each([
{
name: 'hidden buff',
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const,
cost: 200,
general: buildGeneral(),
},
{
name: 'specific special',
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const,
cost: 4_000,
general: buildGeneral(),
},
{
name: 'special reset',
input: { action: 'resetSpecialWar' } as const,
cost: 1_000,
general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_선봉' } }),
},
{
name: 'turn-time reset',
input: { action: 'resetTurnTime' } as const,
cost: 1_000,
general: buildGeneral(),
},
{
name: 'paid stat reset',
input: {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1] as [number, number, number],
} as const,
cost: 1_000,
general: buildGeneral(),
},
{
name: 'random unique reservation',
input: { action: 'buyRandomUnique' } as const,
cost: 3_000,
general: buildGeneral(),
},
{
name: 'owner lookup',
input: { action: 'checkOwner', targetGeneralId: 2 } as const,
cost: 1_000,
general: buildGeneral(),
},
])('charges $name in runtime rank, points, and the same dirty flush', async ({ input, cost, general }) => {
const target =
input.action === 'checkOwner'
? buildGeneral({
id: 2,
userId: 'user-2',
name: '조조',
meta: { killturn: 24, owner_name: '위유저' },
})
: undefined;
const world = buildWorld({ general, target });
const { db, createLog } = buildDatabase();
await expect(execute(world, db, input)).resolves.toMatchObject({ ok: true, remainPoint: 10_000 - cost });
expect(world.getGeneralById(1)).toMatchObject({
meta: { inherit_spent_dyn: 17 + cost },
inheritancePoints: { previous: 10_000 - cost },
});
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual(
cost === 0 ? [] : [{ userId: 'user-1', key: 'previous', amount: -cost }]
);
expect(createLog).toHaveBeenCalled();
});
it('keeps free ResetStat at zero spend and uses the Ref-compatible fixed-seed bonus', async () => {
const world = buildWorld({});
const { db } = buildDatabase({ point: 0 });
const result = await execute(world, db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [0, 0, 0],
});
expect(result).toMatchObject({ ok: true, remainPoint: 0 });
expect(world.getGeneralById(1)?.meta.inherit_spent_dyn).toBe(17);
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([]);
expect(result.ok && result.stats).toEqual({ leadership: 73, strength: 45, intel: 87 });
});
it('matches the Ref ResetStat DRBG seed, inclusive 3..5 count, and weighted choices', () => {
const bonus = buildResetStatRandomBonus(
new RandUtil(new LiteHashDRBG(simpleSerialize('test-seed', 'ResetStat', 'user-1'))),
[70, 45, 85]
);
expect(bonus).toEqual([3, 0, 2]);
expect(bonus.reduce((sum, value) => sum + value, 0)).toBeGreaterThanOrEqual(3);
expect(bonus.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(5);
});
it.each([1, 2])('rejects npcState=%s ResetStat exactly like Ref npc != 0', async (npcState) => {
const world = buildWorld({ general: buildGeneral({ npcState }) });
const { db, queryRaw } = buildDatabase();
await expect(
execute(world, db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: 'NPC는 능력치 초기화를 할 수 없습니다.' });
expect(queryRaw).not.toHaveBeenCalled();
});
it.each([
{
name: 'purchased buff before unification',
general: buildGeneral({ meta: { killturn: 24, inheritBuff: JSON.stringify({ warAvoidRatio: 1 }) } }),
input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const,
reason: '이미 구입했습니다.',
},
{
name: 'owned special before unification',
general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_의술' } }),
input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const,
reason: '이미 그 특기를 보유하고 있습니다.',
},
{
name: 'blank special before unification',
general: buildGeneral(),
input: { action: 'resetSpecialWar' } as const,
reason: '이미 전투 특기가 공란입니다.',
},
{
name: 'random reservation before unification',
general: buildGeneral({ meta: { killturn: 24, inheritRandomUnique: true } }),
input: { action: 'buyRandomUnique' } as const,
reason: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.',
},
])('preserves Ref combined-invalid precedence: $name', async ({ general, input, reason }) => {
const world = buildWorld({ general, worldMeta: { isunited: 1 } });
const { db, queryRaw } = buildDatabase();
await expect(execute(world, db, input)).resolves.toMatchObject({ ok: false, reason });
expect(queryRaw).not.toHaveBeenCalled();
});
it('checks ResetStat shape, npc/S100, unification, season duplicate, then points', async () => {
const npcWorld = buildWorld({ general: buildGeneral({ npcState: 1 }), worldMeta: { isunited: 1 } });
const npcDb = buildDatabase({ point: 0 });
await expect(
execute(npcWorld, npcDb.db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 84,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: '능력치 총합이 200이 아닙니다. 다시 입력해주세요!' });
const seasonWorld = buildWorld({});
const seasonDb = buildDatabase({ point: 0, resetSeasons: [7] });
await expect(
execute(seasonWorld, seasonDb.db, {
action: 'resetStat',
leadership: 70,
strength: 45,
intel: 85,
inheritBonusStat: [2, 1, 1],
})
).resolves.toMatchObject({ ok: false, reason: '이번 시즌에 이미 능력치를 초기화하셨습니다.' });
expect(seasonDb.queryRaw).not.toHaveBeenCalled();
});
it('uses both unification keys and preserves owner display-name compatibility order', async () => {
const world = buildWorld({ worldMeta: { isUnited: 1, isunited: 0 } });
const { db } = buildDatabase();
await expect(execute(world, db, { action: 'resetTurnTime' })).resolves.toMatchObject({
ok: false,
reason: '이미 천하가 통일되었습니다.',
});
expect(resolveOwnerDisplayName({ ownerDisplayName: '현재', owner_name: '레거시', ownerName: '호환' })).toBe(
'현재'
);
expect(resolveOwnerDisplayName({ owner_name: '레거시', ownerName: '호환' })).toBe('레거시');
expect(resolveOwnerDisplayName({ ownerName: '호환' })).toBe('호환');
expect(resolveOwnerDisplayName({})).toBe('알수없음');
});
it('queues CheckOwner messages in Ref requester-then-target order', async () => {
const world = buildWorld({
target: buildGeneral({
id: 2,
userId: 'user-2',
name: '조조',
meta: { killturn: 24, owner_name: '위유저' },
}),
});
const { db } = buildDatabase();
await expect(execute(world, db, { action: 'checkOwner', targetGeneralId: 2 })).resolves.toMatchObject({
ok: true,
ownerName: '위유저',
});
expect(world.peekDirtyState().messages.map((message) => [message.dest.generalId, message.text])).toEqual([
[1, '조조의 소유자는 위유저 입니다.'],
[2, '소유자명이 누군가에 의해 확인되었습니다.'],
]);
});
});
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { buildInheritanceSettlementLogTexts } from '../src/turn/inheritanceSettlementLogs.js';
describe('legacy inheritance settlement logs', () => {
it('logs calculated keys plus only direct stored keys that are present, in Ref order', () => {
expect(
buildInheritanceSettlementLogTexts({
previous: 100,
points: {
lived_month: 12,
max_belong: 90,
max_domestic_critical: 80,
active_action: 3,
combat: 15,
sabotage: 40,
unifier: 250,
dex: 1.25,
tournament: 50,
betting: 5,
},
storedKeys: new Set(['previous', 'lived_month', 'unifier']),
total: 521,
isRebirth: false,
})
).toEqual([
'기존 보유 포인트 100 증가',
'생존 포인트 12 증가',
'최대 임관년 수 포인트 90 증가',
'전투 횟수 포인트 15 증가',
'계략 성공 횟수 포인트 40 증가',
'천통 기여 포인트 250 증가',
'숙련도 포인트 1.25 증가',
'베팅 당첨 포인트 5 증가',
'포인트 100 => 521',
]);
});
it('skips delayed rebirth keys and logs coefficient-adjusted values', () => {
expect(
buildInheritanceSettlementLogTexts({
previous: 50,
points: {
lived_month: 12,
max_belong: 0,
max_domestic_critical: 0,
active_action: 3,
combat: 15,
sabotage: 40,
unifier: 0,
dex: 0.5,
tournament: 7,
betting: 5,
},
storedKeys: new Set([
'previous',
'lived_month',
'max_domestic_critical',
'active_action',
'unifier',
'tournament',
]),
total: 132,
isRebirth: true,
})
).toEqual([
'기존 보유 포인트 50 증가',
'생존 포인트 12 증가',
'능동 행동 수 포인트 3 증가',
'전투 횟수 포인트 15 증가',
'계략 성공 횟수 포인트 40 증가',
'숙련도 포인트 0.5 증가',
'토너먼트 포인트 7 증가',
'베팅 당첨 포인트 5 증가',
'포인트 50 => 132',
]);
});
});
@@ -214,7 +214,7 @@ describe('UpdateNationLevel monthly action', () => {
expect(world.getGeneralById(1)?.role.items.horse).toBe(uniqueHorse.key);
expect(world.getGeneralById(2)?.role.items.horse).toBeNull();
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 500 },
{ userId: 'user-1', key: 'unifier', amount: 500, phase: 'after_lifecycle' },
]);
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(500);
expect(world.peekDirtyState().logs).toEqual(
@@ -291,7 +291,7 @@ describe('UpdateNationLevel monthly action', () => {
meta: { marker: 1, can_국기변경: 1, can_국호변경: 1 },
});
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 250 },
{ userId: 'user-1', key: 'unifier', amount: 250, phase: 'after_lifecycle' },
]);
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
});
@@ -306,7 +306,7 @@ describe('UpdateNationLevel monthly action', () => {
expect(world.getGeneralById(1)?.role.items.horse).toBeNull();
expect(world.peekDirtyState().logs.some((entry) => entry.text.includes('작위보상'))).toBe(false);
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 250 },
{ userId: 'user-1', key: 'unifier', amount: 250, phase: 'after_lifecycle' },
]);
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
});
@@ -84,6 +84,7 @@ describe('durable read-model change journal mapping', () => {
lifecycleEvents: [],
pendingNeutralAuctions: [],
inheritancePointAdjustments: [],
pendingInheritanceLogs: [],
pendingNationBettingOpens: [],
pendingNationBettingFinishes: [],
pendingYearbookSnapshots: [],
@@ -35,6 +35,7 @@ const buildWorld = (): InMemoryTurnWorld => {
rank_warnum: 4,
firenum: 2,
dex1: 100,
event100_allstar: { granted: { dex1: 40 } },
},
officerLevel: 12,
experience: 10,
@@ -141,18 +142,12 @@ const input = {
describe('persistUnificationFinalization', () => {
it.each([
{ label: 'missing row', rows: [], memoryValue: 2_000, expected: 0 },
{ label: 'zero row', rows: [['unifier', 0] as const], memoryValue: 2_000, expected: 0 },
{ label: 'positive row', rows: [['unifier', 7] as const], memoryValue: 2_007, expected: 7 },
])('resolves the pre-award unifier value for $label', ({ rows, memoryValue, expected }) => {
expect(
resolveStoredInheritancePoint(
new Map<string, number>(rows),
{ inheritancePoints: { unifier: memoryValue } },
'unifier',
2_000
)
).toBe(expected);
{ label: 'missing unifier row', rows: [], key: 'unifier' as const, expected: 0 },
{ label: 'missing resettable row', rows: [], key: 'tournament' as const, expected: 0 },
{ label: 'zero row', rows: [['unifier', 0] as const], key: 'unifier' as const, expected: 0 },
{ label: 'positive row', rows: [['unifier', 7] as const], key: 'unifier' as const, expected: 7 },
])('uses only transaction-visible inheritance storage for $label', ({ rows, key, expected }) => {
expect(resolveStoredInheritancePoint(new Map<string, number>(rows), key)).toBe(expected);
});
it('does not write when the transaction-scoped generation was already applied', async () => {
@@ -222,7 +217,7 @@ describe('persistUnificationFinalization', () => {
},
gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate },
hallOfFame: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
create: hallCreate,
update: vi.fn().mockResolvedValue({}),
},
@@ -274,6 +269,11 @@ describe('persistUnificationFinalization', () => {
data: expect.objectContaining({ type: 'inherit_earned', value: 4_321 }),
})
);
expect(hallCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ type: 'dex1', value: 60 }),
})
);
expect(gameHistoryUpdate).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) })
);
@@ -186,9 +186,13 @@ describe('unique lottery on general commands', () => {
expect(dedicationIndex).toBeLessThan(uniqueIndex);
});
it('does not award a unique item reserved by an active auction', async () => {
it('refunds a pending inheritance purchase when active auctions exhaust the supply', async () => {
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const generals = [buildGeneral(1)];
const lotteryGeneral = buildGeneral(1);
lotteryGeneral.userId = 'inherit-user';
lotteryGeneral.meta = { killturn: 24, inheritRandomUnique: true, inherit_spent_dyn: 3_000 };
lotteryGeneral.inheritancePoints = { previous: 200 };
const generals = [lotteryGeneral];
const snapshot: TurnWorldSnapshot = {
generals: generals as any,
cities: [
@@ -267,6 +271,7 @@ describe('unique lottery on general commands', () => {
uniqueTrialCoef: 10,
maxUniqueTrialProb: 10,
minMonthToAllowInheritItem: 0,
inheritItemRandomPoint: 3_000,
},
environment: { mapName: 'test_map', unitSet: 'default' },
},
@@ -317,6 +322,22 @@ describe('unique lottery on general commands', () => {
});
expect(result.general?.role.items.weapon).toBeNull();
expect(result.general?.meta).toMatchObject({ inherit_spent_dyn: 0 });
expect(result.general?.meta).not.toHaveProperty('inheritRandomUnique');
expect(result.general?.inheritancePoints?.previous).toBe(3_200);
expect((result.logs ?? []).some((entry) => entry.text.includes('【아이템】'))).toBe(false);
expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({
userId: 'inherit-user',
key: 'previous',
amount: 3_000,
});
expect(world.peekDirtyState().pendingInheritanceLogs).toEqual([
{
userId: 'inherit-user',
year: 180,
month: 1,
text: '얻을 유니크가 없어 3000 포인트 반환',
},
]);
});
});