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