diff --git a/app/game-engine/src/turn/monthlyUniqueInheritAction.ts b/app/game-engine/src/turn/monthlyUniqueInheritAction.ts new file mode 100644 index 0000000..b22292b --- /dev/null +++ b/app/game-engine/src/turn/monthlyUniqueInheritAction.ts @@ -0,0 +1,254 @@ +import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { + LogCategory, + LogFormat, + LogScope, + cloneItemInventory, + createItemModuleRegistry, + ensureItemInventory, + removeEquippedItem, + type ItemModule, + type ItemSlot, +} from '@sammo-ts/logic'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; +import type { TurnGeneral } from './types.js'; + +const LEGACY_ITEM_SLOTS: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item']; +const DEX_LIMIT = 1_275_975; +const STORED_INHERITANCE_KEYS = [ + 'lived_month', + 'max_domestic_critical', + 'active_action', + 'unifier', + 'tournament', +] as const; +const ALL_MERGED_INHERITANCE_KEYS = [ + ...STORED_INHERITANCE_KEYS, + 'max_belong', + 'combat', + 'sabotage', + 'dex', + 'betting', +] as const; + +const readNumber = (source: Record, key: string): number => { + const value = source[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return 0; +}; + +const resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => { + const state = world.getState(); + const value = state.meta.hiddenSeed ?? state.meta.seed ?? state.id; + return typeof value === 'string' || typeof value === 'number' ? value : String(value); +}; + +const readLostProbability = (value: unknown): number => { + if (value === undefined) { + return 0.1; + } + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error('LostUniqueItem probability must be a finite number.'); + } + return value; +}; + +export const createLostUniqueItemHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + itemModules: ItemModule[]; +}): MonthlyEventActionHandler => { + const itemRegistry = createItemModuleRegistry(options.itemModules); + return (args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const probability = readLostProbability(args[0]); + const rng = new RandUtil( + new LiteHashDRBG( + simpleSerialize(resolveHiddenSeed(world), 'LostUniqueItem', environment.year, environment.month) + ) + ); + let totalLostCount = 0; + let maximumLostCount = 0; + let maximumLostGeneralNames: string[] = []; + + // ref SELECT와 createObjListFromDB 모두 ORDER BY가 없으므로 loader 순서를 + // 유지한다. 아이템 객체 생성 순서는 horse, weapon, book, item이다. + for (const general of world.listGenerals().filter((entry) => entry.npcState <= 1)) { + const nextGeneral: TurnGeneral = { + ...general, + role: { ...general.role, items: { ...general.role.items } }, + itemInventory: cloneItemInventory(ensureItemInventory(general)), + }; + let lostCount = 0; + for (const slot of LEGACY_ITEM_SLOTS) { + const itemKey = nextGeneral.role.items[slot]; + if (!itemKey) { + continue; + } + const item = itemRegistry.get(itemKey); + if (!item) { + throw new Error(`Unknown equipped item: ${itemKey} (generalId=${general.id}).`); + } + if (item.buyable || !rng.nextBool(probability)) { + continue; + } + removeEquippedItem(nextGeneral, slot); + lostCount += 1; + totalLostCount += 1; + const josaUl = JosaUtil.pick(item.rawName, '을'); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: general.id, + text: `${item.name}${josaUl} 잃었습니다.`, + format: LogFormat.PLAIN, + year: environment.year, + month: environment.month, + }); + } + if (lostCount === 0) { + continue; + } + world.updateGeneral(general.id, { + role: nextGeneral.role, + itemInventory: nextGeneral.itemInventory, + }); + if (lostCount > maximumLostCount) { + maximumLostCount = lostCount; + maximumLostGeneralNames = [general.name]; + } else if (lostCount === maximumLostCount) { + maximumLostGeneralNames.push(general.name); + } + } + + if (totalLostCount === 0) { + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '【망실】어떤 아이템도 잃지 않았습니다!', + format: LogFormat.YEAR_MONTH, + year: environment.year, + month: environment.month, + }); + return; + } + const totalMaximumGenerals = maximumLostGeneralNames.length; + const displayedNames = maximumLostGeneralNames.slice(0, 4); + let nameList = displayedNames.join(', '); + if (totalMaximumGenerals > 4) { + nameList += ` 외 ${totalMaximumGenerals - 4}명`; + } + const josaYi = JosaUtil.pick(nameList, '이'); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: `【망실】불운하게도 ${nameList}${josaYi} 한 번에 유니크 ${maximumLostCount}종을 잃었습니다! (총 ${totalLostCount}개)`, + format: LogFormat.YEAR_MONTH, + year: environment.year, + month: environment.month, + }); + }; +}; + +const computeDexPoint = (general: TurnGeneral): number => { + let totalDexterity = 0; + for (let index = 1; index <= 5; index += 1) { + let dexterity = readNumber(general.meta, `dex${index}`); + if (dexterity > DEX_LIMIT) { + totalDexterity += (dexterity - DEX_LIMIT) / 3; + dexterity = DEX_LIMIT; + } + totalDexterity += dexterity; + } + return totalDexterity * 0.001; +}; + +const computeBettingPoint = (general: TurnGeneral): number => { + const wins = readNumber(general.meta, 'betwin'); + const gold = readNumber(general.meta, 'betgold'); + const wonGold = readNumber(general.meta, 'betwingold'); + const winRate = wonGold / Math.max(1000, gold); + return wins * 10 * winRate ** 2; +}; + +const computeActiveInheritancePoint = (general: TurnGeneral, key: string): number => { + const stored = general.inheritancePoints?.[key] ?? 0; + switch (key) { + case 'lived_month': { + const value = readNumber(general.meta, 'inherit_lived_month'); + return value !== 0 ? value : stored; + } + case 'max_domestic_critical': { + const value = readNumber(general.meta, 'max_domestic_critical'); + return value !== 0 ? value : stored; + } + case 'active_action': { + const value = readNumber(general.meta, 'inherit_active_action'); + return value !== 0 ? value * 3 : stored; + } + case 'unifier': + case 'tournament': + return stored; + case 'max_belong': + return Math.max(readNumber(general.meta, 'belong'), readNumber(general.meta, 'inherit_max_belong')) * 10; + case 'combat': + return readNumber(general.meta, 'rank_warnum') * 5; + case 'sabotage': + return readNumber(general.meta, 'firenum') * 20; + case 'dex': + return computeDexPoint(general); + case 'betting': + return computeBettingPoint(general); + default: + return stored; + } +}; + +export const createMergeInheritPointRankHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; +}): MonthlyEventActionHandler => { + return () => { + const world = options.getWorld(); + if (!world) { + return; + } + const state = world.getState(); + const isUnited = readNumber(state.meta, 'isunited') !== 0 || readNumber(state.meta, 'isUnited') !== 0; + for (const general of world.listGenerals()) { + let merged = 0; + for (const key of ALL_MERGED_INHERITANCE_KEYS) { + if (isUnited) { + merged += general.inheritancePoints?.[key] ?? 0; + continue; + } + if (!general.userId || general.npcState >= 2) { + continue; + } + merged += computeActiveInheritancePoint(general, key); + } + const actionPoint = readNumber(general.meta, 'inherit_earned_act'); + const spentDynamic = readNumber(general.meta, 'inherit_spent_dyn'); + world.updateGeneral(general.id, { + meta: { + ...general.meta, + inherit_earned_dyn: merged, + inherit_earned: actionPoint + merged, + inherit_spent: spentDynamic, + }, + }); + } + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 0ff3cd6..2d5a598 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -61,15 +61,10 @@ import { } from './monthlyInvaderAction.js'; import { createChangeCityHandler } from './monthlyChangeCityAction.js'; import { createProvideNpcTroopLeaderHandler } from './monthlyProvideNpcTroopLeaderAction.js'; -import { - createFinishNationBettingHandler, - createOpenNationBettingHandler, -} from './monthlyNationBettingAction.js'; +import { createFinishNationBettingHandler, createOpenNationBettingHandler } from './monthlyNationBettingAction.js'; import { createScoutBlockHandler } from './monthlyScoutBlockAction.js'; -import { - createAddGlobalBetrayHandler, - createAssignGeneralSpecialityHandler, -} from './monthlySpecialityBetrayAction.js'; +import { createAddGlobalBetrayHandler, createAssignGeneralSpecialityHandler } from './monthlySpecialityBetrayAction.js'; +import { createLostUniqueItemHandler, createMergeInheritPointRankHandler } from './monthlyUniqueInheritAction.js'; import { buildCommandEnv } from './reservedTurnCommands.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; @@ -374,6 +369,19 @@ const createTurnDaemonRuntimeWithLease = async ( getWorld: () => worldRef, }) ); + eventActions.set( + 'LostUniqueItem', + createLostUniqueItemHandler({ + getWorld: () => worldRef, + itemModules: monthlyActionModules.itemModules, + }) + ); + eventActions.set( + 'MergeInheritPointRank', + createMergeInheritPointRankHandler({ + getWorld: () => worldRef, + }) + ); eventActions.set('ProcessIncome', async (_args, environment) => { await incomeHandler.onMonthChanged?.({ previousYear: environment.month === 1 ? environment.year - 1 : environment.year, diff --git a/app/game-engine/test/monthlyUniqueInheritAction.test.ts b/app/game-engine/test/monthlyUniqueInheritAction.test.ts new file mode 100644 index 0000000..715cb83 --- /dev/null +++ b/app/game-engine/test/monthlyUniqueInheritAction.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from 'vitest'; +import { LogCategory, LogFormat, loadActionModuleBundle, type GeneralItemSlots } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { + createLostUniqueItemHandler, + createMergeInheritPointRankHandler, +} from '../src/turn/monthlyUniqueInheritAction.js'; +import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 9_000, + condition: true, + action: [], + meta: {}, +}; + +const environment = { + year: 210, + month: 1, + startyear: 180, + currentEventID: 1, + turnTime: new Date('0210-01-01T00:00:00.000Z'), +}; + +const buildGeneral = (options: { + id: number; + name: string; + npcState: number; + items?: Partial; + meta?: Record; + inheritancePoints?: Record; +}): TurnGeneral => ({ + id: options.id, + userId: `user-${options.id}`, + name: options.name, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { + horse: options.items?.horse ?? null, + weapon: options.items?.weapon ?? null, + book: options.items?.book ?? null, + item: options.items?.item ?? null, + }, + }, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + startAge: 20, + npcState: options.npcState, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 1, ...(options.meta ?? {}) }, + inheritancePoints: options.inheritancePoints, + lastTurn: { command: '휴식' }, + turnTime: new Date('0210-01-01T00:00:00.000Z'), +}); + +const buildWorld = (generals: TurnGeneral[], worldMeta: Record = {}) => { + const state: TurnWorldState = { + id: 1, + currentYear: 210, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: environment.turnTime, + meta: { hiddenSeed: 'monthly-unique-fixture', ...worldMeta }, + }; + const scenarioConfig: TurnWorldSnapshot['scenarioConfig'] = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }; + return new InMemoryTurnWorld( + state, + { + scenarioConfig, + map: { id: 'test', name: 'test', cities: [] }, + generals, + cities: [], + nations: [], + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }, + { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] } } + ); +}; + +describe('monthly unique-item loss and inheritance rank merge', () => { + it('drops only non-buyable items in legacy slot and general order and writes the summary', async () => { + const modules = (await loadActionModuleBundle()).itemModules; + const world = buildWorld([ + buildGeneral({ + id: 1, + name: '갑', + npcState: 0, + items: { + horse: 'che_명마_12_옥란백용구', + weapon: 'che_무기_15_의천검', + book: 'che_서적_01_효경전', + item: 'che_저지_삼황내문', + }, + }), + buildGeneral({ + id: 2, + name: '을', + npcState: 1, + items: { + horse: 'che_명마_12_사륜거', + weapon: 'che_무기_01_단도', + item: 'che_부적_태현청생부', + }, + }), + buildGeneral({ + id: 3, + name: '제외', + npcState: 2, + items: { item: 'che_저지_삼황내문' }, + }), + ]); + + await createLostUniqueItemHandler({ getWorld: () => world, itemModules: modules })([1], environment, event); + + expect(world.getGeneralById(1)?.role.items).toEqual({ + horse: null, + weapon: null, + book: 'che_서적_01_효경전', + item: null, + }); + expect(world.getGeneralById(2)?.role.items).toEqual({ + horse: null, + weapon: 'che_무기_01_단도', + book: null, + item: null, + }); + expect(world.getGeneralById(3)?.role.items.item).toBe('che_저지_삼황내문'); + expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([ + '옥란백용구(+12)를 잃었습니다.', + '의천검(+15)을 잃었습니다.', + '삼황내문(저지)을 잃었습니다.', + '사륜거(+12)를 잃었습니다.', + '태현청생부(부적)를 잃었습니다.', + '【망실】불운하게도 갑이 한 번에 유니크 3종을 잃었습니다! (총 5개)', + ]); + expect(world.peekDirtyState().logs.at(-1)).toEqual( + expect.objectContaining({ category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH }) + ); + }); + + it('writes the no-loss history without consuming RNG for buyable items', async () => { + const modules = (await loadActionModuleBundle()).itemModules; + const world = buildWorld([ + buildGeneral({ + id: 1, + name: '갑', + npcState: 0, + items: { horse: 'che_명마_05_갈색마', weapon: 'che_무기_01_단도' }, + }), + ]); + + await createLostUniqueItemHandler({ getWorld: () => world, itemModules: modules })([1], environment, event); + + expect(world.peekDirtyState().generals).toEqual([]); + expect(world.peekDirtyState().logs).toEqual([ + expect.objectContaining({ + text: '【망실】어떤 아이템도 잃지 않았습니다!', + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + }), + ]); + }); + + it.skipIf(!process.env.REF_HIDDEN_SEED)('matches the isolated legacy 20% loss choices', async () => { + const modules = (await loadActionModuleBundle()).itemModules; + const world = buildWorld( + [ + buildGeneral({ + id: 1, + name: '갑', + npcState: 0, + items: { + horse: 'che_명마_12_옥란백용구', + weapon: 'che_무기_15_의천검', + book: 'che_서적_01_효경전', + item: 'che_저지_삼황내문', + }, + }), + buildGeneral({ + id: 2, + name: '을', + npcState: 1, + items: { + horse: 'che_명마_12_사륜거', + weapon: 'che_무기_01_단도', + item: 'che_부적_태현청생부', + }, + }), + ], + { hiddenSeed: process.env.REF_HIDDEN_SEED } + ); + + await createLostUniqueItemHandler({ getWorld: () => world, itemModules: modules })([0.2], environment, event); + + expect(world.getGeneralById(1)?.role.items).toEqual({ + horse: 'che_명마_12_옥란백용구', + weapon: 'che_무기_15_의천검', + book: 'che_서적_01_효경전', + item: 'che_저지_삼황내문', + }); + expect(world.getGeneralById(2)?.role.items).toEqual({ + horse: null, + weapon: 'che_무기_01_단도', + book: null, + item: 'che_부적_태현청생부', + }); + expect(world.peekDirtyState().logs.map((log) => log.text)).toEqual([ + '사륜거(+12)를 잃었습니다.', + '【망실】불운하게도 을이 한 번에 유니크 1종을 잃었습니다! (총 1개)', + ]); + }); + + it('merges every active inheritance source except previous into rank values', async () => { + const world = buildWorld([ + buildGeneral({ + id: 1, + name: '갑', + npcState: 0, + meta: { + belong: 3, + inherit_lived_month: 2, + max_domestic_critical: 4, + inherit_active_action: 5, + dex1: 1000, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + rank_warnum: 2, + firenum: 1, + betwin: 2, + betgold: 4000, + betwingold: 2000, + inherit_earned_act: 11, + inherit_spent_dyn: 9, + }, + inheritancePoints: { + previous: 999, + unifier: 6, + tournament: 7, + }, + }), + ]); + + await createMergeInheritPointRankHandler({ getWorld: () => world })([], environment, event); + + expect(world.getGeneralById(1)?.meta).toEqual( + expect.objectContaining({ + inherit_earned_dyn: 100, + inherit_earned: 111, + inherit_spent: 9, + }) + ); + }); + + it('uses persisted per-key values after unification', async () => { + const stored = Object.fromEntries( + [ + 'lived_month', + 'max_domestic_critical', + 'active_action', + 'unifier', + 'tournament', + 'max_belong', + 'combat', + 'sabotage', + 'dex', + 'betting', + ].map((key, index) => [key, index + 1]) + ); + const world = buildWorld( + [ + buildGeneral({ + id: 1, + name: '갑', + npcState: 0, + meta: { inherit_earned_act: 5, inherit_spent_dyn: 3 }, + inheritancePoints: { previous: 999, ...stored }, + }), + ], + { isunited: 1 } + ); + + await createMergeInheritPointRankHandler({ getWorld: () => world })([], environment, event); + + expect(world.getGeneralById(1)?.meta).toEqual( + expect.objectContaining({ + inherit_earned_dyn: 55, + inherit_earned: 60, + inherit_spent: 3, + }) + ); + }); +}); diff --git a/app/game-engine/test/monthlyUniqueInheritPersistence.integration.test.ts b/app/game-engine/test/monthlyUniqueInheritPersistence.integration.test.ts new file mode 100644 index 0000000..a7f67c6 --- /dev/null +++ b/app/game-engine/test/monthlyUniqueInheritPersistence.integration.test.ts @@ -0,0 +1,296 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { loadActionModuleBundle, type Nation } from '@sammo-ts/logic'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; +import { + createLostUniqueItemHandler, + createMergeInheritPointRankHandler, +} from '../src/turn/monthlyUniqueInheritAction.js'; +import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const nationId = 990_101; +const generalId = 990_101; + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 9_000, + condition: true, + action: [], + meta: {}, +}; + +const nation: Nation = { + id: nationId, + name: '망실검증국', + color: '#777777', + capitalCityId: null, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level: 2, + typeCode: 'che_중립', + meta: {}, +}; + +const general: TurnGeneral = { + id: generalId, + userId: 'monthly-unique-user', + name: '망실대상', + nationId, + cityId: 0, + troopId: 0, + stats: { leadership: 50, strength: 50, intelligence: 50 }, + experience: 0, + dedication: 0, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { + horse: 'che_명마_12_옥란백용구', + weapon: 'che_무기_01_단도', + book: null, + item: 'che_저지_삼황내문', + }, + }, + injury: 0, + gold: 0, + rice: 0, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + startAge: 20, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { + killturn: 1, + belong: 3, + inherit_lived_month: 2, + max_domestic_critical: 4, + inherit_active_action: 5, + dex1: 1000, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + rank_warnum: 2, + firenum: 1, + betwin: 2, + betgold: 4000, + betwingold: 2000, + inherit_earned_act: 11, + inherit_spent_dyn: 9, + }, + inheritancePoints: { + previous: 999, + unifier: 6, + tournament: 7, + }, + lastTurn: { command: '휴식' }, + turnTime: new Date('2026-07-25T00:00:00.000Z'), +}; + +integration('monthly unique-item and inheritance-rank persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.logEntry.deleteMany({ where: { generalId } }); + await db.rankData.deleteMany({ where: { generalId } }); + await db.inheritancePoint.deleteMany({ where: { userId: general.userId! } }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.nation.deleteMany({ where: { id: nationId } }); + }); + + afterAll(async () => { + await db.logEntry.deleteMany({ where: { generalId } }); + await db.rankData.deleteMany({ where: { generalId } }); + await db.inheritancePoint.deleteMany({ where: { userId: general.userId! } }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await closeDb?.(); + }); + + it('commits removed item slots, item inventory, logs, and all merged rank columns', async () => { + await db.nation.create({ + data: { + id: nation.id, + name: nation.name, + color: nation.color, + level: nation.level, + typeCode: nation.typeCode, + meta: {}, + }, + }); + await db.general.create({ + data: { + id: general.id, + userId: general.userId, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + age: general.age, + startAge: general.startAge, + npcState: general.npcState, + horseCode: general.role.items.horse ?? 'None', + weaponCode: general.role.items.weapon ?? 'None', + itemCode: general.role.items.item ?? 'None', + turnTime: general.turnTime, + meta: general.meta, + }, + }); + await db.rankData.createMany({ + data: [ + { generalId, nationId, type: 'warnum', value: 2 }, + { generalId, nationId, type: 'firenum', value: 1 }, + { generalId, nationId, type: 'betwin', value: 2 }, + { generalId, nationId, type: 'betgold', value: 4000 }, + { generalId, nationId, type: 'betwingold', value: 2000 }, + { generalId, nationId, type: 'inherit_earned', value: 999 }, + { generalId, nationId, type: 'inherit_earned_act', value: 11 }, + { generalId, nationId, type: 'inherit_earned_dyn', value: 999 }, + { generalId, nationId, type: 'inherit_spent', value: 999 }, + { generalId, nationId, type: 'inherit_spent_dyn', value: 9 }, + ], + }); + await db.inheritancePoint.createMany({ + data: Object.entries(general.inheritancePoints!).map(([key, value]) => ({ + userId: general.userId!, + key, + value, + })), + }); + const worldRow = await db.worldState.create({ + data: { + scenarioCode: 'monthly-unique-inherit-persistence', + currentYear: 210, + currentMonth: 1, + tickSeconds: 600, + config: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'che', unitSet: 'che' }, + }, + meta: { hiddenSeed: 'monthly-unique-inherit-persistence' }, + }, + }); + const state: TurnWorldState = { + id: worldRow.id, + currentYear: 210, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: general.turnTime, + meta: { hiddenSeed: 'monthly-unique-inherit-persistence' }, + }; + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + const loadedGeneral = loaded.snapshot.generals.find((entry) => entry.id === generalId); + expect(loadedGeneral?.meta).toEqual( + expect.objectContaining({ + rank_warnum: 2, + firenum: 1, + betwin: 2, + betgold: 4000, + betwingold: 2000, + inherit_earned_act: 11, + inherit_spent_dyn: 9, + }) + ); + expect(loadedGeneral?.inheritancePoints).toEqual(general.inheritancePoints); + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { id: 'test', name: 'test', cities: [] }, + generals: [general], + cities: [], + nations: [nation], + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + const modules = (await loadActionModuleBundle()).itemModules; + const environment = { + year: 210, + month: 1, + startyear: 180, + currentEventID: 1, + turnTime: state.lastTurnTime, + }; + + try { + await createLostUniqueItemHandler({ getWorld: () => world, itemModules: modules })([1], environment, event); + await createMergeInheritPointRankHandler({ getWorld: () => world })([], environment, event); + await hooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 2, + durationMs: 0, + partial: false, + }); + + const row = await db.general.findUniqueOrThrow({ where: { id: generalId } }); + expect(row).toMatchObject({ + horseCode: 'None', + weaponCode: 'che_무기_01_단도', + itemCode: 'None', + }); + expect(row.meta).toHaveProperty('itemInventory'); + expect( + await db.rankData.findMany({ + where: { + generalId, + type: { + in: [ + 'inherit_earned', + 'inherit_earned_act', + 'inherit_earned_dyn', + 'inherit_spent', + 'inherit_spent_dyn', + ], + }, + }, + orderBy: { type: 'asc' }, + select: { type: true, value: true }, + }) + ).toEqual([ + { type: 'inherit_earned', value: 111 }, + { type: 'inherit_earned_act', value: 11 }, + { type: 'inherit_earned_dyn', value: 100 }, + { type: 'inherit_spent', value: 9 }, + { type: 'inherit_spent_dyn', value: 9 }, + ]); + expect(await db.logEntry.count({ where: { generalId } })).toBe(2); + } finally { + await hooks.close(); + await db.worldState.deleteMany({ where: { id: worldRow.id } }); + } + }); +});