From 41d70a475cad44a66d9bf9da420b6f70db381b89 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 17:19:44 +0000 Subject: [PATCH] Implement monthly nation level updates --- app/game-engine/src/turn/databaseHooks.ts | 21 + app/game-engine/src/turn/inMemoryWorld.ts | 12 + .../src/turn/monthlyEventHandler.ts | 6 +- .../src/turn/monthlyNationLevelAction.ts | 370 ++++++++++++++++++ app/game-engine/src/turn/reservedTurnStore.ts | 34 +- app/game-engine/src/turn/turnDaemon.ts | 63 ++- .../test/monthlyNationLevelAction.test.ts | 288 ++++++++++++++ ...NationLevelPersistence.integration.test.ts | 312 +++++++++++++++ 8 files changed, 1089 insertions(+), 17 deletions(-) create mode 100644 app/game-engine/src/turn/monthlyNationLevelAction.ts create mode 100644 app/game-engine/test/monthlyNationLevelAction.test.ts create mode 100644 app/game-engine/test/monthlyNationLevelPersistence.integration.test.ts diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 287db2b..5ec6ebb 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -384,6 +384,7 @@ export const createDatabaseTurnHooks = async ( deletedEvents, lifecycleEvents, pendingNeutralAuctions, + inheritancePointAdjustments, } = changes; const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); @@ -435,6 +436,26 @@ export const createDatabaseTurnHooks = async ( asRecord(world.getScenarioConfig().const) ); + if (inheritancePointAdjustments.length > 0) { + const grouped = new Map(); + for (const entry of inheritancePointAdjustments) { + const groupKey = `${entry.userId}\u0000${entry.key}`; + const current = grouped.get(groupKey); + if (current) { + current.amount += entry.amount; + } else { + grouped.set(groupKey, { ...entry }); + } + } + for (const entry of grouped.values()) { + await prisma.inheritancePoint.upsert({ + where: { userId_key: { userId: entry.userId, key: entry.key } }, + update: { value: { increment: entry.amount } }, + create: { userId: entry.userId, key: entry.key, value: entry.amount }, + }); + } + } + if (deletedNationSnapshots.length > 0) { const nationIds = deletedNationSnapshots.map((snapshot) => snapshot.nation.id); const historyRows = await prisma.logEntry.findMany({ diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index a1c18ac..7815b25 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -110,6 +110,7 @@ export interface TurnWorldChanges { deletedEvents: number[]; lifecycleEvents: GeneralLifecycleEvent[]; pendingNeutralAuctions: PendingNeutralAuction[]; + inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>; } const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => { @@ -279,6 +280,7 @@ export class InMemoryTurnWorld { private readonly messages: MessageDraft[] = []; private readonly lifecycleEvents: GeneralLifecycleEvent[] = []; private readonly pendingNeutralAuctions: PendingNeutralAuction[] = []; + private readonly inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }> = []; private readonly scenarioConfig: ScenarioConfig; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; @@ -348,6 +350,13 @@ export class InMemoryTurnWorld { }); } + queueInheritancePointAdjustment(userId: string, key: string, amount: number): void { + if (!userId || !Number.isFinite(amount) || amount === 0) { + return; + } + this.inheritancePointAdjustments.push({ userId, key, amount }); + } + getScenarioConfig(): ScenarioConfig { return this.scenarioConfig; } @@ -840,6 +849,7 @@ export class InMemoryTurnWorld { detail: { ...auction.detail }, closeAt: new Date(auction.closeAt.getTime()), })); + const inheritancePointAdjustments = this.inheritancePointAdjustments.map((entry) => ({ ...entry })); return { generals, @@ -860,6 +870,7 @@ export class InMemoryTurnWorld { deletedEvents, lifecycleEvents, pendingNeutralAuctions, + inheritancePointAdjustments, }; } @@ -886,6 +897,7 @@ export class InMemoryTurnWorld { this.messages.splice(0, changes.messages.length); this.lifecycleEvents.splice(0, changes.lifecycleEvents.length); this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length); + this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length); } consumeDirtyState(): TurnWorldChanges { diff --git a/app/game-engine/src/turn/monthlyEventHandler.ts b/app/game-engine/src/turn/monthlyEventHandler.ts index 2dea6a1..fabf00d 100644 --- a/app/game-engine/src/turn/monthlyEventHandler.ts +++ b/app/game-engine/src/turn/monthlyEventHandler.ts @@ -16,7 +16,7 @@ export type MonthlyEventActionHandler = ( args: readonly unknown[], environment: MonthlyEventEnvironment, event: TurnEvent -) => void; +) => void | Promise; export type MonthlyEventActionRegistry = ReadonlyMap; @@ -180,7 +180,7 @@ export const createMonthlyEventHandler = (options: { startYear: number; actions?: MonthlyEventActionRegistry; }): TurnCalendarHandler => { - const dispatch = (targetCode: 'pre_month' | 'month', context: TurnCalendarContext): void => { + const dispatch = async (targetCode: 'pre_month' | 'month', context: TurnCalendarContext): Promise => { const world = options.getWorld(); if (!world) { return; @@ -209,7 +209,7 @@ export const createMonthlyEventHandler = (options: { if (!handler) { throw new Error(`Unsupported monthly event action: ${action.name} (eventId=${event.id})`); } - handler(action.args, environment, event); + await handler(action.args, environment, event); } } }; diff --git a/app/game-engine/src/turn/monthlyNationLevelAction.ts b/app/game-engine/src/turn/monthlyNationLevelAction.ts new file mode 100644 index 0000000..ba4491b --- /dev/null +++ b/app/game-engine/src/turn/monthlyNationLevelAction.ts @@ -0,0 +1,370 @@ +import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { + LogCategory, + LogFormat, + LogScope, + countOccupiedUniqueItems, + createItemModuleRegistry, + equipNewItem, + resolveUniqueConfig, + type ItemModule, +} from '@sammo-ts/logic'; +import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; +import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; +import type { TurnGeneral } from './types.js'; + +const NATION_LEVEL_CITY_COUNTS = [0, 1, 2, 5, 8, 11, 16, 21] as const; +const NATION_LEVEL_NAMES = ['방랑군', '호족', '군벌', '주자사', '주목', '공', '왕', '황제'] as const; +// Legacy Util::range(minChiefLevel, 12) is Python-style and excludes 12. +const EXCLUSIVE_MAX_CHIEF_LEVEL = 12; + +const resolveNationChiefLevel = (nationLevel: number): number => { + if (nationLevel >= 6) return 5; + if (nationLevel >= 4) return 7; + if (nationLevel >= 2) return 9; + return 11; +}; + +const readNumber = (value: unknown, fallback = 0): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return fallback; +}; + +const resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => { + const state = world.getState(); + const rawSeed = state.meta.hiddenSeed ?? state.meta.seed ?? state.id; + return typeof rawSeed === 'string' || typeof rawSeed === 'number' ? rawSeed : String(rawSeed); +}; + +const countNonBuyableItems = (general: TurnGeneral, itemRegistry: Map): number => + Object.values(general.role.items).filter((itemKey) => { + if (!itemKey) return false; + return itemRegistry.get(itemKey)?.buyable === false; + }).length; + +const addCount = (target: Map, source: ReadonlyMap): void => { + for (const [key, value] of source) { + target.set(key, (target.get(key) ?? 0) + value); + } +}; + +const giveRandomUniqueItem = (options: { + world: InMemoryTurnWorld; + general: TurnGeneral; + nationName: string; + rng: RandUtil; + itemRegistry: Map; + allItems: Record>; + additionalOccupiedCounts: ReadonlyMap; + year: number; + month: number; +}): boolean => { + const invalidSlots = new Set(); + for (const [slot, itemKey] of Object.entries(options.general.role.items)) { + if (itemKey && options.itemRegistry.get(itemKey)?.buyable === false) { + invalidSlots.add(slot); + } + } + + const occupiedCounts = countOccupiedUniqueItems( + options.world.listGenerals().map((general) => general.role.items), + options.itemRegistry + ); + addCount(occupiedCounts, options.additionalOccupiedCounts); + + const available: Array<[ItemModule, number]> = []; + for (const [slot, itemEntries] of Object.entries(options.allItems)) { + if (invalidSlots.has(slot)) { + continue; + } + for (const [itemKey, count] of Object.entries(itemEntries)) { + const item = options.itemRegistry.get(itemKey); + if (!item || item.buyable || count <= 0) { + continue; + } + const remain = count - (occupiedCounts.get(itemKey) ?? 0); + if (remain > 0) { + available.push([item, remain]); + } + } + } + if (available.length === 0) { + return false; + } + + const item = options.rng.choiceUsingWeightPair(available); + const nextGeneral = options.world.getGeneralById(options.general.id); + if (!nextGeneral) { + return false; + } + equipNewItem(nextGeneral, item.slot, item.key, { + ...(item.initialCharges === undefined ? {} : { charges: item.initialCharges }), + }); + options.world.updateGeneral(nextGeneral.id, { + role: nextGeneral.role, + itemInventory: nextGeneral.itemInventory, + }); + + const josaYi = JosaUtil.pick(nextGeneral.name, '이'); + const josaUl = JosaUtil.pick(item.rawName, '을'); + options.world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + generalId: nextGeneral.id, + text: `${item.name}${josaUl} 습득했습니다!`, + format: LogFormat.MONTH, + year: options.year, + month: options.month, + }); + options.world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + generalId: nextGeneral.id, + text: `${item.name}${josaUl} 습득`, + format: LogFormat.YEAR_MONTH, + year: options.year, + month: options.month, + }); + options.world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + text: `${nextGeneral.name}${josaYi} ${item.name}${josaUl} 습득했습니다!`, + format: LogFormat.MONTH, + year: options.year, + month: options.month, + }); + options.world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: `【작위보상】${options.nationName}${nextGeneral.name}${josaYi} ${item.name}${josaUl} 습득했습니다!`, + format: LogFormat.YEAR_MONTH, + year: options.year, + month: options.month, + }); + return true; +}; + +const pushLevelLogs = (options: { + world: InMemoryTurnWorld; + nationId: number; + nationName: string; + lordName: string; + oldLevel: number; + newLevel: number; + year: number; + month: number; +}): void => { + const oldLevelName = NATION_LEVEL_NAMES[options.oldLevel]; + const levelName = NATION_LEVEL_NAMES[options.newLevel]; + if (oldLevelName === undefined || levelName === undefined) { + throw new Error(`Unsupported nation level transition: ${options.oldLevel} -> ${options.newLevel}`); + } + const josaYi = JosaUtil.pick(options.lordName, '이'); + const josaRo = JosaUtil.pick(levelName, '로'); + let globalText: string | null = null; + let nationText: string | null = null; + + if (options.newLevel === 7) { + globalText = `【작위】${options.nationName} ${oldLevelName} ${options.lordName}${josaYi} ${levelName}${josaRo} 옹립되었습니다.`; + nationText = `${options.nationName} ${oldLevelName} ${options.lordName}${josaYi} ${levelName}${josaRo} 옹립`; + } else if (options.newLevel === 6) { + globalText = `【작위】${options.nationName}${options.lordName}${josaYi} ${levelName}${josaRo} 책봉되었습니다.`; + nationText = `${options.nationName}${options.lordName}${josaYi} ${levelName}${josaRo} 책봉`; + } else if (options.newLevel >= 3) { + globalText = `【작위】${options.nationName}${options.lordName}${josaYi} ${levelName}${josaRo} 임명되었습니다.`; + nationText = `${options.nationName}${options.lordName}${josaYi} ${levelName}${josaRo} 임명됨`; + } else if (options.newLevel === 2) { + const josaRa = JosaUtil.pick(options.nationName, '라'); + globalText = `【작위】${options.lordName}${josaYi} 독립하여 ${options.nationName}${josaRa}는 ${levelName}${josaRo} 나섰습니다.`; + nationText = `${options.lordName}${josaYi} 독립하여 ${options.nationName}${josaRa}는 ${levelName}${josaRo} 나서다`; + } + if (globalText) { + options.world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: globalText, + format: LogFormat.YEAR_MONTH, + year: options.year, + month: options.month, + }); + } + if (nationText) { + options.world.pushLog({ + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: options.nationId, + text: nationText, + format: LogFormat.YEAR_MONTH, + year: options.year, + month: options.month, + }); + } +}; + +export const createUpdateNationLevelHandler = (options: { + getWorld: () => InMemoryTurnWorld | null; + reservedTurns: InMemoryReservedTurnStore; + itemModules: ItemModule[]; + loadAdditionalOccupiedUniqueCounts?: () => Promise>; +}): MonthlyEventActionHandler => { + const itemRegistry = createItemModuleRegistry(options.itemModules); + + return async (_args, environment) => { + const world = options.getWorld(); + if (!world) { + return; + } + const uniqueConfig = resolveUniqueConfig(world.getScenarioConfig().const); + if (Object.keys(uniqueConfig.allItems).length === 0) { + uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); + } + const additionalOccupiedCounts = options.loadAdditionalOccupiedUniqueCounts + ? await options.loadAdditionalOccupiedUniqueCounts() + : new Map(); + const cityCounts = new Map(); + for (const city of world.listCities()) { + if (city.level >= 4) { + cityCounts.set(city.nationId, (cityCounts.get(city.nationId) ?? 0) + 1); + } + } + const state = world.getState(); + const worldKillturn = readNumber(state.meta.killturn); + const turnMinutes = state.tickSeconds / 60; + if (!(turnMinutes > 0)) { + throw new Error('UpdateNationLevel requires a positive turn term.'); + } + const targetKillturn = worldKillturn - (24 * 60) / turnMinutes; + const hiddenSeed = resolveHiddenSeed(world); + + for (const nation of world.listNations().sort((left, right) => left.id - right.id)) { + const cityCount = cityCounts.get(nation.id) ?? 0; + let newLevel = 0; + for (let level = 0; level < NATION_LEVEL_CITY_COUNTS.length; level += 1) { + if (cityCount < NATION_LEVEL_CITY_COUNTS[level]!) { + break; + } + newLevel = level; + } + if (newLevel <= nation.level) { + continue; + } + + const oldLevel = nation.level; + const levelDiff = newLevel - oldLevel; + const lord = world + .listGenerals() + .sort((left, right) => left.id - right.id) + .find((general) => general.nationId === nation.id && general.officerLevel === 12); + const nextMeta = newLevel === 7 ? { ...nation.meta, can_국기변경: 1, can_국호변경: 1 } : { ...nation.meta }; + world.updateNation(nation.id, { + level: newLevel, + gold: nation.gold + newLevel * 1000, + rice: nation.rice + newLevel * 1000, + meta: nextMeta, + }); + pushLevelLogs({ + world, + nationId: nation.id, + nationName: nation.name, + lordName: lord?.name ?? '', + oldLevel, + newLevel, + year: environment.year, + month: environment.month, + }); + + for ( + let officerLevel = resolveNationChiefLevel(newLevel); + officerLevel < EXCLUSIVE_MAX_CHIEF_LEVEL; + officerLevel += 1 + ) { + options.reservedTurns.ensureNationTurns(nation.id, officerLevel); + } + + const eligible = world + .listGenerals() + .filter( + (general) => + general.nationId === nation.id && + general.npcState < 2 && + readNumber(general.meta.killturn, Number.NEGATIVE_INFINITY) >= targetKillturn + ) + .sort((left, right) => left.id - right.id); + const chief = eligible.find((general) => general.officerLevel === 12); + const relativeYear = environment.year - environment.startyear; + let maxTrialCountByYear = 1; + for (const [targetYear, targetTrialCount] of uniqueConfig.maxUniqueItemLimit) { + if (relativeYear < targetYear) { + break; + } + maxTrialCountByYear = targetTrialCount; + } + const itemTypeCount = Object.keys(uniqueConfig.allItems).length; + const candidates: Array<[TurnGeneral, number]> = []; + for (const general of eligible) { + const trialCount = + Math.min(maxTrialCountByYear, itemTypeCount) - countNonBuyableItems(general, itemRegistry); + if (trialCount <= 0) { + continue; + } + let score = readNumber(general.meta.belong) + 10; + if (general.officerLevel === 12) score += 60; + else if (general.officerLevel === 11) score += 30; + else if (general.officerLevel > 4) score += 15; + score *= 2 ** trialCount; + candidates.push([general, score]); + } + const nationRng = new RandUtil( + new LiteHashDRBG( + simpleSerialize(hiddenSeed, 'nationLevelUp', environment.year, environment.month, nation.id) + ) + ); + for (let index = 0; index < levelDiff && candidates.length > 0; index += 1) { + const winner = nationRng.choiceUsingWeightPair(candidates); + const winnerIndex = candidates.findIndex(([general]) => general.id === winner.id); + if (winnerIndex >= 0) { + candidates.splice(winnerIndex, 1); + } + const itemRng = new RandUtil( + new LiteHashDRBG( + simpleSerialize( + hiddenSeed, + 'givenUnique', + environment.year, + environment.month, + nation.id, + winner.id + ) + ) + ); + giveRandomUniqueItem({ + world, + general: winner, + nationName: nation.name, + rng: itemRng, + itemRegistry, + allItems: uniqueConfig.allItems, + additionalOccupiedCounts, + year: environment.year, + month: environment.month, + }); + } + const isUnited = readNumber(state.meta.isunited ?? state.meta.isUnited); + if (chief?.userId && isUnited === 0) { + world.queueInheritancePointAdjustment(chief.userId, 'unifier', 250 * levelDiff); + } + } + }; +}; diff --git a/app/game-engine/src/turn/reservedTurnStore.ts b/app/game-engine/src/turn/reservedTurnStore.ts index 062b08b..2a665d5 100644 --- a/app/game-engine/src/turn/reservedTurnStore.ts +++ b/app/game-engine/src/turn/reservedTurnStore.ts @@ -75,6 +75,7 @@ type ReservedTurnDatabaseClient = Pick(); private readonly dirtyGeneralIds = new Set(); private readonly dirtyNationKeys = new Set(); + private readonly pendingNationInitializationKeys = new Set(); private readonly maxGeneralTurns: number; private readonly maxNationTurns: number; @@ -164,7 +166,7 @@ export class InMemoryReservedTurnStore { async refreshNationTurns(nationId: number, officerLevel: number): Promise { const key = buildNationKey(nationId, officerLevel); - if (this.dirtyNationKeys.has(key)) { + if (this.dirtyNationKeys.has(key) || this.pendingNationInitializationKeys.has(key)) { return; } const rows = await this.prisma.nationTurn.findMany({ @@ -205,6 +207,12 @@ export class InMemoryReservedTurnStore { return list[turnIdx] ?? createDefaultEntry(); } + ensureNationTurns(nationId: number, officerLevel: number): void { + const key = buildNationKey(nationId, officerLevel); + this.getNationTurns(nationId, officerLevel); + this.pendingNationInitializationKeys.add(key); + } + shiftGeneralTurns(generalId: number, amount: number): void { const list = this.getGeneralTurns(generalId); this.generalTurns.set(generalId, applyShift(list, amount)); @@ -222,6 +230,7 @@ export class InMemoryReservedTurnStore { return { generalIds: Array.from(this.dirtyGeneralIds), nationKeys: Array.from(this.dirtyNationKeys), + nationInitializationKeys: Array.from(this.pendingNationInitializationKeys), }; } @@ -232,6 +241,9 @@ export class InMemoryReservedTurnStore { for (const key of changes.nationKeys) { this.dirtyNationKeys.delete(key); } + for (const key of changes.nationInitializationKeys) { + this.pendingNationInitializationKeys.delete(key); + } } async persistChanges(prisma: ReservedTurnDatabaseClient, changes: ReservedTurnChanges): Promise { @@ -266,6 +278,26 @@ export class InMemoryReservedTurnStore { })), }); } + + for (const key of changes.nationInitializationKeys) { + if (changes.nationKeys.includes(key)) { + continue; + } + const [nationIdRaw, officerLevelRaw] = key.split(':'); + const nationId = Number(nationIdRaw); + const officerLevel = Number(officerLevelRaw); + const turns = this.getNationTurns(nationId, officerLevel); + await prisma.nationTurn.createMany({ + data: turns.map((entry, turnIdx) => ({ + nationId, + officerLevel, + turnIdx, + actionCode: normalizeAction(entry.action), + arg: asJson(normalizeArgs(entry.args)), + })), + skipDuplicates: true, + }); + } } async flushChanges(): Promise { diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 40fce07..8b76587 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -47,6 +47,7 @@ import { } from './monthlyEventHandler.js'; import { createRaiseDisasterHandler } from './monthlyDisasterAction.js'; import { createUpdateCitySupplyHandler } from './monthlyCitySupplyAction.js'; +import { createUpdateNationLevelHandler } from './monthlyNationLevelAction.js'; import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js'; export interface TurnDaemonRuntimeOptions { @@ -95,6 +96,30 @@ const buildFixedSchedule = (tickMinutes: number): TurnSchedule => ({ entries: [{ startMinute: 0, tickMinutes }], }); +const loadOccupiedAuctionUniqueCounts = async (databaseUrl: string): Promise> => { + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const rows = await connector.prisma.auction.findMany({ + where: { + type: 'UNIQUE_ITEM', + status: { in: ['OPEN', 'FINALIZING'] }, + targetCode: { not: null }, + }, + select: { targetCode: true }, + }); + const counts = new Map(); + for (const row of rows) { + if (row.targetCode) { + counts.set(row.targetCode, (counts.get(row.targetCode) ?? 0) + 1); + } + } + return counts; + } finally { + await connector.disconnect(); + } +}; + const resolveRedisConfig = (redisUrl?: string, env: NodeJS.ProcessEnv = process.env) => { if (redisUrl) { return { url: redisUrl }; @@ -119,11 +144,18 @@ const createTurnDaemonRuntimeWithLease = async ( const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes); const resolvedState = options.tickMinutes ? { ...state, tickSeconds: tickMinutes * 60 } : state; const schedule = options.schedule ?? buildFixedSchedule(tickMinutes); - const reservedTurnStoreHandle = options.generalTurnHandler - ? null - : await createReservedTurnStore({ - databaseUrl: options.databaseUrl, - }); + const hasEventAction = (name: string): boolean => + snapshot.events.some( + (event) => + Array.isArray(event.action) && + event.action.some((action) => Array.isArray(action) && action[0] === name) + ); + const reservedTurnStoreHandle = + options.generalTurnHandler && !hasEventAction('UpdateNationLevel') + ? null + : await createReservedTurnStore({ + databaseUrl: options.databaseUrl, + }); const commandProfile = options.commandProfile ?? (options.commandProfilePath @@ -148,12 +180,6 @@ const createTurnDaemonRuntimeWithLease = async ( scenarioConfig: snapshot.scenarioConfig, nationTraits: nationTraitMap, }); - const hasEventAction = (name: string): boolean => - snapshot.events.some( - (event) => - Array.isArray(event.action) && - event.action.some((action) => Array.isArray(action) && action[0] === name) - ); const eventActions = new Map(); eventActions.set( 'RandomizeCityTradeRate', @@ -175,8 +201,19 @@ const createTurnDaemonRuntimeWithLease = async ( map: snapshot.map, }) ); - eventActions.set('ProcessIncome', (_args, environment) => { - void incomeHandler.onMonthChanged?.({ + if (reservedTurnStoreHandle) { + eventActions.set( + 'UpdateNationLevel', + createUpdateNationLevelHandler({ + getWorld: () => worldRef, + reservedTurns: reservedTurnStoreHandle.store, + itemModules: monthlyActionModules.itemModules, + loadAdditionalOccupiedUniqueCounts: () => loadOccupiedAuctionUniqueCounts(options.databaseUrl), + }) + ); + } + eventActions.set('ProcessIncome', async (_args, environment) => { + await incomeHandler.onMonthChanged?.({ previousYear: environment.month === 1 ? environment.year - 1 : environment.year, previousMonth: environment.month === 1 ? 12 : environment.month - 1, currentYear: environment.year, diff --git a/app/game-engine/test/monthlyNationLevelAction.test.ts b/app/game-engine/test/monthlyNationLevelAction.test.ts new file mode 100644 index 0000000..92c213b --- /dev/null +++ b/app/game-engine/test/monthlyNationLevelAction.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + ITEM_KEYS, + LogCategory, + LogScope, + loadItemModules, + type City, + type ItemModule, + type Nation, +} from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createUpdateNationLevelHandler } from '../src/turn/monthlyNationLevelAction.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; +import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const uniqueHorse: ItemModule = { + key: 'test_unique_horse', + name: '시험명마', + rawName: '시험명마', + info: '', + slot: 'horse', + cost: null, + buyable: false, + consumable: false, + reqSecu: 0, + unique: true, +}; + +const buildCity = (id: number, nationId = 1, level = 4): City => ({ + id, + name: `도시${id}`, + nationId, + level, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 500, + securityMax: 1_000, + supplyState: 1, + frontState: 0, + defence: 500, + defenceMax: 1_000, + wall: 500, + wallMax: 1_000, + meta: {}, +}); + +const buildNation = (level: number): Nation => ({ + id: 1, + name: '위', + color: '#000000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10_000, + rice: 20_000, + power: 0, + level, + typeCode: 'che_중립', + meta: { marker: 1 }, +}); + +const buildGeneral = (id: number, patch: Partial = {}): TurnGeneral => ({ + id, + userId: `user-${id}`, + name: id === 1 ? '조조' : `장수${id}`, + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 90, strength: 70, intelligence: 90 }, + experience: 0, + dedication: 0, + officerLevel: id === 1 ? 12 : 5, + 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: 100, + crewTypeId: 1100, + train: 100, + atmos: 100, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 1_000, belong: 5 }, + turnTime: new Date('0193-01-01T00:00:00.000Z'), + ...patch, +}); + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['UpdateNationLevel']], + meta: {}, +}; + +const buildHarness = async ( + nationLevel: number, + cityCount: number, + options: { + hiddenSeed?: string; + itemModules?: ItemModule[]; + configConst?: Record; + additionalOccupiedCounts?: Map; + } = {} +) => { + const state: TurnWorldState = { + id: 1, + currentYear: 193, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0193-01-01T00:00:00.000Z'), + meta: { hiddenSeed: options.hiddenSeed ?? 'nation-level-test', killturn: 1_000 }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: + options.configConst ?? + ({ + allItems: { horse: { [uniqueHorse.key]: 1 } }, + maxUniqueItemLimit: [[-1, 1]], + } satisfies Record), + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals: [buildGeneral(1), buildGeneral(2, { meta: { killturn: 850, belong: 30 } })], + cities: Array.from({ length: cityCount }, (_, index) => buildCity(index + 1)), + nations: [buildNation(nationLevel)], + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const prisma = { + generalTurn: { + findMany: vi.fn(async () => []), + deleteMany: vi.fn(async () => ({ count: 0 })), + createMany: vi.fn(async () => ({ count: 0 })), + }, + nationTurn: { + findMany: vi.fn(async () => []), + deleteMany: vi.fn(async () => ({ count: 0 })), + createMany: vi.fn(async () => ({ count: 0 })), + }, + }; + const reservedTurns = new InMemoryReservedTurnStore(prisma, { maxGeneralTurns: 30, maxNationTurns: 12 }); + await reservedTurns.loadAll(); + const handler = createUpdateNationLevelHandler({ + getWorld: () => world, + reservedTurns, + itemModules: options.itemModules ?? [uniqueHorse], + loadAdditionalOccupiedUniqueCounts: options.additionalOccupiedCounts + ? async () => options.additionalOccupiedCounts! + : undefined, + }); + return { world, reservedTurns, handler }; +}; + +describe('UpdateNationLevel monthly action', () => { + it('raises only upward, initializes new chief turns, rewards a unique item, and credits the chief', async () => { + const { world, reservedTurns, handler } = await buildHarness(0, 2); + + await handler([], { year: 193, month: 2, startyear: 190, currentEventID: 1, turnTime: new Date() }, event); + + expect(world.getNationById(1)).toMatchObject({ + level: 2, + gold: 12_000, + rice: 22_000, + meta: { marker: 1 }, + }); + expect(reservedTurns.peekDirtyState().nationInitializationKeys).toEqual(['1:9', '1:10', '1:11']); + 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 }, + ]); + expect(world.peekDirtyState().logs).toEqual( + expect.arrayContaining([ + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: '【작위】조조가 독립하여 라는 군벌로 나섰습니다.', + format: 2, + year: 193, + month: 2, + }, + { + scope: LogScope.NATION, + category: LogCategory.HISTORY, + nationId: 1, + text: '조조가 독립하여 라는 군벌로 나서다', + format: 2, + year: 193, + month: 2, + }, + expect.objectContaining({ scope: LogScope.GENERAL, category: LogCategory.ACTION, generalId: 1 }), + expect.objectContaining({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + text: expect.stringContaining('작위보상'), + }), + ]) + ); + }); + + it('does not downgrade or pay rewards when the qualifying city count is below the current level', async () => { + const { world, reservedTurns, handler } = await buildHarness(3, 1); + + await handler([], { year: 193, month: 2, startyear: 190, currentEventID: 1, turnTime: new Date() }, event); + + expect(world.getNationById(1)).toMatchObject({ level: 3, gold: 10_000, rice: 20_000 }); + expect(reservedTurns.peekDirtyState().nationInitializationKeys).toEqual([]); + expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([]); + expect(world.peekDirtyState().logs).toEqual([]); + }); + + it('sets both rename permissions only on promotion to emperor', async () => { + const { world, handler } = await buildHarness(6, 21); + + await handler([], { year: 203, month: 1, startyear: 190, currentEventID: 1, turnTime: new Date() }, event); + + expect(world.getNationById(1)).toMatchObject({ + level: 7, + gold: 17_000, + rice: 27_000, + meta: { marker: 1, can_국기변경: 1, can_국호변경: 1 }, + }); + expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ + { userId: 'user-1', key: 'unifier', amount: 250 }, + ]); + }); + + it('does not duplicate a unique item reserved by an unfinished auction', async () => { + const { world, handler } = await buildHarness(0, 1, { + additionalOccupiedCounts: new Map([[uniqueHorse.key, 1]]), + }); + + await handler([], { year: 193, month: 2, startyear: 190, currentEventID: 1, turnTime: new Date() }, event); + + 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 }, + ]); + }); +}); + +describe.skipIf(!process.env.LEGACY_HIDDEN_SEED)('UpdateNationLevel legacy fixed-seed comparison', () => { + it('selects the same unique item as the isolated legacy fixture', async () => { + const itemModules = await loadItemModules([...ITEM_KEYS]); + const { world, handler } = await buildHarness(0, 2, { + hiddenSeed: process.env.LEGACY_HIDDEN_SEED!, + itemModules, + configConst: { maxUniqueItemLimit: [[-1, 1]] }, + }); + + await handler([], { year: 193, month: 2, startyear: 190, currentEventID: 1, turnTime: new Date() }, event); + + expect(world.getGeneralById(1)?.role.items).toEqual({ + horse: null, + weapon: null, + book: 'che_서적_13_관자', + item: null, + }); + }); +}); diff --git a/app/game-engine/test/monthlyNationLevelPersistence.integration.test.ts b/app/game-engine/test/monthlyNationLevelPersistence.integration.test.ts new file mode 100644 index 0000000..f6a7633 --- /dev/null +++ b/app/game-engine/test/monthlyNationLevelPersistence.integration.test.ts @@ -0,0 +1,312 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import type { City, ItemModule, Nation } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createUpdateNationLevelHandler } from '../src/turn/monthlyNationLevelAction.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.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_061; +const generalId = 990_061; +const cityIds = [990_061, 990_062] as const; +const userId = 'monthly-nation-level-user'; +const itemKey = 'test_monthly_nation_unique'; + +const uniqueHorse: ItemModule = { + key: itemKey, + name: '작위시험마', + rawName: '작위시험마', + info: '', + slot: 'horse', + cost: null, + buyable: false, + consumable: false, + reqSecu: 0, + unique: true, +}; + +const nation: Nation = { + id: nationId, + name: '작위저장국', + color: '#000000', + capitalCityId: cityIds[0], + chiefGeneralId: generalId, + gold: 10_000, + rice: 20_000, + power: 0, + level: 0, + typeCode: 'che_중립', + meta: {}, +}; + +const buildCity = (id: number): City => ({ + id, + name: `작위도시${id}`, + nationId, + level: 4, + state: 0, + population: 1_000, + populationMax: 2_000, + agriculture: 500, + agricultureMax: 1_000, + commerce: 500, + commerceMax: 1_000, + security: 500, + securityMax: 1_000, + supplyState: 1, + frontState: 0, + defence: 500, + defenceMax: 1_000, + wall: 500, + wallMax: 1_000, + meta: { trust: 50, region: 1 }, +}); + +const general: TurnGeneral = { + id: generalId, + userId, + name: '조조', + nationId, + cityId: cityIds[0], + troopId: 0, + stats: { leadership: 90, strength: 70, intelligence: 90 }, + experience: 0, + dedication: 0, + officerLevel: 12, + 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: 100, + crewTypeId: 1100, + train: 100, + atmos: 100, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 1_000, belong: 10 }, + turnTime: new Date('0193-01-01T00:00:00.000Z'), +}; + +const event: TurnEvent = { + id: 1, + targetCode: 'month', + priority: 1_000, + condition: true, + action: [['UpdateNationLevel']], + meta: {}, +}; + +integration('monthly nation level database 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: { OR: [{ nationId }, { generalId }] } }); + await db.nationTurn.deleteMany({ where: { nationId } }); + await db.inheritancePoint.deleteMany({ where: { userId } }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.city.deleteMany({ where: { id: { in: [...cityIds] } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + }); + + afterAll(async () => { + await db.logEntry.deleteMany({ where: { OR: [{ nationId }, { generalId }] } }); + await db.nationTurn.deleteMany({ where: { nationId } }); + await db.inheritancePoint.deleteMany({ where: { userId } }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.city.deleteMany({ where: { id: { in: [...cityIds] } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await closeDb?.(); + }); + + it('commits nation resources, chief turns, unique reward, logs, and unifier points together', async () => { + 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: nation.power, + level: nation.level, + typeCode: nation.typeCode, + meta: {}, + }, + }); + const cities = cityIds.map(buildCity); + await db.city.createMany({ + data: cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + nationId: city.nationId, + supplyState: city.supplyState, + frontState: city.frontState, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + trust: 50, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + region: 1, + conflict: {}, + meta: {}, + })), + }); + await db.general.create({ + data: { + id: general.id, + userId, + name: general.name, + nationId, + cityId: general.cityId, + troopId: 0, + npcState: 0, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + experience: 0, + dedication: 0, + officerLevel: 12, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 100, + crewTypeId: 1100, + train: 100, + atmos: 100, + age: 30, + turnTime: general.turnTime, + meta: general.meta, + }, + }); + await db.nationTurn.create({ + data: { + nationId, + officerLevel: 9, + turnIdx: 0, + actionCode: 'che_포상', + arg: { amount: 100 }, + }, + }); + const stateRow = await db.worldState.create({ + data: { + scenarioCode: 'monthly-nation-level-persistence', + currentYear: 193, + currentMonth: 2, + tickSeconds: 600, + config: {}, + meta: { hiddenSeed: 'nation-level-persistence', killturn: 1_000 }, + }, + }); + const state: TurnWorldState = { + id: stateRow.id, + currentYear: 193, + currentMonth: 2, + tickSeconds: 600, + lastTurnTime: new Date('2026-07-25T00:20:00.000Z'), + meta: { hiddenSeed: 'nation-level-persistence', killturn: 1_000 }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { allItems: { horse: { [itemKey]: 1 } }, maxUniqueItemLimit: [[-1, 1]] }, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals: [general], + cities, + nations: [nation], + troops: [], + diplomacy: [], + events: [event], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); + const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 30, maxNationTurns: 12 }); + await reservedTurns.loadAll(); + const handler = createUpdateNationLevelHandler({ + getWorld: () => world, + reservedTurns, + itemModules: [uniqueHorse], + }); + const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); + + try { + await handler( + [], + { year: 193, month: 2, startyear: 190, currentEventID: 1, turnTime: state.lastTurnTime }, + event + ); + await dbHooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + + expect(await db.nation.findUniqueOrThrow({ where: { id: nationId } })).toMatchObject({ + level: 2, + gold: 12_000, + rice: 22_000, + }); + expect(await db.general.findUniqueOrThrow({ where: { id: generalId } })).toMatchObject({ + horseCode: itemKey, + }); + expect(await db.nationTurn.count({ where: { nationId } })).toBe(36); + expect( + await db.nationTurn.findUniqueOrThrow({ + where: { + nationId_officerLevel_turnIdx: { + nationId, + officerLevel: 9, + turnIdx: 0, + }, + }, + }) + ).toMatchObject({ actionCode: 'che_포상', arg: { amount: 100 } }); + expect( + await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'unifier' } } }) + ).toMatchObject({ + value: 500, + }); + expect(await db.logEntry.count({ where: { OR: [{ nationId }, { generalId }] } })).toBeGreaterThanOrEqual(3); + } finally { + await dbHooks.close(); + await db.worldState.delete({ where: { id: stateRow.id } }); + } + }); +});