From 45ba9604740c0a2ca8e451f40b7360974e1381f1 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 08:42:57 +0000 Subject: [PATCH] feat: complete general turn lifecycle --- app/game-api/src/router/join/index.ts | 88 +++- app/game-engine/src/turn/databaseHooks.ts | 13 + .../turn/generalTurnLifecyclePersistence.ts | 320 ++++++++++++++ app/game-engine/src/turn/inMemoryWorld.ts | 44 +- .../src/turn/reservedTurnHandler.ts | 409 +++++++++++++++++- app/game-engine/src/turn/types.ts | 4 + app/game-engine/src/turn/worldLoader.ts | 4 + .../test/generalTurnLifecycle.test.ts | 310 +++++++++++++ ...rnLifecyclePersistence.integration.test.ts | 216 +++++++++ packages/infra/prisma/game.prisma | 14 + .../migration.sql | 13 + packages/infra/src/db.ts | 1 + packages/infra/src/turnEngineDb.ts | 6 + 13 files changed, 1412 insertions(+), 30 deletions(-) create mode 100644 app/game-engine/src/turn/generalTurnLifecyclePersistence.ts create mode 100644 app/game-engine/test/generalTurnLifecycle.test.ts create mode 100644 app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts create mode 100644 packages/infra/prisma/migrations/20260725001000_add_general_access_log/migration.sql diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index bb98a8a..f6b65f5 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -517,6 +517,18 @@ export const joinRouter = router({ }, }, }); + await db.generalAccessLog.upsert({ + where: { generalId: general.id }, + update: { + userId, + lastRefresh: new Date(), + }, + create: { + generalId: general.id, + userId, + lastRefresh: new Date(), + }, + }); if (inheritRequiredPoint > 0) { await setInheritancePoint(db, userId, 'previous', currentPoint - inheritRequiredPoint); @@ -618,25 +630,67 @@ export const joinRouter = router({ }); } - const updated = await ctx.db.general.updateMany({ - where: { - id: input.generalId, - userId: null, - npcState: { gte: 2 }, - }, - data: { - userId, - npcState: 1, - updatedAt: new Date(), - }, - }); + await ctx.db.$transaction!(async (db) => { + const [candidate, worldState] = await Promise.all([ + db.general.findUnique({ + where: { id: input.generalId }, + select: { npcState: true, meta: true }, + }), + db.worldState.findFirst({ + select: { currentYear: true, currentMonth: true }, + }), + ]); + if (!candidate || candidate.npcState < 2 || !worldState) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: '빙의 가능한 장수를 찾지 못했습니다.', + }); + } - if (updated.count === 0) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: '빙의 가능한 장수를 찾지 못했습니다.', + const now = new Date(); + const updated = await db.general.updateMany({ + where: { + id: input.generalId, + userId: null, + npcState: candidate.npcState, + }, + data: { + userId, + npcState: 1, + meta: { + ...asRecord(candidate.meta), + npc_org: candidate.npcState, + owner_name: ctx.auth?.user.displayName ?? '', + pickYearMonth: worldState.currentYear * 12 + worldState.currentMonth - 1, + killturn: 6, + defence_train: 80, + }, + updatedAt: now, + }, }); - } + if (updated.count === 0) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: '빙의 가능한 장수를 찾지 못했습니다.', + }); + } + await db.generalAccessLog.upsert({ + where: { generalId: input.generalId }, + update: { + userId, + lastRefresh: now, + refresh: 0, + refreshTotal: 0, + refreshScore: 0, + refreshScoreTotal: 0, + }, + create: { + generalId: input.generalId, + userId, + lastRefresh: now, + }, + }); + }); return { ok: true }; }), diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index bba692c..177e333 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -28,6 +28,7 @@ import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; import { buildDiplomacyMeta } from '@sammo-ts/logic'; import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js'; +import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; @@ -119,6 +120,7 @@ const buildRankRows = ( const buildGeneralUpdate = ( general: ReturnType['generals'][number] ): TurnEngineGeneralUpdateInput => ({ + userId: general.userId ?? null, name: general.name, nationId: general.nationId, cityId: general.cityId, @@ -236,6 +238,7 @@ const buildNationUpdate = ( name: nation.name, color: nation.color, capitalCityId: nation.capitalCityId, + chiefGeneralId: nation.chiefGeneralId, gold: nation.gold, rice: nation.rice, level: nation.level, @@ -336,6 +339,7 @@ export const createDatabaseTurnHooks = async ( createdNations, createdTroops, createdDiplomacy, + lifecycleEvents, } = changes; const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); @@ -354,6 +358,12 @@ export const createDatabaseTurnHooks = async ( const meta = asRecord(state.meta); const serverId = typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default'; + await persistGeneralLifecycleEvents( + prisma, + lifecycleEvents, + meta, + asRecord(world.getScenarioConfig().const) + ); if (deletedNationSnapshots.length > 0) { const nationIds = deletedNationSnapshots.map((snapshot) => snapshot.nation.id); @@ -466,6 +476,9 @@ export const createDatabaseTurnHooks = async ( } if (deletedGenerals.length > 0) { + await prisma.generalTurn.deleteMany({ + where: { generalId: { in: deletedGenerals } }, + }); await prisma.general.deleteMany({ where: { id: { in: deletedGenerals } }, }); diff --git a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts new file mode 100644 index 0000000..a58cfb5 --- /dev/null +++ b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts @@ -0,0 +1,320 @@ +import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common'; +import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; + +import type { GeneralLifecycleEvent } from './inMemoryWorld.js'; + +const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; + +const readNumber = (record: Record, key: string): number => { + const value = record[key]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +}; + +const readWorldNumber = (record: Record, key: string, fallback: number): number => { + const value = readNumber(record, key); + return value === 0 && record[key] === undefined ? fallback : Math.floor(value); +}; + +const computeDexPoint = (meta: Record): number => { + let total = 0; + for (let dex = 1; dex <= 5; dex += 1) { + total += readNumber(meta, `dex${dex}`); + } + return total * 0.001; +}; + +const settleInheritance = async ( + prisma: GamePrisma.TransactionClient, + event: GeneralLifecycleEvent, + worldMeta: Record, + isRebirth: boolean, + configConst: Record +): Promise => { + const userId = event.before.userId; + if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) { + return; + } + const meta = asRecord(event.before.meta); + if (event.before.npcState === 1) { + const pickYearMonth = readNumber(meta, 'pickYearMonth'); + if (pickYearMonth === 0 && meta.pickYearMonth === undefined) { + return; + } + const pickYear = Math.floor(pickYearMonth / 12); + const scenarioMeta = asRecord(worldMeta.scenarioMeta); + const startYear = readWorldNumber( + worldMeta, + 'startYear', + readWorldNumber(worldMeta, 'startyear', readWorldNumber(scenarioMeta, 'startYear', event.year)) + ); + if ((event.year - pickYear) * 2 <= event.year - startYear) { + return; + } + } + + const [rows, rankRows] = await Promise.all([ + prisma.inheritancePoint.findMany({ + where: { userId }, + select: { key: true, value: true }, + }), + prisma.rankData.findMany({ + where: { generalId: event.generalId }, + select: { type: true, value: true }, + }), + ]); + const points = new Map(rows.map((row) => [row.key, row.value])); + const ranks = new Map(rankRows.map((row) => [row.type, row.value])); + const rank = (key: string): number => ranks.get(key) ?? readNumber(meta, `rank_${key}`); + const previous = points.get('previous') ?? 0; + const refund = + (meta.inheritRandomUnique + ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) + : 0) + + (meta.inheritSpecificSpecialWar + ? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000) + : 0); + const lived = readNumber(meta, 'inherit_lived_month'); + const maxBelong = readNumber(meta, 'inherit_max_belong') * 10; + const maxDomestic = readNumber(meta, 'max_domestic_critical'); + const active = readNumber(meta, 'inherit_active_action') * 3; + const combat = rank('warnum') * 5; + const sabotage = (ranks.get('firenum') ?? readNumber(meta, 'firenum')) * 20; + const dex = computeDexPoint(meta); + const unifier = points.get('unifier') ?? 0; + const earned = isRebirth + ? lived + active + combat + sabotage + dex * 0.5 + : lived + maxBelong + maxDomestic + active + combat + sabotage + dex + unifier; + const total = Math.trunc(previous + refund + earned); + + await prisma.inheritancePoint.upsert({ + where: { userId_key: { userId, key: 'previous' } }, + update: { value: total }, + create: { userId, key: 'previous', value: total }, + }); + await prisma.inheritancePoint.deleteMany({ + where: { + userId, + key: isRebirth ? { notIn: ['previous', 'unifier'] } : { not: 'previous' }, + }, + }); + const serverId = + typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; + await prisma.inheritanceResult.create({ + data: { + serverId, + owner: userId, + generalId: event.generalId, + year: event.year, + month: event.month, + value: asJson({ + previous, + refund, + lived_month: lived, + max_belong: maxBelong, + max_domestic_critical: maxDomestic, + active_action: active, + combat, + sabotage, + dex: isRebirth ? dex * 0.5 : dex, + unifier: isRebirth ? 0 : unifier, + rebirth: isRebirth, + }), + }, + }); + await prisma.inheritanceLog.create({ + data: { + userId, + year: event.year, + month: event.month, + text: `${isRebirth ? '은퇴' : '사망'} 정산: ${total.toLocaleString()} 포인트`, + }, + }); +}; + +const computeRate = (numerator: number, denominator: number): number => + denominator > 0 ? numerator / denominator : 0; + +const settleHall = async ( + prisma: GamePrisma.TransactionClient, + event: GeneralLifecycleEvent, + worldMeta: Record +): Promise => { + const isUnited = readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0)); + if (isUnited !== 0) { + return; + } + const [ranks, nation, historyCount] = await Promise.all([ + prisma.rankData.findMany({ + where: { generalId: event.generalId }, + select: { type: true, value: true }, + }), + event.before.nationId > 0 + ? prisma.nation.findUnique({ + where: { id: event.before.nationId }, + select: { name: true, color: true }, + }) + : null, + prisma.gameHistory.count(), + ]); + const rank = new Map(ranks.map((row) => [row.type, row.value])); + const value = (key: string): number => rank.get(key) ?? readNumber(asRecord(event.before.meta), key); + const warnum = value('warnum'); + const tt = value('ttw') + value('ttd') + value('ttl'); + const tl = value('tlw') + value('tld') + value('tll'); + const ts = value('tsw') + value('tsd') + value('tsl'); + const ti = value('tiw') + value('tid') + value('til'); + const calc: Record = { + winrate: computeRate(value('killnum'), warnum), + killrate: computeRate(value('killcrew'), Math.max(1, value('deathcrew'))), + killrate_person: computeRate(value('killcrew_person'), Math.max(1, value('deathcrew_person'))), + ttrate: computeRate(value('ttw'), Math.max(1, tt)), + tlrate: computeRate(value('tlw'), Math.max(1, tl)), + tsrate: computeRate(value('tsw'), Math.max(1, ts)), + tirate: computeRate(value('tiw'), Math.max(1, ti)), + betrate: computeRate(value('betwingold'), Math.max(1, value('betgold'))), + }; + const serverId = + typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; + const season = readWorldNumber(worldMeta, 'season', 1); + const scenario = readWorldNumber(worldMeta, 'scenarioId', 0); + const scenarioName = + typeof asRecord(worldMeta.scenarioMeta).title === 'string' ? String(asRecord(worldMeta.scenarioMeta).title) : ''; + const aux = { + name: event.before.name, + nationName: nation?.name ?? '재야', + bgColor: nation?.color ?? '#000000', + fgColor: nation?.color ?? '#000000', + startTime: typeof worldMeta.starttime === 'string' ? worldMeta.starttime : null, + unitedTime: new Date().toISOString(), + ownerName: event.before.userId ?? null, + serverID: serverId, + serverIdx: historyCount, + scenarioName, + }; + + for (const type of HALL_OF_FAME_TYPES) { + let hallValue = + type === 'experience' + ? event.before.experience + : type === 'dedication' + ? event.before.dedication + : type.endsWith('rate') + ? (calc[type] ?? 0) + : value(type); + if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue; + if (type === 'ttrate' && tt < 50) continue; + if (type === 'tlrate' && tl < 50) continue; + if (type === 'tsrate' && ts < 50) continue; + if (type === 'tirate' && ti < 50) continue; + if (type === 'betrate' && value('betgold') < 1000) continue; + if (!Number.isFinite(hallValue) || hallValue <= 0) continue; + hallValue = Number(hallValue); + + const existing = await prisma.hallOfFame.findUnique({ + where: { + serverId_type_generalNo: { + serverId, + type: type as HallOfFameType, + generalNo: event.generalId, + }, + }, + }); + if (existing) { + if (hallValue > existing.value) { + await prisma.hallOfFame.update({ + where: { id: existing.id }, + data: { value: hallValue, aux: asJson(aux) }, + }); + } + continue; + } + await prisma.hallOfFame.createMany({ + data: [ + { + serverId, + season, + scenario, + generalNo: event.generalId, + type, + value: hallValue, + owner: event.before.userId ?? null, + aux: asJson(aux), + }, + ], + skipDuplicates: true, + }); + } +}; + +const archiveDeletedGeneral = async ( + prisma: GamePrisma.TransactionClient, + event: GeneralLifecycleEvent, + worldMeta: Record +): Promise => { + const serverId = + typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; + const data = { + ...event.before, + turnTime: event.before.turnTime.toISOString(), + recentWarTime: event.before.recentWarTime?.toISOString() ?? null, + }; + await prisma.oldGeneral.upsert({ + where: { by_no: { serverId, generalNo: event.generalId } }, + update: { + owner: event.before.userId ?? null, + name: event.before.name, + lastYearMonth: event.year * 100 + event.month, + turnTime: event.before.turnTime, + data: asJson(data), + }, + create: { + serverId, + generalNo: event.generalId, + owner: event.before.userId ?? null, + name: event.before.name, + lastYearMonth: event.year * 100 + event.month, + turnTime: event.before.turnTime, + data: asJson(data), + }, + }); +}; + +export const persistGeneralLifecycleEvents = async ( + prisma: GamePrisma.TransactionClient, + events: GeneralLifecycleEvent[], + worldMeta: Record, + configConst: Record +): Promise => { + if (events.length === 0) { + return; + } + await prisma.generalAccessLog.updateMany({ + where: { generalId: { in: events.map((event) => event.generalId) } }, + data: { refreshScore: 0 }, + }); + + for (const event of events) { + if (event.outcome === 'detached' || event.outcome === 'deleted') { + await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } }); + } + if (event.outcome === 'deleted') { + await archiveDeletedGeneral(prisma, event, worldMeta); + await settleInheritance(prisma, event, worldMeta, false, configConst); + } + if (event.outcome === 'retired') { + await settleHall(prisma, event, worldMeta); + await settleInheritance(prisma, event, worldMeta, true, configConst); + await prisma.rankData.updateMany({ + where: { generalId: event.generalId }, + data: { value: 0 }, + }); + } + } +}; diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index bdf02a3..0e33908 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -42,6 +42,20 @@ export interface GeneralTurnResult { nations?: Nation[]; troops?: Troop[]; }; + deleted?: { + general: boolean; + troopIds?: number[]; + }; + lifecycleEvent?: GeneralLifecycleEvent; +} + +export interface GeneralLifecycleEvent { + generalId: number; + outcome: 'active' | 'detached' | 'deleted' | 'retired'; + before: TurnGeneral; + after?: TurnGeneral; + year: number; + month: number; } export interface GeneralTurnHandler { @@ -85,6 +99,7 @@ export interface TurnWorldChanges { createdNations: Nation[]; createdTroops: Troop[]; createdDiplomacy: TurnDiplomacy[]; + lifecycleEvents: GeneralLifecycleEvent[]; } const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => { @@ -250,6 +265,7 @@ export class InMemoryTurnWorld { }> = []; private readonly logs: LogEntryDraft[] = []; private readonly messages: MessageDraft[] = []; + private readonly lifecycleEvents: GeneralLifecycleEvent[] = []; private readonly scenarioConfig: ScenarioConfig; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; @@ -572,12 +588,14 @@ export class InMemoryTurnWorld { }); const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, this.schedule); - const nextGeneral = { - ...(result.general ?? currentGeneral), - turnTime: nextTurnAt, - }; - this.generals.set(nextGeneral.id, nextGeneral); - this.dirtyGeneralIds.add(nextGeneral.id); + if (!result.deleted?.general) { + const nextGeneral = { + ...(result.general ?? currentGeneral), + turnTime: nextTurnAt, + }; + this.generals.set(nextGeneral.id, nextGeneral); + this.dirtyGeneralIds.add(nextGeneral.id); + } if (result.city) { this.cities.set(result.city.id, result.city); @@ -675,6 +693,17 @@ export class InMemoryTurnWorld { } } } + if (result.deleted?.troopIds) { + for (const troopId of result.deleted.troopIds) { + this.removeTroop(troopId); + } + } + if (result.deleted?.general) { + this.removeGeneral(currentGeneral.id); + } + if (result.lifecycleEvent) { + this.lifecycleEvents.push(result.lifecycleEvent); + } this.removeCollapsedNations(); @@ -751,6 +780,7 @@ export class InMemoryTurnWorld { const deletedNationSnapshots = this.deletedNationSnapshots.slice(); const logs = this.logs.slice(); const messages = this.messages.slice(); + const lifecycleEvents = this.lifecycleEvents.slice(); return { generals, @@ -768,6 +798,7 @@ export class InMemoryTurnWorld { createdNations, createdTroops, createdDiplomacy, + lifecycleEvents, }; } @@ -791,6 +822,7 @@ export class InMemoryTurnWorld { this.deletedNationSnapshots.splice(0, changes.deletedNationSnapshots.length); this.logs.splice(0, changes.logs.length); this.messages.splice(0, changes.messages.length); + this.lifecycleEvents.splice(0, changes.lifecycleEvents.length); } consumeDirtyState(): TurnWorldChanges { diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 22eb6b5..ad80532 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -17,7 +17,9 @@ import type { import { DEFAULT_TURN_COMMAND_PROFILE, GeneralTurnCommandLoader, + GeneralActionPipeline, NationTurnCommandLoader, + createGeneralTriggerContext, defaultActionContextBuilder, evaluateConstraints, resolveGeneralAction, @@ -28,6 +30,7 @@ import { loadItemModules, resolveUniqueConfig, rollUniqueLottery, + getNextTurnAt, type ItemModule, type UniqueLotteryRunner, } from '@sammo-ts/logic'; @@ -101,6 +104,90 @@ const serializeSeed = (...values: Array): string => const joinYearMonth = (year: number, month: number): number => year * 12 + month - 1; +const readConfigNumber = (config: ScenarioConfig, key: string, fallback: number): number => { + const value = asRecord(config.const)[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +}; + +const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({ + ...general, + stats: { ...general.stats }, + role: { + ...general.role, + items: { ...general.role.items }, + }, + meta: { ...general.meta }, + triggerState: { + ...general.triggerState, + flags: { ...general.triggerState.flags }, + counters: { ...general.triggerState.counters }, + modifiers: { ...general.triggerState.modifiers }, + meta: { ...general.triggerState.meta }, + }, +}); + +const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => { + const meta = { ...general.meta }; + for (const key of [ + 'firenum', + 'rank_warnum', + 'rank_killnum', + 'rank_deathnum', + 'rank_occupied', + 'rank_killcrew', + 'rank_deathcrew', + 'rank_killcrew_person', + 'rank_deathcrew_person', + 'rank_ttw', + 'rank_ttd', + 'rank_ttl', + 'rank_ttg', + 'rank_ttp', + 'rank_tlw', + 'rank_tld', + 'rank_tll', + 'rank_tlg', + 'rank_tlp', + 'rank_tsw', + 'rank_tsd', + 'rank_tsl', + 'rank_tsg', + 'rank_tsp', + 'rank_tiw', + 'rank_tid', + 'rank_til', + 'rank_tig', + 'rank_tip', + 'rank_betgold', + 'rank_betwin', + 'rank_betwingold', + 'specage', + 'specage2', + ]) { + meta[key] = 0; + } + for (let dex = 1; dex <= 5; dex += 1) { + const key = `dex${dex}`; + meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5); + } + meta.inherit_lived_month = 0; + meta.inherit_active_action = 0; + + return { + ...general, + stats: { + leadership: Math.max(10, Math.round(general.stats.leadership * 0.85)), + strength: Math.max(10, Math.round(general.stats.strength * 0.85)), + intelligence: Math.max(10, Math.round(general.stats.intelligence * 0.85)), + }, + injury: 0, + experience: Math.round(general.experience * 0.5), + dedication: Math.round(general.dedication * 0.5), + age: 20, + meta, + }; +}; + type NationLastTurn = { command: string; arg?: Record; @@ -959,12 +1046,108 @@ export const createReservedTurnHandler = async (options: { }; }; - if (currentNation && currentGeneral.officerLevel >= 5) { + const lifecycleBefore = cloneTurnGeneral(currentGeneral); + currentGeneral = cloneTurnGeneral(currentGeneral); + currentGeneral.meta.inherit_lived_month = + readMetaNumber(currentGeneral.meta, 'inherit_lived_month', 0) + 1; + + const preprocessRng = new RandUtil( + new LiteHashDRBG( + serializeSeed( + buildSeedBase(context.world), + 'preprocess', + context.world.currentYear, + context.world.currentMonth, + currentGeneral.id + ) + ) + ); + const cityGeneralCopies = new Map(); + for (const general of worldView?.listGenerals() ?? []) { + cityGeneralCopies.set( + general.id, + general.id === currentGeneral.id ? currentGeneral : cloneTurnGeneral(general) + ); + } + cityGeneralCopies.set(currentGeneral.id, currentGeneral); + const preTurnPipeline = new GeneralActionPipeline(env.generalActionModules ?? []); + const preTurnContext = createGeneralTriggerContext({ + general: currentGeneral, + nation: currentNation, + worldView: { + listGenerals: () => Array.from(cityGeneralCopies.values()), + listGeneralsByCity: (cityId) => + Array.from(cityGeneralCopies.values()).filter((general) => general.cityId === cityId), + }, + rng: preprocessRng, + log: { + push: (message) => logs.push(createActionLog(message)), + }, + }); + preTurnPipeline.getPreTurnExecuteTriggerList(preTurnContext).fire(preTurnContext, baseConstraintEnv); + if (currentGeneral.injury > 0 && !preTurnContext.skill.has('pre.부상경감')) { + currentGeneral.injury = Math.max(0, currentGeneral.injury - 10); + preTurnContext.skill.activate('pre.부상경감'); + } + if (currentGeneral.crew >= 100) { + const consumeRice = Math.trunc(currentGeneral.crew / 100); + if (consumeRice <= currentGeneral.rice) { + currentGeneral.rice -= consumeRice; + } else { + const releasedCrew = Math.trunc( + preTurnPipeline.onCalcDomestic(preTurnContext, '징집인구', 'score', currentGeneral.crew) + ); + if (currentCity) { + currentCity = { + ...currentCity, + population: currentCity.population + releasedCrew, + meta: { ...currentCity.meta }, + }; + worldOverlay?.syncCity(currentCity); + } + currentGeneral.crew = 0; + currentGeneral.rice = 0; + logs.push(createActionLog('군량이 모자라 병사들이 소집해제되었습니다!')); + preTurnContext.skill.activate('pre.소집해제'); + } + preTurnContext.skill.activate('pre.병력군량소모'); + } + for (const [generalId, next] of cityGeneralCopies) { + if (generalId === currentGeneral.id) { + continue; + } + const previous = worldView?.getGeneralById(generalId); + if (!previous || previous.injury === next.injury) { + continue; + } + patches.generals.push({ id: generalId, patch: { injury: next.injury } }); + worldOverlay?.applyGeneralPatch(generalId, { injury: next.injury }); + } + worldOverlay?.syncGeneral(currentGeneral); + + const blockCode = readMetaNumber(currentGeneral.meta, 'block', 0); + const isBlocked = blockCode === 2 || blockCode === 3; + if (isBlocked) { + currentGeneral.meta.killturn = Math.max(0, currentGeneral.meta.killturn - 1); + logs.push( + createActionLog( + blockCode === 2 + ? '현재 멀티, 또는 비매너로 인한블럭 대상자입니다.' + : '현재 악성유저로 분류되어 블럭 대상자입니다.' + ) + ); + } + + let hasReservedTurn = false; + if (!isBlocked && currentNation && currentGeneral.officerLevel >= 5) { let nationCommand = options.reservedTurns.getNationTurn( currentNation.id, currentGeneral.officerLevel, 0 ); + if (nationCommand.action !== DEFAULT_ACTION) { + hasReservedTurn = true; + } let nationAiState: ReturnType | undefined; if (worldView && shouldUseAi(currentGeneral, context.world)) { const ai = new GeneralAI({ @@ -1003,10 +1186,17 @@ export const createReservedTurnHandler = async (options: { }); options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1); } + if (isBlocked && currentNation && currentGeneral.officerLevel >= 5) { + options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1); + } let generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0); + if (!isBlocked && generalCommand.action !== DEFAULT_ACTION) { + hasReservedTurn = true; + } let generalAiState: ReturnType | undefined; - if (worldView && shouldUseAi(currentGeneral, context.world)) { + let generalAutorunMode = false; + if (!isBlocked && worldView && shouldUseAi(currentGeneral, context.world)) { const ai = new GeneralAI({ general: currentGeneral, city: currentCity, @@ -1026,11 +1216,20 @@ export const createReservedTurnHandler = async (options: { }); const candidate = ai.chooseGeneralTurn(generalCommand); if (candidate) { + generalAutorunMode = + candidate.action !== generalCommand.action || + JSON.stringify(candidate.args ?? {}) !== JSON.stringify(generalCommand.args ?? {}); generalCommand = { action: candidate.action, args: candidate.args }; } generalAiState = ai.getDebugState(); } - const generalResult = runAction('general', generalDefinitions, generalFallback, generalCommand, true); + const generalResult = isBlocked + ? { + actionKey: DEFAULT_ACTION, + usedFallback: true, + blockedReason: '블럭 대상자입니다.', + } + : runAction('general', generalDefinitions, generalFallback, generalCommand, true); options.onActionResolved?.({ kind: 'general', generalId: currentGeneral.id, @@ -1041,15 +1240,25 @@ export const createReservedTurnHandler = async (options: { ...(generalResult.blockedReason ? { blockedReason: generalResult.blockedReason } : {}), ...(generalAiState ? { aiState: generalAiState } : {}), }); - const nextTurnAt = generalResult.nextTurnAt; + let nextTurnAt = 'nextTurnAt' in generalResult ? generalResult.nextTurnAt : undefined; options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1); const worldMeta = asRecord(context.world.meta); - if (currentGeneral.npcState < 2 && !(typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0)) { + if (!isBlocked) { const meta = { ...currentGeneral.meta }; - const lived = typeof meta.inherit_lived_month === 'number' ? meta.inherit_lived_month : 0; + const currentKillturn = readMetaNumber(meta, 'killturn', 0); + const worldKillturn = readMetaNumber(worldMeta, 'killturn', currentKillturn); + if ( + currentGeneral.npcState >= 2 || + currentKillturn > worldKillturn || + generalAutorunMode || + generalCommand.action === DEFAULT_ACTION + ) { + meta.killturn = Math.max(0, currentKillturn - 1); + } else { + meta.killturn = worldKillturn; + } const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; - meta.inherit_lived_month = lived + 1; if (generalResult.actionKey !== DEFAULT_ACTION) { meta.inherit_active_action = active + 1; } else { @@ -1059,6 +1268,176 @@ export const createReservedTurnHandler = async (options: { worldOverlay?.syncGeneral(currentGeneral); } + const incDefSettingChange = readConfigNumber(options.scenarioConfig, 'incDefSettingChange', 3); + const maxDefSettingChange = readConfigNumber(options.scenarioConfig, 'maxDefSettingChange', 9); + currentGeneral = { + ...currentGeneral, + meta: { + ...currentGeneral.meta, + myset: Math.min( + maxDefSettingChange, + readMetaNumber(currentGeneral.meta, 'myset', 0) + incDefSettingChange + ), + }, + }; + + const autorunUser = asRecord(worldMeta.autorun_user); + const autorunLimitMinutes = readMetaNumber(autorunUser, 'limit_minutes', 0); + if (hasReservedTurn && currentGeneral.npcState < 2 && autorunLimitMinutes > 0) { + const turnMinutes = Math.max(1, Math.round(context.world.tickSeconds / 60)); + currentGeneral.meta.autorun_limit = + joinYearMonth(context.world.currentYear, context.world.currentMonth) + + Math.trunc(autorunLimitMinutes / turnMinutes); + } + + const nextTurnTimeBase = readMetaNumber(currentGeneral.meta, 'nextTurnTimeBase', -1); + if (nextTurnTimeBase >= 0) { + const alignedNextTurn = nextTurnAt ?? getNextTurnAt(currentGeneral.turnTime, context.schedule); + nextTurnAt = new Date(alignedNextTurn.getTime() + nextTurnTimeBase * 1000); + delete currentGeneral.meta.nextTurnTimeBase; + } + + let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = 'active'; + let deleteGeneral = false; + const deletedTroopIds: number[] = []; + const lifecycleSnapshot = cloneTurnGeneral(currentGeneral); + if (currentGeneral.meta.killturn <= 0) { + if ( + currentGeneral.npcState === 1 && + typeof currentGeneral.deadYear === 'number' && + currentGeneral.deadYear > context.world.currentYear + ) { + const npcOrg = readMetaNumber(currentGeneral.meta, 'npc_org', 2); + const ownerName = + typeof currentGeneral.meta.owner_name === 'string' + ? currentGeneral.meta.owner_name + : currentGeneral.userId; + logs.push( + createActionLog( + `${ownerName ?? '사용자'}이 ${currentGeneral.name}의 육체에서 유체이탈합니다!` + ) + ); + currentGeneral = { + ...currentGeneral, + userId: null, + npcState: npcOrg, + meta: { + ...currentGeneral.meta, + killturn: (currentGeneral.deadYear - context.world.currentYear) * 12, + defence_train: 80, + owner_name: '', + }, + }; + lifecycleOutcome = 'detached'; + } else { + if (currentGeneral.officerLevel === 12 && currentNation && worldView) { + const candidates = worldView + .listGenerals() + .filter( + (candidate) => + candidate.id !== currentGeneral.id && + candidate.nationId === currentGeneral.nationId && + candidate.officerLevel !== 12 && + candidate.npcState !== 5 + ); + let successor: TurnGeneral | undefined; + const fiction = readMetaNumber(worldMeta, 'fiction', 0); + if ( + fiction === 0 && + currentGeneral.npcState > 0 && + typeof currentGeneral.affinity === 'number' + ) { + const npcCandidates = candidates.filter( + (candidate) => + candidate.npcState >= 1 && + candidate.npcState <= 3 && + typeof candidate.affinity === 'number' + ); + const affinityDistance = (candidate: TurnGeneral): number => { + const distance = Math.abs((candidate.affinity ?? 0) - (currentGeneral.affinity ?? 0)); + return distance > 75 ? 150 - distance : distance; + }; + const minDistance = Math.min(...npcCandidates.map(affinityDistance)); + const nearest = npcCandidates.filter( + (candidate) => affinityDistance(candidate) === minDistance + ); + if (nearest.length > 0) { + const rng = new RandUtil( + new LiteHashDRBG( + serializeSeed( + buildSeedBase(context.world), + 'NextNPCRuler', + context.world.currentYear, + context.world.currentMonth, + currentGeneral.id + ) + ) + ); + successor = rng.choice(nearest); + } + } + successor ??= candidates + .filter((candidate) => candidate.officerLevel >= 9) + .sort((left, right) => right.officerLevel - left.officerLevel || left.id - right.id)[0]; + successor ??= candidates.sort( + (left, right) => right.dedication - left.dedication || left.id - right.id + )[0]; + if (successor) { + patches.generals.push({ + id: successor.id, + patch: { officerLevel: 12 }, + }); + currentNation = { + ...currentNation, + chiefGeneralId: successor.id, + }; + logs.push( + createActionLog( + `${successor.name}이 ${currentNation.name}의 유지를 이어 받았습니다` + ) + ); + } + } + if (currentGeneral.troopId === currentGeneral.id) { + deletedTroopIds.push(currentGeneral.id); + for (const member of worldView?.listGenerals() ?? []) { + if (member.id !== currentGeneral.id && member.troopId === currentGeneral.id) { + patches.generals.push({ id: member.id, patch: { troopId: 0 } }); + } + } + } + if (currentNation) { + const gennum = readMetaNumber(asRecord(currentNation.meta), 'gennum', 0); + currentNation = { + ...currentNation, + meta: { + ...currentNation.meta, + gennum: Math.max(0, gennum - 1), + }, + }; + } + deleteGeneral = true; + lifecycleOutcome = 'deleted'; + } + } + + const retirementYear = readConfigNumber(options.scenarioConfig, 'retirementYear', 80); + if (!deleteGeneral && currentGeneral.age >= retirementYear && currentGeneral.npcState === 0) { + currentGeneral = resetRetiredGeneral(currentGeneral); + lifecycleOutcome = 'retired'; + logs.push( + createActionLog('나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.') + ); + } + + currentGeneral = { + ...currentGeneral, + triggerState: { + ...currentGeneral.triggerState, + flags: {}, + }, + }; + const result: GeneralTurnResult = { general: currentGeneral, city: currentCity, @@ -1075,6 +1454,22 @@ export const createReservedTurnHandler = async (options: { ...(createdNations.length > 0 ? { nations: createdNations } : {}), } : undefined, + ...(deleteGeneral + ? { + deleted: { + general: true, + ...(deletedTroopIds.length > 0 ? { troopIds: deletedTroopIds } : {}), + }, + } + : undefined), + lifecycleEvent: { + generalId: currentGeneral.id, + outcome: lifecycleOutcome, + before: lifecycleOutcome === 'active' ? lifecycleBefore : lifecycleSnapshot, + ...(deleteGeneral ? {} : { after: currentGeneral }), + year: context.world.currentYear, + month: context.world.currentMonth, + }, }; return result; diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 499e80f..5905ca5 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -20,6 +20,10 @@ export interface TurnWorldState { } export interface TurnGeneral extends General { + userId?: string | null; + bornYear?: number; + deadYear?: number; + affinity?: number | null; turnTime: Date; recentWarTime?: Date | null; } diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 5fa9177..79b690d 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -161,6 +161,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => { return { meta: { ...meta, killturn } as TurnGeneral['meta'] }; })(), id: row.id, + userId: row.userId, name: row.name, nationId: row.nationId, cityId: row.cityId, @@ -188,6 +189,9 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => { atmos: row.atmos, age: row.age, npcState: row.npcState, + bornYear: row.bornYear, + deadYear: row.deadYear, + affinity: row.affinity, triggerState: { flags: {}, counters: {}, diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts new file mode 100644 index 0000000..33c2b4e --- /dev/null +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest'; +import type { TurnSchedule } from '@sammo-ts/logic'; + +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnTestHarness } from './helpers/turnTestHarness.js'; + +const start = new Date('0200-01-01T00:00:00.000Z'); +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; +const map = { + id: 'general-lifecycle', + name: '장수 lifecycle', + cities: [ + { + id: 1, + name: '테스트성', + level: 1, + region: 1, + position: { x: 0, y: 0 }, + connections: [], + max: { + population: 50_000, + agriculture: 1_000, + commerce: 1_000, + security: 1_000, + defence: 1_000, + wall: 1_000, + }, + initial: { + population: 10_000, + agriculture: 500, + commerce: 500, + security: 500, + defence: 500, + wall: 500, + }, + }, + ], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, +}; + +const makeGeneral = (patch: Partial = {}): TurnGeneral => ({ + id: 1, + userId: 'user-1', + name: '테스트장수', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + experience: 100, + dedication: 80, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 2_000, + rice: 2_000, + crew: 0, + crewTypeId: 1, + train: 40, + atmos: 40, + age: 30, + npcState: 0, + bornYear: 170, + deadYear: 260, + affinity: 50, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + turnTime: start, + ...patch, +}); + +const makeSnapshot = (generals: TurnGeneral[]): TurnWorldSnapshot => ({ + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { + develCost: 100, + trainDelta: 30, + atmosDelta: 30, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + initialNationGenLimit: 10, + incDefSettingChange: 3, + maxDefSettingChange: 9, + retirementYear: 80, + }, + environment: { mapName: map.id, unitSet: 'test' }, + }, + scenarioMeta: { + title: '장수 lifecycle', + startYear: 200, + life: null, + fiction: 0, + history: [], + ignoreDefaultEvents: false, + }, + map, + unitSet: { id: 'test', name: 'test', crewTypes: [] }, + nations: [ + { + id: 1, + name: '테스트국', + color: '#000000', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10_000, + rice: 10_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: { gennum: generals.length, tech: 0 }, + }, + ], + cities: [ + { + id: 1, + name: '테스트성', + nationId: 1, + level: 1, + state: 0, + population: 10_000, + populationMax: 50_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, trade: 100, region: 1 }, + }, + ], + generals, + troops: [], + diplomacy: [], + events: [], + initialEvents: [], +}); + +const makeState = (meta: Record = {}): TurnWorldState => ({ + id: 1, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: start, + meta: { killturn: 24, ...meta }, +}); + +describe('legacy general turn lifecycle', () => { + it('runs preprocess before commands and restores crew population when rice is insufficient', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([makeGeneral({ injury: 25, crew: 200, rice: 1 })]), + state: makeState(), + schedule, + map, + }); + + await harness.runOneTick(); + + const general = harness.world.getGeneralById(1)!; + expect(general.injury).toBe(15); + expect(general.crew).toBe(0); + expect(general.rice).toBe(0); + expect(general.meta.inherit_lived_month).toBe(1); + expect(harness.world.getCityById(1)!.population).toBe(10_200); + }); + + it('skips blocked commands, decreases killturn once, and advances the queue', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([makeGeneral({ injury: 20, meta: { killturn: 5, block: 3 } })]), + state: makeState(), + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_요양', args: {} }; + + await harness.runOneTick(); + + const general = harness.world.getGeneralById(1)!; + expect(general.injury).toBe(10); + expect(general.experience).toBe(100); + expect(general.meta.killturn).toBe(4); + expect(general.meta.myset).toBe(3); + expect(harness.reservedTurnStore.getGeneralTurn(1, 0).action).toBe('휴식'); + }); + + it('updates killturn, autorun_limit, myset, and nextTurnTimeBase with legacy branches', async () => { + const general = makeGeneral({ + injury: 20, + meta: { killturn: 3, myset: 8, nextTurnTimeBase: 30 }, + }); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([general]), + state: makeState({ autorun_user: { limit_minutes: 60, options: {} } }), + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_요양', args: {} }; + + await harness.runOneTick(); + + const updated = harness.world.getGeneralById(1)!; + expect(updated.meta.killturn).toBe(24); + expect(updated.meta.myset).toBe(9); + expect(updated.meta.autorun_limit).toBe(2406); + expect(updated.meta.nextTurnTimeBase).toBeUndefined(); + expect(updated.turnTime.toISOString()).toBe('0200-01-01T00:10:30.000Z'); + }); + + it('detaches an expired possessed NPC instead of deleting its body', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([ + makeGeneral({ + npcState: 1, + deadYear: 205, + meta: { killturn: 1, npc_org: 3, owner_name: '소유자' }, + }), + ]), + state: makeState(), + schedule, + map, + }); + + await harness.runOneTick(); + + const updated = harness.world.getGeneralById(1)!; + expect(updated.userId).toBeNull(); + expect(updated.npcState).toBe(3); + expect(updated.meta.killturn).toBe(60); + expect(updated.meta.defence_train).toBe(80); + expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('detached'); + }); + + it('deletes a zero-killturn general, clears its troop, and appoints a successor', async () => { + const leader = makeGeneral({ + troopId: 1, + officerLevel: 12, + meta: { killturn: 1 }, + }); + const successor = makeGeneral({ + id: 2, + userId: 'user-2', + name: '후계자', + troopId: 1, + officerLevel: 9, + dedication: 200, + meta: { killturn: 24 }, + }); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([leader, successor]), + state: makeState(), + schedule, + map, + }); + + await harness.runOneTick(); + + expect(harness.world.getGeneralById(1)).toBeNull(); + expect(harness.world.getGeneralById(2)!.troopId).toBe(0); + expect(harness.world.getGeneralById(2)!.officerLevel).toBe(12); + expect(harness.world.getNationById(1)!.chiefGeneralId).toBe(2); + expect(harness.world.peekDirtyState().deletedGenerals).toContain(1); + }); + + it('retires a player general and resets inherited stats and rank state', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([ + makeGeneral({ + age: 80, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + experience: 101, + dedication: 81, + meta: { + killturn: 24, + dex1: 101, + inherit_lived_month: 10, + inherit_active_action: 4, + rank_warnum: 12, + }, + }), + ]), + state: makeState(), + schedule, + map, + }); + + await harness.runOneTick(); + + const updated = harness.world.getGeneralById(1)!; + expect(updated.age).toBe(20); + expect(updated.stats).toEqual({ leadership: 68, strength: 60, intelligence: 51 }); + expect(updated.experience).toBe(51); + expect(updated.dedication).toBe(41); + expect(updated.meta.dex1).toBe(51); + expect(updated.meta.inherit_lived_month).toBe(0); + expect(updated.meta.inherit_active_action).toBe(0); + expect(updated.meta.rank_warnum).toBe(0); + expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired'); + }); +}); diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts new file mode 100644 index 0000000..c9a3c4f --- /dev/null +++ b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts @@ -0,0 +1,216 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js'; +import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js'; +import type { TurnGeneral } from '../src/turn/types.js'; + +const databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const generalIds = [990_001, 990_002, 990_003]; +const userIds = [ + 'integration-lifecycle-dead', + 'integration-lifecycle-retired', + 'integration-lifecycle-possessed', +]; +const serverId = 'integration-lifecycle'; + +const makeGeneral = (id: number, userId: string, patch: Partial = {}): TurnGeneral => ({ + id, + userId, + name: `lifecycle-${id}`, + nationId: 0, + cityId: 0, + troopId: 0, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + experience: 1_000, + dedication: 800, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 1, + train: 0, + atmos: 0, + age: 80, + npcState: 0, + bornYear: 170, + deadYear: 260, + affinity: 50, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { + killturn: 0, + inherit_lived_month: 10, + inherit_active_action: 2, + dex1: 1_000, + }, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + ...patch, +}); + +const event = ( + general: TurnGeneral, + outcome: GeneralLifecycleEvent['outcome'] +): GeneralLifecycleEvent => ({ + generalId: general.id, + outcome, + before: general, + ...(outcome === 'deleted' ? {} : { after: general }), + year: 200, + month: 1, +}); + +integration('general turn lifecycle persistence', () => { + let db: GamePrismaClient; + let close: (() => Promise) | undefined; + + const cleanup = async () => { + await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } }); + await db.hallOfFame.deleteMany({ where: { serverId, generalNo: { in: generalIds } } }); + await db.inheritanceResult.deleteMany({ where: { serverId, owner: { in: userIds } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: userIds } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: userIds } } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + close = () => connector.disconnect(); + await cleanup(); + }); + + afterAll(async () => { + await cleanup(); + await close?.(); + }); + + it('archives deletion, removes access state, and settles inheritance', async () => { + const general = makeGeneral(generalIds[0]!, userIds[0]!, { + meta: { + killturn: 0, + inherit_lived_month: 10, + inherit_active_action: 2, + inheritRandomUnique: true, + dex1: 1_000, + }, + }); + await db.generalAccessLog.create({ + data: { generalId: general.id, userId: general.userId, refreshScore: 99 }, + }); + await db.inheritancePoint.create({ + data: { userId: general.userId!, key: 'previous', value: 100 }, + }); + await db.rankData.createMany({ + data: [ + { generalId: general.id, nationId: 0, type: 'warnum', value: 2 }, + { generalId: general.id, nationId: 0, type: 'firenum', value: 1 }, + ], + }); + + await db.$transaction((tx) => + persistGeneralLifecycleEvents( + tx, + [event(general, 'deleted')], + { serverId }, + { inheritItemRandomPoint: 3_000, inheritSpecificSpecialPoint: 4_000 } + ) + ); + + expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toBeNull(); + expect(await db.oldGeneral.findUnique({ where: { by_no: { serverId, generalNo: general.id } } })).not.toBeNull(); + expect( + await db.inheritancePoint.findUnique({ + where: { userId_key: { userId: general.userId!, key: 'previous' } }, + }) + ).toMatchObject({ value: 3_147 }); + }); + + it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => { + const general = makeGeneral(generalIds[1]!, userIds[1]!); + await db.generalAccessLog.create({ + data: { generalId: general.id, userId: general.userId, refreshScore: 77 }, + }); + await db.inheritancePoint.create({ + data: { userId: general.userId!, key: 'previous', value: 50 }, + }); + await db.rankData.create({ + data: { generalId: general.id, nationId: 0, type: 'warnum', value: 10 }, + }); + + await db.$transaction((tx) => + persistGeneralLifecycleEvents( + tx, + [event(general, 'retired')], + { serverId, season: 1, scenarioId: 2, isUnited: 0 }, + {} + ) + ); + + expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toMatchObject({ + refreshScore: 0, + }); + expect(await db.rankData.findUnique({ where: { generalId_type: { generalId: general.id, type: 'warnum' } } })) + .toMatchObject({ value: 0 }); + expect( + await db.hallOfFame.findUnique({ + where: { + serverId_type_generalNo: { + serverId, + type: 'experience', + generalNo: general.id, + }, + }, + }) + ).toMatchObject({ value: 1_000 }); + expect( + await db.inheritancePoint.findUnique({ + where: { userId_key: { userId: general.userId!, key: 'previous' } }, + }) + ).toMatchObject({ value: 116 }); + }); + + it('does not settle a possessed NPC before the legacy minimum possession period', async () => { + const general = makeGeneral(generalIds[2]!, userIds[2]!, { + npcState: 1, + meta: { + killturn: 0, + inherit_lived_month: 10, + inherit_active_action: 2, + pickYearMonth: 190 * 12, + }, + }); + await db.inheritancePoint.create({ + data: { userId: general.userId!, key: 'previous', value: 100 }, + }); + + await db.$transaction((tx) => + persistGeneralLifecycleEvents( + tx, + [event(general, 'deleted')], + { serverId, startYear: 180 }, + {} + ) + ); + + expect( + await db.inheritancePoint.findUnique({ + where: { userId_key: { userId: general.userId!, key: 'previous' } }, + }) + ).toMatchObject({ value: 100 }); + expect( + await db.inheritanceResult.count({ + where: { serverId, owner: general.userId! }, + }) + ).toBe(0); + }); +}); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index c718f89..e0d46d0 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -179,6 +179,20 @@ model General { @@map("general") } +model GeneralAccessLog { + id Int @id @default(autoincrement()) + generalId Int @unique @map("general_id") + userId String? @map("user_id") + lastRefresh DateTime? @map("last_refresh") + refresh Int @default(0) + refreshTotal Int @default(0) @map("refresh_total") + refreshScore Int @default(0) @map("refresh_score") + refreshScoreTotal Int @default(0) @map("refresh_score_total") + + @@index([userId]) + @@map("general_access_log") +} + model RankData { id Int @id @default(autoincrement()) nationId Int @default(0) @map("nation_id") diff --git a/packages/infra/prisma/migrations/20260725001000_add_general_access_log/migration.sql b/packages/infra/prisma/migrations/20260725001000_add_general_access_log/migration.sql new file mode 100644 index 0000000..c1e9124 --- /dev/null +++ b/packages/infra/prisma/migrations/20260725001000_add_general_access_log/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE "general_access_log" ( + "id" SERIAL PRIMARY KEY, + "general_id" INTEGER NOT NULL, + "user_id" TEXT, + "last_refresh" TIMESTAMP(3), + "refresh" INTEGER NOT NULL DEFAULT 0, + "refresh_total" INTEGER NOT NULL DEFAULT 0, + "refresh_score" INTEGER NOT NULL DEFAULT 0, + "refresh_score_total" INTEGER NOT NULL DEFAULT 0 +); + +CREATE UNIQUE INDEX "general_access_log_general_id_key" ON "general_access_log"("general_id"); +CREATE INDEX "general_access_log_user_id_idx" ON "general_access_log"("user_id"); diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index d8aa337..3573074 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -6,6 +6,7 @@ export interface DatabaseClient { $executeRaw: GamePrismaClient['$executeRaw']; worldState: GamePrisma.WorldStateDelegate; general: GamePrisma.GeneralDelegate; + generalAccessLog: GamePrisma.GeneralAccessLogDelegate; city: GamePrisma.CityDelegate; nation: GamePrisma.NationDelegate; diplomacy: GamePrisma.DiplomacyDelegate; diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index ffd2b44..74736c0 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -16,6 +16,7 @@ export interface TurnEngineWorldStateRow { export interface TurnEngineGeneralRow { id: number; + userId: string | null; name: string; nationId: number; cityId: number; @@ -42,6 +43,9 @@ export interface TurnEngineGeneralRow { atmos: number; age: number; npcState: number; + bornYear: number; + deadYear: number; + affinity: number | null; meta: JsonValue; turnTime: Date; recentWarTime: Date | null; @@ -140,6 +144,7 @@ export interface TurnEngineWorldStateCreateInput { } export interface TurnEngineGeneralUpdateInput { + userId: string | null; name: string; nationId: number; cityId: number; @@ -264,6 +269,7 @@ export interface TurnEngineNationUpdateInput { name: string; color: string; capitalCityId: number | null; + chiefGeneralId: number | null; gold: number; rice: number; level: number;