diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 7f058a7..5e0e876 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -32,11 +32,7 @@ import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logi import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js'; import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js'; import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js'; -import type { - NationBettingCandidate, - PendingNationBettingFinish, - PendingNationBettingOpen, -} from './types.js'; +import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; @@ -53,11 +49,7 @@ const readBettingCandidates = (value: unknown): NationBettingCandidate[] => { return value.flatMap((candidate) => { const item = asRecord(candidate); const aux = asRecord(item.aux); - if ( - typeof item.title !== 'string' || - typeof aux.nation !== 'number' || - !Number.isInteger(aux.nation) - ) { + if (typeof item.title !== 'string' || typeof aux.nation !== 'number' || !Number.isInteger(aux.nation)) { return []; } return [ @@ -369,6 +361,9 @@ const buildRankRows = ( ['betwingold', readMeta('betwingold')], ['inherit_earned', readMeta('inherit_earned')], ['inherit_spent', readMeta('inherit_spent')], + ['inherit_earned_dyn', readMeta('inherit_earned_dyn')], + ['inherit_earned_act', readMeta('inherit_earned_act')], + ['inherit_spent_dyn', readMeta('inherit_spent_dyn')], ]; return entries.map(([type, value]) => ({ 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/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 957a5b4..4703e1d 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -31,6 +31,7 @@ export interface TurnGeneral extends General { recentWarTime?: Date | null; lastTurn?: GeneralLastTurn; penalty?: unknown; + inheritancePoints?: Record; } export interface TurnDiplomacy { diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 0567674..63c7b45 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -5,6 +5,8 @@ import { type TurnEngineDatabaseClient, type TurnEngineDiplomacyRow, type TurnEngineGeneralRow, + type TurnEngineInheritancePointRow, + type TurnEngineRankDataRow, type TurnEngineNationRow, type TurnEngineTroopRow, } from '@sammo-ts/infra'; @@ -154,14 +156,36 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => { return parsed.data; }; -const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => { +const GENERAL_RANK_META_PREFIX_TYPES = new Set([ + 'warnum', + 'killnum', + 'deathnum', + 'occupied', + 'killcrew', + 'deathcrew', + 'killcrew_person', + 'deathcrew_person', +]); + +const mapGeneralRow = ( + row: TurnEngineGeneralRow, + rankRows: readonly TurnEngineRankDataRow[], + inheritanceRows: readonly TurnEngineInheritancePointRow[] +): TurnGeneral => { const legacySlots: GeneralItemSlots = { horse: normalizeCode(row.horseCode), weapon: normalizeCode(row.weaponCode), book: normalizeCode(row.bookCode), item: normalizeCode(row.itemCode), }; - const rawMeta = asTriggerRecord(row.meta) as Record; + const rawMeta = { ...(asTriggerRecord(row.meta) as Record) }; + for (const rank of rankRows) { + if (rank.type === 'experience' || rank.type === 'dedication') { + continue; + } + rawMeta[GENERAL_RANK_META_PREFIX_TYPES.has(rank.type) ? `rank_${rank.type}` : rank.type] = rank.value; + } + const inheritancePoints = Object.fromEntries(inheritanceRows.map((entry) => [entry.key, entry.value])); const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots); return { ...((): { meta: TurnGeneral['meta'] } => { @@ -218,6 +242,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => { // meta는 상단에서 보장 처리됨. turnTime: row.turnTime, recentWarTime: row.recentWarTime ?? null, + inheritancePoints, }; }; @@ -315,18 +340,39 @@ export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions) throw new Error('world_state row is required to start turn daemon.'); } - const [generalRows, cityRows, nationRows, diplomacyRows, troopRows, eventRows] = await Promise.all([ - prisma.general.findMany(), - prisma.city.findMany(), - prisma.nation.findMany(), - prisma.diplomacy.findMany(), - prisma.troop.findMany(), - prisma.event.findMany({ - orderBy: [{ priority: 'desc' }, { id: 'asc' }], - }), - ]); + const [generalRows, rankRows, inheritanceRows, cityRows, nationRows, diplomacyRows, troopRows, eventRows] = + await Promise.all([ + prisma.general.findMany(), + prisma.rankData.findMany(), + prisma.inheritancePoint.findMany(), + prisma.city.findMany(), + prisma.nation.findMany(), + prisma.diplomacy.findMany(), + prisma.troop.findMany(), + prisma.event.findMany({ + orderBy: [{ priority: 'desc' }, { id: 'asc' }], + }), + ]); - const generals = generalRows.map(mapGeneralRow); + const ranksByGeneral = new Map(); + for (const row of rankRows) { + const bucket = ranksByGeneral.get(row.generalId) ?? []; + bucket.push(row); + ranksByGeneral.set(row.generalId, bucket); + } + const inheritanceByUser = new Map(); + for (const row of inheritanceRows) { + const bucket = inheritanceByUser.get(row.userId) ?? []; + bucket.push(row); + inheritanceByUser.set(row.userId, bucket); + } + const generals = generalRows.map((row) => + mapGeneralRow( + row, + ranksByGeneral.get(row.id) ?? [], + row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [] + ) + ); const cities = cityRows.map(mapCityRow); const nations = nationRows.map(mapNationRow); const diplomacy = diplomacyRows.map(mapDiplomacyRow); diff --git a/app/game-engine/test/monthlyCreateManyNpcPersistence.integration.test.ts b/app/game-engine/test/monthlyCreateManyNpcPersistence.integration.test.ts index b183b08..3472b23 100644 --- a/app/game-engine/test/monthlyCreateManyNpcPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyCreateManyNpcPersistence.integration.test.ts @@ -213,7 +213,7 @@ integration('CreateManyNPC database persistence', () => { const ranks = await db.rankData.findMany({ where: { generalId: createdGeneralId } }); // Legacy inserts 37 RankColumn rows. Core's canonical rank model // additionally projects experience/dedication/dex, so it owns 41. - expect(ranks).toHaveLength(41); + expect(ranks).toHaveLength(44); expect(ranks.every((rank) => rank.nationId === 0 && rank.value === 0)).toBe(true); expect( await db.logEntry.findMany({ diff --git a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts index 4a6ac03..97d64f6 100644 --- a/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyInvaderPersistence.integration.test.ts @@ -320,7 +320,7 @@ integration('RaiseInvader database persistence', () => { }); expect(await db.general.count({ where: { nationId: createdNationId } })).toBe(10); expect(await db.generalTurn.count({ where: { generalId: { gte: firstCreatedGeneralId } } })).toBe(300); - expect(await db.rankData.count({ where: { generalId: { gte: firstCreatedGeneralId } } })).toBe(410); + expect(await db.rankData.count({ where: { generalId: { gte: firstCreatedGeneralId } } })).toBe(440); expect(await db.nationTurn.count({ where: { nationId: createdNationId } })).toBe(48); expect( await db.diplomacy.count({ diff --git a/app/game-engine/test/monthlyNpcSupportPersistence.integration.test.ts b/app/game-engine/test/monthlyNpcSupportPersistence.integration.test.ts index d9af8a5..b7e3d91 100644 --- a/app/game-engine/test/monthlyNpcSupportPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyNpcSupportPersistence.integration.test.ts @@ -228,16 +228,14 @@ integration('monthly NPC support database persistence', () => { affinity: 999, meta: expect.objectContaining({ killturn: 70 }), }); - expect( - await db.troop.findUniqueOrThrow({ where: { troopLeaderId: generalId } }) - ).toMatchObject({ + expect(await db.troop.findUniqueOrThrow({ where: { troopLeaderId: generalId } })).toMatchObject({ nationId, name: '㉥부대장 41', }); const turns = await db.generalTurn.findMany({ where: { generalId } }); expect(turns).toHaveLength(30); expect(new Set(turns.map((turn) => turn.actionCode))).toEqual(new Set(['che_집합'])); - expect(await db.rankData.count({ where: { generalId } })).toBe(41); + expect(await db.rankData.count({ where: { generalId } })).toBe(44); expect((await db.worldState.findUniqueOrThrow({ where: { id: stateRow.id } })).meta).toMatchObject({ lastNPCTroopLeaderID: 41, }); diff --git a/app/game-engine/test/monthlyRaiseNpcNationPersistence.integration.test.ts b/app/game-engine/test/monthlyRaiseNpcNationPersistence.integration.test.ts index 4a8dde7..f4bbb73 100644 --- a/app/game-engine/test/monthlyRaiseNpcNationPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyRaiseNpcNationPersistence.integration.test.ts @@ -65,10 +65,7 @@ const map: MapDefinition = { level: 5, region: 1, position: { x: index, y: 0 }, - connections: [ - ...(index > 0 ? [rows[index - 1]!] : []), - ...(index + 1 < rows.length ? [rows[index + 1]!] : []), - ], + connections: [...(index > 0 ? [rows[index - 1]!] : []), ...(index + 1 < rows.length ? [rows[index + 1]!] : [])], max: { population: 50_000, agriculture: 5_000, @@ -294,7 +291,7 @@ integration('RaiseNPCNation database persistence', () => { }); expect(await db.generalTurn.count({ where: { generalId: createdGeneralId } })).toBe(30); expect(await db.nationTurn.count({ where: { nationId: createdNationId } })).toBe(48); - expect(await db.rankData.count({ where: { generalId: createdGeneralId } })).toBe(41); + expect(await db.rankData.count({ where: { generalId: createdGeneralId } })).toBe(44); expect( await db.diplomacy.count({ where: { diff --git a/app/game-engine/test/monthlyRegisterNpcPersistence.integration.test.ts b/app/game-engine/test/monthlyRegisterNpcPersistence.integration.test.ts index b239a36..a99dc5f 100644 --- a/app/game-engine/test/monthlyRegisterNpcPersistence.integration.test.ts +++ b/app/game-engine/test/monthlyRegisterNpcPersistence.integration.test.ts @@ -53,10 +53,7 @@ integration('RegNPC database persistence', () => { const clean = async () => { await db.logEntry.deleteMany({ where: { - OR: [ - { generalId: createdGeneralId }, - { year: 200, month: 1, text: { contains: 'ⓝ저장장수' } }, - ], + OR: [{ generalId: createdGeneralId }, { year: 200, month: 1, text: { contains: 'ⓝ저장장수' } }], }, }); await db.generalTurn.deleteMany({ where: { generalId: createdGeneralId } }); @@ -207,7 +204,7 @@ integration('RegNPC database persistence', () => { expect(turns).toHaveLength(30); expect(new Set(turns.map((turn) => turn.actionCode))).toEqual(new Set(['휴식'])); const ranks = await db.rankData.findMany({ where: { generalId: createdGeneralId } }); - expect(ranks).toHaveLength(41); + expect(ranks).toHaveLength(44); expect(ranks.every((rank) => rank.nationId === 0 && rank.value === 0)).toBe(true); expect( await db.logEntry.findFirst({ 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 } }); + } + }); +}); diff --git a/packages/common/src/ranking/types.ts b/packages/common/src/ranking/types.ts index d50ae6a..6350075 100644 --- a/packages/common/src/ranking/types.ts +++ b/packages/common/src/ranking/types.ts @@ -40,6 +40,9 @@ export const RANK_DATA_TYPES = [ 'betwingold', 'inherit_earned', 'inherit_spent', + 'inherit_earned_dyn', + 'inherit_earned_act', + 'inherit_spent_dyn', ] as const; export type RankDataType = (typeof RANK_DATA_TYPES)[number]; diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index 18d3429..9efea38 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -55,6 +55,19 @@ export interface TurnEngineGeneralRow { recentWarTime: Date | null; } +export interface TurnEngineRankDataRow { + generalId: number; + nationId: number; + type: string; + value: number; +} + +export interface TurnEngineInheritancePointRow { + userId: string; + key: string; + value: number; +} + export interface TurnEngineCityRow { id: number; name: string; @@ -363,6 +376,12 @@ export interface TurnEngineDatabaseClient { update(args: { where: { id: number }; data: TurnEngineGeneralUpdateInput }): Promise; deleteMany(args?: unknown): Promise; }; + rankData: { + findMany(args?: unknown): Promise; + }; + inheritancePoint: { + findMany(args?: unknown): Promise; + }; city: { findMany(args?: unknown): Promise; createMany(args: { data: TurnEngineCityCreateManyInput[] }): Promise;