diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index cc5ffc77..6b4d02d5 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -2,36 +2,27 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; -import { asNumber, asRecord, parseJson, LiteHashDRBG } from '@sammo-ts/common'; +import { asNumber, asRecord, parseJson, LiteHashDRBG, type TurnDaemonInheritanceAction } from '@sammo-ts/common'; import { ItemLoader, isItemKey, loadWarTraitModules, - sendMessage, WarTraitLoader, WAR_TRAIT_KEYS, isWarTraitKey, isCentennialStatResetAllowed, } from '@sammo-ts/logic'; -import type { InheritBuffType, ItemSlot, MessageDraft, MessageRecordDraft } from '@sammo-ts/logic'; +import type { InheritBuffType, ItemSlot } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { - appendInheritanceLog, buildResetCost, computeInheritanceItems, - readInheritancePoint, - readUserStateMeta, resolveInheritConstants, - setInheritancePoint, sumInheritanceItems, - writeUserStateMeta, } from '../../services/inheritance.js'; import type { GameApiContext, WorldStateRow } from '../../context.js'; import { openAuctionWithDaemon } from '../../auction/open.js'; -import { buildTargetFromGeneral } from '../../messages/targets.js'; -import { insertMessage } from '../../messages/store.js'; -import { loadCurrentGameTime } from '../../services/gameClock.js'; const BUFF_KEYS: InheritBuffType[] = [ 'warAvoidRatio', @@ -46,17 +37,6 @@ const BUFF_KEYS: InheritBuffType[] = [ const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item']; -const BUFF_LABELS: Record = { - warAvoidRatio: '회피 확률 증가', - warCriticalRatio: '필살 확률 증가', - warMagicTrialProb: '전투계략 시도 확률 증가', - domesticSuccessProb: '내정 성공률 증가', - domesticFailProb: '내정 실패율 감소', - warAvoidRatioOppose: '상대 회피 확률 감소', - warCriticalRatioOppose: '상대 필살 확률 감소', - warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소', -}; - const POSTGRES_INTEGER_MAX = 2_147_483_647; const parseBuffRecord = (raw: unknown): Record => { @@ -74,13 +54,6 @@ const parseBuffRecord = (raw: unknown): Record => { return result; }; -const serializeBuffRecord = (buff: Record): string => JSON.stringify(buff); - -const readStringList = (raw: unknown): string[] => { - const parsed = typeof raw === 'string' ? parseJson(raw) : raw; - return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : []; -}; - const readBuffLevel = (buff: Record, key: InheritBuffType): number => { const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null; return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0))); @@ -131,31 +104,24 @@ const resolveWorld = async (ctx: { db: { worldState: { findFirst: () => Promise< }; }; -const patchGeneral = async ( - ctx: Pick, - generalId: number, - patch: { - meta?: Record; - turnTime?: string; - stats?: { - leadership?: number; - strength?: number; - intelligence?: number; - }; - specialWar?: string | null; - } -): Promise => { +const requestInheritanceAction = async ( + ctx: Pick, + userId: string, + input: TurnDaemonInheritanceAction +) => { const result = await ctx.turnDaemon.requestCommand({ - type: 'patchGeneral', - generalId, - patch, + type: 'inheritanceAction', + userId, + input, + ...(ctx.requestId ? { requestId: `${ctx.requestId}:inherit.${input.action}:engine:0:inheritanceAction` } : {}), }); - if (!result || result.type !== 'patchGeneral') { + if (!result || result.type !== 'inheritanceAction') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } if (!result.ok) { - throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); + throw new TRPCError({ code: result.code, message: result.reason }); } + return result; }; const buildTurnTimeZoneList = (tickMinutes: number): string[] => { @@ -189,54 +155,6 @@ export const resolveResetTurnTimeBase = (options: { return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) }; }; -const resolveSeasonValue = (meta: Record): number | null => { - const raw = meta.season; - if (typeof raw === 'number' && Number.isFinite(raw)) { - return Math.floor(raw); - } - if (typeof raw === 'string') { - const parsed = Number(raw); - if (Number.isFinite(parsed)) { - return Math.floor(parsed); - } - } - return null; -}; - -const readResetSeasons = (meta: Record): number[] => { - if (!Array.isArray(meta.last_stat_reset)) { - return []; - } - return meta.last_stat_reset - .map((value) => (typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null)) - .filter((value): value is number => value !== null); -}; - -const pickWeightedIndex = (rng: LiteHashDRBG, weights: number[]): number => { - const total = weights.reduce((acc, value) => acc + value, 0); - if (total <= 0) { - return 0; - } - let cursor = rng.nextFloat1() * total; - for (let i = 0; i < weights.length; i += 1) { - cursor -= weights[i] ?? 0; - if (cursor <= 0) { - return i; - } - } - return weights.length - 1; -}; - -const buildRandomBonus = (rng: LiteHashDRBG, baseStats: [number, number, number]): [number, number, number] => { - const bonusCount = rng.nextInt(2) + 3; - const bonus = [0, 0, 0] as [number, number, number]; - for (let i = 0; i < bonusCount; i += 1) { - const index = pickWeightedIndex(rng, baseStats); - bonus[index] += 1; - } - return bonus; -}; - export const inheritRouter = router({ getStatus: authedProcedure.query(async ({ ctx }) => { const userId = ctx.auth?.user.id; @@ -380,7 +298,7 @@ export const inheritRouter = router({ }); return logs; }), - buyHiddenBuff: authedProcedure + buyHiddenBuff: engineAuthedProcedure .input( z.object({ type: z.enum(BUFF_KEYS), @@ -393,56 +311,14 @@ export const inheritRouter = router({ throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const worldState = await resolveWorld(ctx); - const worldMeta = asRecord(worldState.meta); - if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { - throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); - } - - const inheritConst = resolveInheritConstants(worldState as WorldStateRow); - const general = await ctx.db.general.findFirst({ - where: { userId }, - select: { id: true, meta: true }, + const result = await requestInheritanceAction(ctx, userId, { + action: 'buyHiddenBuff', + buffType: input.type, + level: input.level, }); - if (!general) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); - } - - const buff = parseBuffRecord(asRecord(general.meta).inheritBuff); - const prevLevel = readBuffLevel(buff, input.type); - if (input.level === prevLevel) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 구입했습니다.' }); - } - if (input.level < prevLevel) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 더 높은 등급을 구입했습니다.' }); - } - const cost = inheritConst.inheritBuffPoints[input.level] - inheritConst.inheritBuffPoints[prevLevel]; - const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); - if (currentPoint < cost) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); - } - - const buffText = BUFF_LABELS[input.type]; - const moreText = prevLevel > 0 ? '추가' : ''; - buff[input.type] = input.level; - await patchGeneral(ctx, general.id, { - meta: { - ...asRecord(general.meta), - inheritBuff: serializeBuffRecord(buff), - }, - }); - - await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost); - await appendInheritanceLog( - ctx.db, - userId, - worldState.currentYear, - worldState.currentMonth, - `${cost} 포인트로 ${buffText} ${input.level} 단계 ${moreText}구입` - ); - return { ok: true, remainPoint: currentPoint - cost }; + return { ok: true, remainPoint: result.remainPoint }; }), - setNextSpecialWar: authedProcedure + setNextSpecialWar: engineAuthedProcedure .input( z.object({ specialKey: z.string(), @@ -454,197 +330,32 @@ export const inheritRouter = router({ throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const worldState = await resolveWorld(ctx); - const worldMeta = asRecord(worldState.meta); - if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { - throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); - } - - if (!isWarTraitKey(input.specialKey)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '잘못된 전투 특기입니다.' }); - } - const config = asRecord(worldState.config); - const constValues = asRecord(config.const); - const allowedSpecialWar = Array.isArray(constValues.availableSpecialWar) - ? constValues.availableSpecialWar.filter((key): key is string => typeof key === 'string') - : []; - if (allowedSpecialWar.length > 0 && !allowedSpecialWar.includes(input.specialKey)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '허용되지 않은 전투 특기입니다.' }); - } - - const inheritConst = resolveInheritConstants(worldState as WorldStateRow); - const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); - if (currentPoint < inheritConst.inheritSpecificSpecialPoint) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); - } - - const general = await ctx.db.general.findFirst({ - where: { userId }, - select: { id: true, meta: true, special2Code: true }, - }); - if (!general) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); - } - if (general.special2Code === input.specialKey) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 그 특기를 보유하고 있습니다.' }); - } - const meta = asRecord(general.meta); - const reservedSpecial = - typeof meta.inheritSpecificSpecialWar === 'string' ? meta.inheritSpecificSpecialWar : null; - if (reservedSpecial === input.specialKey) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 그 특기를 예약하였습니다.' }); - } - if (reservedSpecial) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' }); - } - - const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader()); - const warName = warModule?.name ?? input.specialKey; - - await patchGeneral(ctx, general.id, { - meta: { - ...meta, - inheritSpecificSpecialWar: input.specialKey, - }, - }); - - await setInheritancePoint( - ctx.db, - userId, - 'previous', - currentPoint - inheritConst.inheritSpecificSpecialPoint - ); - await appendInheritanceLog( - ctx.db, - userId, - worldState.currentYear, - worldState.currentMonth, - `${inheritConst.inheritSpecificSpecialPoint} 포인트로 다음 전투 특기로 ${warName} 지정` - ); + await requestInheritanceAction(ctx, userId, { action: 'setNextSpecialWar', specialKey: input.specialKey }); return { ok: true }; }), - resetSpecialWar: authedProcedure.mutation(async ({ ctx }) => { + resetSpecialWar: engineAuthedProcedure.mutation(async ({ ctx }) => { const userId = ctx.auth?.user.id; if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const worldState = await resolveWorld(ctx); - const worldMeta = asRecord(worldState.meta); - if (asNumber(worldMeta.isunited ?? worldMeta.isUnited, 0) !== 0) { - throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); - } - - const general = await ctx.db.general.findFirst({ - where: { userId }, - select: { id: true, special2Code: true, meta: true }, - }); - if (!general) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); - } - if (!general.special2Code || general.special2Code === 'None') { - throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 전투 특기가 공란입니다.' }); - } - - const inheritConst = resolveInheritConstants(worldState as WorldStateRow); - const currentLevel = asNumber(asRecord(general.meta).inheritResetSpecialWar, -1); - const nextLevel = currentLevel + 1; - const cost = buildResetCost(inheritConst.inheritResetAttrPointBase, nextLevel); - const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); - if (currentPoint < cost) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); - } - - const meta = asRecord(general.meta); - const prevList = readStringList(meta.prev_types_special2); - prevList.push(general.special2Code); - - await patchGeneral(ctx, general.id, { - specialWar: null, - meta: { - ...meta, - inheritResetSpecialWar: nextLevel, - prev_types_special2: prevList, - }, - }); - - await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost); - await appendInheritanceLog( - ctx.db, - userId, - worldState.currentYear, - worldState.currentMonth, - `${cost} 포인트로 전투 특기 초기화` - ); + await requestInheritanceAction(ctx, userId, { action: 'resetSpecialWar' }); return { ok: true }; }), - resetTurnTime: authedProcedure.mutation(async ({ ctx }) => { + resetTurnTime: engineAuthedProcedure.mutation(async ({ ctx }) => { const userId = ctx.auth?.user.id; if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const worldState = await resolveWorld(ctx); - const worldMeta = asRecord(worldState.meta); - if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { - throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); - } - - const general = await ctx.db.general.findFirst({ - where: { userId }, - select: { id: true, meta: true, turnTick: true }, - }); - if (!general) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); - } - - const inheritConst = resolveInheritConstants(worldState as WorldStateRow); - const currentLevel = asNumber(asRecord(general.meta).inheritResetTurnTime, -1); - const nextLevel = currentLevel + 1; - const cost = buildResetCost(inheritConst.inheritResetAttrPointBase, nextLevel); - const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); - if (currentPoint < cost) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); - } - - const generalMeta = asRecord(general.meta); - const rawSeedTurnTime = generalMeta.nextTurnTimeBase ?? general.turnTick ?? 0; - const seedTurnTime = - typeof rawSeedTurnTime === 'string' || typeof rawSeedTurnTime === 'number' - ? rawSeedTurnTime - : typeof rawSeedTurnTime === 'bigint' - ? Number(rawSeedTurnTime) - : 0; - const hiddenSeed = - typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number' - ? worldMeta.hiddenSeed - : 'inherit'; - const { nextTurnTimeBase, nextTurnTimeLabel } = resolveResetTurnTimeBase({ - hiddenSeed, - userId, - previousTurnTimeBase: seedTurnTime, - tickSeconds: worldState.tickSeconds, - }); - - await patchGeneral(ctx, general.id, { - meta: { - ...generalMeta, - inheritResetTurnTime: nextLevel, - nextTurnTimeBase, - }, - }); - - await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost); - await appendInheritanceLog( - ctx.db, - userId, - worldState.currentYear, - worldState.currentMonth, - `${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${nextTurnTimeLabel} 적용` - ); - return { ok: true, nextTurnTimeBase, nextTurnTimeLabel }; + const result = await requestInheritanceAction(ctx, userId, { action: 'resetTurnTime' }); + return { + ok: true, + nextTurnTimeBase: result.nextTurnTimeBase!, + nextTurnTimeLabel: result.nextTurnTimeLabel!, + }; }), - resetStat: authedProcedure + resetStat: engineAuthedProcedure .input( z.object({ leadership: z.number().int(), @@ -658,191 +369,21 @@ export const inheritRouter = router({ if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const worldState = await resolveWorld(ctx); - const worldMeta = asRecord(worldState.meta); - if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { - throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); - } - const config = asRecord(worldState.config); - const statConfig = asRecord(config.stat); - const statTotal = asNumber(statConfig.total, input.leadership + input.strength + input.intel); - const statMin = asNumber(statConfig.min, 1); - const statMax = asNumber(statConfig.max, 999); - - const total = input.leadership + input.strength + input.intel; - if (total !== statTotal) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `능력치 총합이 ${statTotal}이 아닙니다. 다시 입력해주세요!`, - }); - } - if ( - input.leadership < statMin || - input.strength < statMin || - input.intel < statMin || - input.leadership > statMax || - input.strength > statMax || - input.intel > statMax - ) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '능력치 범위를 벗어났습니다.' }); - } - - const inheritConst = resolveInheritConstants(worldState as WorldStateRow); - const bonus = input.inheritBonusStat ?? [0, 0, 0]; - const bonusSum = bonus.reduce((acc, value) => acc + value, 0); - if (bonus.some((value) => value < 0)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '보너스 능력치가 음수입니다. 다시 입력해주세요!', - }); - } - if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '보너스 능력치 합이 잘못 지정되었습니다. 다시 입력해주세요!', - }); - } - - const general = await ctx.db.general.findFirst({ - where: { userId }, - select: { id: true, npcState: true }, + const result = await requestInheritanceAction(ctx, userId, { + action: 'resetStat', + leadership: input.leadership, + strength: input.strength, + intel: input.intel, + ...(input.inheritBonusStat ? { inheritBonusStat: input.inheritBonusStat } : {}), }); - if (!general) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); - } - if (general.npcState >= 2) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'NPC는 능력치 초기화를 할 수 없습니다.' }); - } - if (!isCentennialStatResetAllowed(config)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.', - }); - } - - const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); - const cost = bonusSum > 0 ? inheritConst.inheritBornStatPoint : 0; - if (currentPoint < cost) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); - } - - const seasonValue = resolveSeasonValue(worldMeta); - if (seasonValue !== null) { - const userState = await readUserStateMeta(ctx.db, userId); - const resetSeasons = readResetSeasons(userState); - if (resetSeasons.includes(seasonValue)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '이번 시즌에 이미 능력치를 초기화하셨습니다.', - }); - } - } - - const finalBonus = - bonusSum === 0 - ? buildRandomBonus( - new LiteHashDRBG(`${asRecord(worldState.meta).hiddenSeed ?? 'inherit'}:ResetStat:${userId}`), - [input.leadership, input.strength, input.intel] - ) - : (bonus as [number, number, number]); - const nextStats = { - leadership: input.leadership + finalBonus[0], - strength: input.strength + finalBonus[1], - intel: input.intel + finalBonus[2], - }; - - await patchGeneral(ctx, general.id, { - stats: { - leadership: nextStats.leadership, - strength: nextStats.strength, - intelligence: nextStats.intel, - }, - }); - - await appendInheritanceLog( - ctx.db, - userId, - worldState.currentYear, - worldState.currentMonth, - `통솔 ${input.leadership}, 무력 ${input.strength}, 지력 ${input.intel} 스탯 재설정` - ); - if (bonusSum > 0) { - await appendInheritanceLog( - ctx.db, - userId, - worldState.currentYear, - worldState.currentMonth, - `${cost}로 통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용` - ); - } else { - await appendInheritanceLog( - ctx.db, - userId, - worldState.currentYear, - worldState.currentMonth, - `통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용` - ); - } - if (cost > 0) { - await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - cost); - } - if (seasonValue !== null) { - const userState = await readUserStateMeta(ctx.db, userId); - const resetSeasons = readResetSeasons(userState); - const nextSeasons = resetSeasons.includes(seasonValue) ? resetSeasons : [...resetSeasons, seasonValue]; - await writeUserStateMeta(ctx.db, userId, { - ...userState, - last_stat_reset: nextSeasons, - }); - } - return { ok: true, stats: nextStats }; + return { ok: true, stats: result.stats! }; }), - buyRandomUnique: authedProcedure.mutation(async ({ ctx }) => { + buyRandomUnique: engineAuthedProcedure.mutation(async ({ ctx }) => { const userId = ctx.auth?.user.id; if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const worldState = await resolveWorld(ctx); - const worldMeta = asRecord(worldState.meta); - if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { - throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); - } - const inheritConst = resolveInheritConstants(worldState as WorldStateRow); - const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); - if (currentPoint < inheritConst.inheritItemRandomPoint) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); - } - - const general = await ctx.db.general.findFirst({ - where: { userId }, - select: { id: true, meta: true }, - }); - if (!general) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); - } - const meta = asRecord(general.meta); - if (meta.inheritRandomUnique !== undefined && meta.inheritRandomUnique !== null) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.', - }); - } - - await patchGeneral(ctx, general.id, { - meta: { - ...meta, - inheritRandomUnique: 1, - }, - }); - - await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritItemRandomPoint); - await appendInheritanceLog( - ctx.db, - userId, - worldState.currentYear, - worldState.currentMonth, - `${inheritConst.inheritItemRandomPoint} 포인트로 랜덤 유니크 구입` - ); + await requestInheritanceAction(ctx, userId, { action: 'buyRandomUnique' }); return { ok: true }; }), openUniqueAuction: engineAuthedProcedure @@ -885,7 +426,7 @@ export const inheritRouter = router({ ); return { ok: true, ...result }; }), - checkOwner: authedProcedure + checkOwner: engineAuthedProcedure .input( z.object({ targetGeneralId: z.number().int().positive(), @@ -896,79 +437,10 @@ export const inheritRouter = router({ if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const worldState = await resolveWorld(ctx); - const worldMeta = asRecord(worldState.meta); - if (typeof worldMeta.isUnited === 'number' && worldMeta.isUnited !== 0) { - throw new TRPCError({ code: 'FORBIDDEN', message: '이미 천하가 통일되었습니다.' }); - } - const inheritConst = resolveInheritConstants(worldState as WorldStateRow); - const currentPoint = await readInheritancePoint(ctx.db, userId, 'previous'); - if (currentPoint < inheritConst.inheritCheckOwnerPoint) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '유산 포인트가 부족합니다.' }); - } - - const [general, target] = await Promise.all([ - ctx.db.general.findFirst({ where: { userId } }), - ctx.db.general.findUnique({ where: { id: input.targetGeneralId } }), - ]); - if (!general) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); - } - if (!target || !target.userId) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '대상 장수가 존재하지 않습니다.' }); - } - if (target.id === general.id) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '자신의 정보는 확인할 수 없습니다.' }); - } - - const rawOwnerName = asRecord(target.meta).ownerName; - const ownerName = - typeof rawOwnerName === 'string' && rawOwnerName.trim().length > 0 ? rawOwnerName : '알수없음'; - - await setInheritancePoint(ctx.db, userId, 'previous', currentPoint - inheritConst.inheritCheckOwnerPoint); - await appendInheritanceLog( - ctx.db, - userId, - worldState.currentYear, - worldState.currentMonth, - `${inheritConst.inheritCheckOwnerPoint} 포인트로 장수 소유자 확인` - ); - - const [generalTarget, checkedTarget, gameTime] = await Promise.all([ - buildTargetFromGeneral(ctx.db, general), - buildTargetFromGeneral(ctx.db, target), - loadCurrentGameTime(ctx.db), - ]); - const systemTarget: MessageDraft['src'] = { - generalId: 0, - generalName: '', - nationId: 0, - nationName: 'System', - color: '#000000', - icon: '', - }; - const validUntil = new Date('9999-12-31T00:00:00.000Z'); - const sendSystemPrivateMessage = async (dest: MessageDraft['dest'], text: string): Promise => { - await sendMessage( - { - insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft), - }, - { - msgType: 'private', - src: systemTarget, - dest, - text, - time: gameTime.now, - validUntil, - option: {}, - }, - { sendDestOnly: true } - ); - ctx.changeJournal?.mark('messages.mailbox', dest.generalId); - }; - - await sendSystemPrivateMessage(generalTarget, `${target.name}의 소유자는 ${ownerName} 입니다.`); - await sendSystemPrivateMessage(checkedTarget, '소유자명이 누군가에 의해 확인되었습니다.'); - return { ok: true, ownerName, targetName: target.name }; + const result = await requestInheritanceAction(ctx, userId, { + action: 'checkOwner', + targetGeneralId: input.targetGeneralId, + }); + return { ok: true, ownerName: result.ownerName!, targetName: result.targetName! }; }), }); diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts index 755449f4..c154796d 100644 --- a/app/game-api/src/router/ranking/index.ts +++ b/app/game-api/src/router/ranking/index.ts @@ -5,6 +5,10 @@ import { ITEM_KEYS, ItemLoader, loadItemModules } from '@sammo-ts/logic/items/in import type { ItemModule } from '@sammo-ts/logic/items/types.js'; import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js'; +import { + readCentennialRecordableDexterity, + type CentennialDexKey, +} from '@sammo-ts/logic/scenario/centennialAllStar.js'; import { accessAuthedInputProcedure, accessInputProcedure, procedure, router } from '../../trpc.js'; import { @@ -182,11 +186,17 @@ export const rankingRouter = router({ return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0); }, ], - ['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)], - ['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)], - ['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)], - ['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)], - ['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)], + ...(['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const).map( + (key, index) => + [ + ['보 병 숙 련 도', '궁 병 숙 련 도', '기 병 숙 련 도', '귀 병 숙 련 도', '차 병 숙 련 도'][ + index + ]!, + 'int', + (general: (typeof generals)[number]) => + readCentennialRecordableDexterity(asRecord(general.meta), key as CentennialDexKey), + ] as [string, 'int', (general: (typeof generals)[number], ranks: Record) => number] + ), [ '전 력 전 승 률', 'percent', @@ -415,7 +425,7 @@ export const rankingRouter = router({ return Array.from(optionMap.values()); } const rows = await ctx.db.gameHistory.findMany({ - where: { status: 'COMPLETED' }, + where: { status: { in: ['OPEN', 'COMPLETED'] } }, select: { season: true, scenario: true, scenarioName: true }, orderBy: [{ season: 'desc' }, { scenario: 'asc' }], }); diff --git a/app/game-api/test/inheritOwnerMessages.integration.test.ts b/app/game-api/test/inheritOwnerMessages.integration.test.ts index 930a062f..495b54ae 100644 --- a/app/game-api/test/inheritOwnerMessages.integration.test.ts +++ b/app/game-api/test/inheritOwnerMessages.integration.test.ts @@ -1,68 +1,258 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { SystemClock } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; -import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra'; +import { + createDatabaseTurnHooks, + DatabaseTurnDaemonCommandQueue, + EngineStateManager, + InMemoryTurnStateStore, + InMemoryTurnWorld, + loadTurnWorldFromDatabase, + TurnDaemonLifecycle, + type TurnGeneral, + type TurnWorldSnapshot, + type TurnWorldState, +} from '@sammo-ts/game-engine'; +import { createTurnDaemonCommandHandler } from '@sammo-ts/game-engine/turn/worldCommandHandler.js'; +import { + createGamePostgresConnector, + type GamePrisma, + type GamePrismaClient, + type RedisConnector, +} from '@sammo-ts/infra'; +import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; import type { GameApiContext } from '../src/context.js'; -import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js'; import { InMemoryFlushStore } from '../src/auth/flushStore.js'; import { appRouter } from '../src/router.js'; -const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); -const actorGeneralId = 8_701; -const checkedGeneralId = 8_702; -const actorNationId = 871; -const checkedNationId = 872; +const worldId = 992_320; +const actorId = 7_320; +const targetId = 7_321; +const actorNationId = 7_322; +const targetNationId = 7_323; const actorUserId = 'inherit-owner-message-actor'; -const checkedUserId = 'inherit-owner-message-checked'; +const targetUserId = 'inherit-owner-message-target'; +const requestId = 'integration:inherit-owner-message:success'; +const engineRequestId = `${requestId}:inherit.checkOwner:engine:0:inheritanceAction`; +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; +const scenarioConfig: ScenarioConfig = { + stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: { inheritCheckOwnerPoint: 1_000 }, + environment: { mapName: 'che', unitSet: 'che' }, +}; +const scenarioMeta: ScenarioMeta = { + title: '소유자 확인 통합', + startYear: 200, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, +}; +const map: MapDefinition = { id: 'inherit-owner-message', name: scenarioMeta.title, cities: [] }; +const state: TurnWorldState = { + id: worldId, + currentYear: 200, + currentMonth: 4, + tickSeconds: 600, + lastTurnTime: new Date('2026-08-19T00:00:00.000Z'), + meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta }, +}; +const general = (overrides: Partial): TurnGeneral => ({ + id: actorId, + userId: actorUserId, + name: '확인장수', + nationId: actorNationId, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 45, intelligence: 85 }, + turnTime: new Date('2026-08-19T00:10:00.000Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, inherit_spent_dyn: 0 }, + inheritancePoints: { previous: 1_500 }, + penalty: {}, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + ...overrides, +}); +const actor = general({}); +const target = general({ + id: targetId, + userId: targetUserId, + name: '피확인장수', + nationId: targetNationId, + meta: { killturn: 24, owner_name: '피확인 계정' }, + inheritancePoints: { previous: 0 }, +}); +const nation = (id: number, name: string, chiefGeneralId: number) => ({ + id, + name, + color: id === actorNationId ? '#123456' : '#654321', + capitalCityId: null, + chiefGeneralId, + gold: 0, + rice: 0, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, +}); +const actorNation = nation(actorNationId, '확인국', actorId); +const targetNation = nation(targetNationId, '피확인국', targetId); const auth: GameSessionTokenPayload = { version: 1, profile: 'che:inherit-owner-message', issuedAt: '2026-08-19T00:00:00.000Z', expiresAt: '2026-08-20T00:00:00.000Z', sessionId: 'inherit-owner-message-session', - user: { - id: actorUserId, - username: actorUserId, - displayName: '확인자 계정', - roles: ['user'], - }, + user: { id: actorUserId, username: actorUserId, displayName: '확인자 계정', roles: ['user'] }, sanctions: {}, }; -const hasMailboxChange = (payload: unknown): boolean => { - if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false; - const changes = (payload as { changes?: unknown }).changes; - if (!Array.isArray(changes)) return false; - const mailboxes = new Set([actorGeneralId, checkedGeneralId]); - return changes.some( - (change) => - Array.isArray(change) && - change[0] === 'messages.mailbox' && - typeof change[1] === 'number' && - mailboxes.has(change[1]) - ); -}; +const toCreate = (entry: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({ + id: entry.id, + userId: entry.userId, + name: entry.name, + nationId: entry.nationId, + cityId: entry.cityId, + npcState: entry.npcState, + leadership: entry.stats.leadership, + strength: entry.stats.strength, + intel: entry.stats.intelligence, + turnTime: entry.turnTime, + meta: entry.meta as GamePrisma.InputJsonValue, + penalty: entry.penalty as GamePrisma.InputJsonValue, +}); integration('inherit owner lookup private messages', () => { let db: GamePrismaClient; let closeDb: (() => Promise) | undefined; - let worldStateId: number; - const buildContext = (requestId: string): GameApiContext => { - const redisClient = { - get: async () => null, - set: async () => null, + beforeAll(async () => { + const schema = new URL(databaseUrl!).searchParams.get('schema'); + if (!schema?.endsWith('immediate_action_integration')) throw new Error(`Unsafe schema: ${schema}`); + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } }); + await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); + await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } }); + await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } }); + await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } }); + await db.worldState.deleteMany({ where: { id: worldId } }); + await db.worldState.create({ + data: { + id: worldId, + scenarioCode: 'inherit-owner-message', + currentYear: 200, + currentMonth: 4, + tickSeconds: 600, + config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, + meta: state.meta as GamePrisma.InputJsonValue, + }, + }); + await db.nation.createMany({ + data: [ + { id: actorNationId, name: actorNation.name, color: actorNation.color, level: 1 }, + { id: targetNationId, name: targetNation.name, color: targetNation.color, level: 1 }, + ], + }); + await db.general.createMany({ data: [actor, target].map(toCreate) }); + await db.inheritancePoint.create({ data: { userId: actorUserId, key: 'previous', value: 1_500 } }); + await db.rankData.create({ + data: { generalId: actorId, nationId: actorNationId, type: 'inherit_spent_dyn', value: 0 }, + }); + }); + + afterAll(async () => { + if (db) { + await db.inputEvent.deleteMany({ where: { requestId: engineRequestId } }); + await db.message.deleteMany({ where: { mailbox: { in: [actorId, targetId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); + await db.rankData.deleteMany({ where: { generalId: { in: [actorId, targetId] } } }); + await db.general.deleteMany({ where: { id: { in: [actorId, targetId] } } }); + await db.nation.deleteMany({ where: { id: { in: [actorNationId, targetNationId] } } }); + await db.worldState.deleteMany({ where: { id: worldId } }); + } + await closeDb?.(); + }); + + it('commits once and returns the durable result without double charging on the same API retry', async () => { + const snapshot: TurnWorldSnapshot = { + generals: [actor, target], + cities: [], + nations: [actorNation, targetNation], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig, + scenarioMeta, + map, }; - return { + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const queue = new DatabaseTurnDaemonCommandQueue(db); + await queue.initialize(); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + const stateManager = new EngineStateManager(); + stateManager.register('world', { + capture: () => world.captureState(), + restore: (value) => world.restoreState(value), + }); + const lifecycle = new TurnDaemonLifecycle( + { + clock: new SystemClock(), + controlQueue: queue, + commandResponder: queue, + getNextTickTime: () => new Date(Date.now() + 3_600_000), + stateStore: new InMemoryTurnStateStore(world), + processor: { + run: async () => { + throw new Error('scheduled turn must not run in inheritance API integration'); + }, + }, + commandHandler: createTurnDaemonCommandHandler({ world }), + hooks: hooks.hooks, + stateManager, + }, + { profile: 'inherit-owner-message', defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 } } + ); + const redisClient = { get: async () => null, set: async () => null }; + const context: GameApiContext = { requestId, db, redis: redisClient as unknown as RedisConnector['client'], - turnDaemon: new InMemoryTurnDaemonTransport(), + turnDaemon: new DatabaseTurnDaemonTransport(db, 10_000), battleSim: new InMemoryBattleSimTransport(), profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' }, uploadDir: 'uploads', @@ -72,157 +262,46 @@ integration('inherit owner lookup private messages', () => { accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', - readModelOutbox: { wake: vi.fn() }, }; - }; - beforeAll(async () => { - const connector = createGamePostgresConnector({ url: databaseUrl! }); - await connector.connect(); - db = connector.prisma; - closeDb = () => connector.disconnect(); - - await db.inputEvent.deleteMany({ where: { actorUserId } }); - await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } }); - await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } }); - await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } }); - await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } }); - await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } }); - await db.readModelRevision.deleteMany({ - where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } }, - }); - - await db.nation.createMany({ - data: [ - { id: actorNationId, name: '확인국', color: '#123456', level: 2 }, - { id: checkedNationId, name: '피확인국', color: '#654321', level: 3 }, - ], - }); - await db.general.createMany({ - data: [ - { - id: actorGeneralId, - userId: actorUserId, - name: '확인장수', - nationId: actorNationId, - cityId: 1, - npcState: 0, - turnTime: new Date('0200-01-01T00:00:00.000Z'), - meta: { ownerName: '확인자 계정' }, - }, - { - id: checkedGeneralId, - userId: checkedUserId, - name: '피확인장수', - nationId: checkedNationId, - cityId: 1, - npcState: 0, - turnTime: new Date('0200-01-01T00:00:00.000Z'), - meta: { ownerName: '피확인 계정' }, - }, - ], - }); - await db.inheritancePoint.create({ - data: { userId: actorUserId, key: 'previous', value: 1_500 }, - }); - const world = await db.worldState.create({ - data: { - scenarioCode: 'inherit-owner-message', - currentYear: 200, - currentMonth: 4, - tickSeconds: 600, - config: { const: { inheritCheckOwnerPoint: 1_000 } }, - meta: { isUnited: 0 }, - }, - }); - worldStateId = world.id; - }); - - afterAll(async () => { - const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } }); - const outboxIds = outboxes.filter(({ payload }) => hasMailboxChange(payload)).map(({ id }) => id); - if (outboxIds.length > 0) { - await db.readModelOutbox.deleteMany({ where: { id: { in: outboxIds } } }); + let loop: Promise | undefined; + try { + loop = lifecycle.start(); + const caller = appRouter.createCaller(context); + const expected = { ok: true, ownerName: '피확인 계정', targetName: '피확인장수' }; + await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected); + await expect(caller.inherit.checkOwner({ targetGeneralId: targetId })).resolves.toEqual(expected); + } finally { + await lifecycle.stop('inherit owner integration finished'); + await loop; + await hooks.close(); } - await db.inputEvent.deleteMany({ where: { actorUserId } }); - await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } }); - await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } }); - await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } }); - await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } }); - await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } }); - await db.readModelRevision.deleteMany({ - where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } }, - }); - await db.worldState.delete({ where: { id: worldStateId } }); - await closeDb?.(); - }); - - it('commits the point charge, log, and both Ref-compatible private messages', async () => { - const requestId = 'integration:inherit-owner-message:success'; - await expect( - appRouter.createCaller(buildContext(requestId)).inherit.checkOwner({ targetGeneralId: checkedGeneralId }) - ).resolves.toEqual({ - ok: true, - ownerName: '피확인 계정', - targetName: '피확인장수', - }); await expect( - db.inheritancePoint.findUniqueOrThrow({ - where: { userId_key: { userId: actorUserId, key: 'previous' } }, - }) + db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId: actorUserId, key: 'previous' } } }) ).resolves.toMatchObject({ value: 500 }); - await expect(db.inheritanceLog.findMany({ where: { userId: actorUserId } })).resolves.toEqual([ - expect.objectContaining({ - year: 200, - month: 4, - text: '1000 포인트로 장수 소유자 확인', - }), - ]); - - const messages = await db.message.findMany({ - where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } }, - orderBy: { mailbox: 'asc' }, - }); - expect(messages).toHaveLength(2); - expect( - messages.map(({ mailbox, type, src, dest, message }) => ({ mailbox, type, src, dest, message })) - ).toEqual([ - { - mailbox: actorGeneralId, - type: 'private', - src: 0, - dest: actorGeneralId, - message: expect.objectContaining({ - src: expect.objectContaining({ generalId: 0, nationName: 'System' }), - dest: expect.objectContaining({ generalId: actorGeneralId, generalName: '확인장수' }), - text: '피확인장수의 소유자는 피확인 계정 입니다.', - }), - }, - { - mailbox: checkedGeneralId, - type: 'private', - src: 0, - dest: checkedGeneralId, - message: expect.objectContaining({ - src: expect.objectContaining({ generalId: 0, nationName: 'System' }), - dest: expect.objectContaining({ generalId: checkedGeneralId, generalName: '피확인장수' }), - text: '소유자명이 누군가에 의해 확인되었습니다.', - }), - }, - ]); - await expect( - db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:inherit.checkOwner` } }) - ).resolves.toMatchObject({ status: 'SUCCEEDED', actorUserId }); - await expect( - db.readModelRevision.findMany({ - where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } }, - orderBy: { entityId: 'asc' }, + db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId: actorId, type: 'inherit_spent_dyn' } }, }) - ).resolves.toEqual([ - expect.objectContaining({ domain: 'messages.mailbox', entityId: actorGeneralId, revision: 1n }), - expect.objectContaining({ domain: 'messages.mailbox', entityId: checkedGeneralId, revision: 1n }), + ).resolves.toMatchObject({ value: 1_000 }); + await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(1); + const messages = await db.message.findMany({ + where: { mailbox: { in: [actorId, targetId] } }, + orderBy: { id: 'asc' }, + select: { mailbox: true, message: true }, + }); + expect(messages.map(({ mailbox, message }) => [mailbox, (message as { text: string }).text])).toEqual([ + [actorId, '피확인장수의 소유자는 피확인 계정 입니다.'], + [targetId, '소유자명이 누군가에 의해 확인되었습니다.'], ]); - }); + await expect(db.inputEvent.findMany({ where: { requestId: engineRequestId } })).resolves.toEqual([ + expect.objectContaining({ status: 'SUCCEEDED', attempts: 1, actorUserId }), + ]); + const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect(reloaded.snapshot.generals.find((entry) => entry.id === actorId)).toMatchObject({ + meta: { inherit_spent_dyn: 1_000 }, + inheritancePoints: { previous: 500 }, + }); + }, 30_000); }); diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index eb657c0a..118e7062 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { ChangeJournal } from '@sammo-ts/common'; +import { ChangeJournal, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { RedisConnector } from '@sammo-ts/infra'; import type { MessagePayload } from '@sammo-ts/logic'; @@ -111,6 +111,7 @@ const buildContext = (options: { inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>; configConst?: Record; configMap?: Record; + daemonResult?: TurnDaemonCommandResult; }) => { const auth = options.auth === undefined ? buildAuth() : options.auth; const general = options.general === undefined ? buildGeneral() : options.general; @@ -118,11 +119,19 @@ const buildContext = (options: { options.target === undefined ? buildGeneral({ id: 8, userId: 'user-2', name: '조조', meta: { ownerName: '위유저' } }) : options.target; - const requestCommand = vi.fn(async (command: { type: string; generalId: number }) => ({ - type: command.type, - ok: true, - generalId: command.generalId, - })); + const requestCommand = vi.fn(async (command: TurnDaemonCommand): Promise => { + if (options.daemonResult) return options.daemonResult; + if (command.type === 'inheritanceAction') { + return { + type: 'inheritanceAction', + ok: true, + action: command.input.action, + generalId: general?.id ?? 7, + remainPoint: options.inheritancePoint ?? 10_000, + }; + } + return { type: 'patchGeneral', ok: true, generalId: 7 }; + }); const pointUpsert = vi.fn(async () => ({})); const logCreate = vi.fn(async () => ({})); const findMany = vi.fn(async () => (target ? [{ id: target.id, name: target.name }] : [])); @@ -272,10 +281,17 @@ describe('inherit router actor and permission boundaries', () => { }); }); - it('reports and enforces the Ref S100 stat-reset ban without dispatching or charging', async () => { + it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => { const fixture = buildContext({ configMap: { targetGeneralPool: 'SPoolUnderU100' }, inheritancePoint: 0, + daemonResult: { + type: 'inheritanceAction', + ok: false, + action: 'resetStat', + code: 'BAD_REQUEST', + reason: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.', + }, }); const caller = appRouter.createCaller(fixture.context); @@ -291,7 +307,17 @@ describe('inherit router actor and permission boundaries', () => { code: 'BAD_REQUEST', message: '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.', }); - expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'inheritanceAction', + userId: 'user-1', + input: { + action: 'resetStat', + leadership: 70, + strength: 45, + intel: 85, + inheritBonusStat: [2, 1, 1], + }, + }); expect(fixture.pointUpsert).not.toHaveBeenCalled(); expect(fixture.logCreate).not.toHaveBeenCalled(); }); @@ -429,10 +455,17 @@ describe('inherit router actor and permission boundaries', () => { expect(fixture.inheritanceLogFindMany).not.toHaveBeenCalled(); }); - it('does not dispatch or charge when the authenticated user owns no general', async () => { + it('delegates the authenticated actor and maps a missing-general daemon rejection', async () => { const fixture = buildContext({ auth: buildAuth('user-2'), general: buildGeneral({ userId: 'user-1' }), + daemonResult: { + type: 'inheritanceAction', + ok: false, + action: 'buyHiddenBuff', + code: 'PRECONDITION_FAILED', + reason: '장수가 존재하지 않습니다.', + }, }); await expect( @@ -444,12 +477,25 @@ describe('inherit router actor and permission boundaries', () => { code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.', }); - expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'inheritanceAction', + userId: 'user-2', + input: { action: 'buyHiddenBuff', buffType: 'domesticSuccessProb', level: 1 }, + }); expect(fixture.pointUpsert).not.toHaveBeenCalled(); }); it('mutates only the authenticated user general and inheritance balance', async () => { - const fixture = buildContext({ inheritancePoint: 1000 }); + const fixture = buildContext({ + inheritancePoint: 1000, + daemonResult: { + type: 'inheritanceAction', + ok: true, + action: 'buyHiddenBuff', + generalId: 7, + remainPoint: 800, + }, + }); await expect( appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({ @@ -458,29 +504,26 @@ describe('inherit router actor and permission boundaries', () => { }) ).resolves.toEqual({ ok: true, remainPoint: 800 }); - expect(fixture.requestCommand).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'patchGeneral', - generalId: 7, - patch: expect.objectContaining({ - meta: expect.objectContaining({ - inheritBuff: JSON.stringify({ domesticSuccessProb: 1 }), - }), - }), - }) - ); - expect(fixture.pointUpsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { userId_key: { userId: 'user-1', key: 'previous' } }, - update: { value: 800 }, - }) - ); + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'inheritanceAction', + userId: 'user-1', + input: { action: 'buyHiddenBuff', buffType: 'domesticSuccessProb', level: 1 }, + }); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + expect(fixture.logCreate).not.toHaveBeenCalled(); }); it('reserves the selected Ref war trait and charges the authenticated owner once', async () => { const fixture = buildContext({ inheritancePoint: 5_000, configConst: { availableSpecialWar: ['che_의술'] }, + daemonResult: { + type: 'inheritanceAction', + ok: true, + action: 'setNextSpecialWar', + generalId: 7, + remainPoint: 1_000, + }, }); await expect( @@ -488,19 +531,12 @@ describe('inherit router actor and permission boundaries', () => { ).resolves.toEqual({ ok: true }); expect(fixture.requestCommand).toHaveBeenCalledWith({ - type: 'patchGeneral', - generalId: 7, - patch: { meta: { inheritSpecificSpecialWar: 'che_의술' } }, - }); - expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } })); - expect(fixture.logCreate).toHaveBeenCalledWith({ - data: { - userId: 'user-1', - year: 200, - month: 4, - text: '4000 포인트로 다음 전투 특기로 의술 지정', - }, + type: 'inheritanceAction', + userId: 'user-1', + input: { action: 'setNextSpecialWar', specialKey: 'che_의술' }, }); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + expect(fixture.logCreate).not.toHaveBeenCalled(); }); it('does not dispatch or charge when a different war trait is already reserved', async () => { @@ -508,12 +544,19 @@ describe('inherit router actor and permission boundaries', () => { inheritancePoint: 5_000, general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }), configConst: { availableSpecialWar: ['che_의술'] }, + daemonResult: { + type: 'inheritanceAction', + ok: false, + action: 'setNextSpecialWar', + code: 'BAD_REQUEST', + reason: '이미 예약한 특기가 있습니다.', + }, }); await expect( appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' }) ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' }); - expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.requestCommand).toHaveBeenCalledOnce(); expect(fixture.pointUpsert).not.toHaveBeenCalled(); expect(fixture.logCreate).not.toHaveBeenCalled(); }); @@ -522,41 +565,44 @@ describe('inherit router actor and permission boundaries', () => { const fixture = buildContext({ inheritancePoint: 2_000, general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }), + daemonResult: { + type: 'inheritanceAction', + ok: true, + action: 'resetSpecialWar', + generalId: 7, + remainPoint: 1_000, + }, }); await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true }); expect(fixture.requestCommand).toHaveBeenCalledWith({ - type: 'patchGeneral', - generalId: 7, - patch: { - specialWar: null, - meta: { - prev_types_special2: ['che_돌격', 'che_선봉'], - marker: 3, - inheritResetSpecialWar: 0, - }, - }, - }); - expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } })); - expect(fixture.logCreate).toHaveBeenCalledWith({ - data: { - userId: 'user-1', - year: 200, - month: 4, - text: '1000 포인트로 전투 특기 초기화', - }, + type: 'inheritanceAction', + userId: 'user-1', + input: { action: 'resetSpecialWar' }, }); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + expect(fixture.logCreate).not.toHaveBeenCalled(); }); it('does not dispatch or charge when the current war trait is already blank', async () => { - const fixture = buildContext({ inheritancePoint: 2_000, general: buildGeneral({ special2Code: 'None' }) }); + const fixture = buildContext({ + inheritancePoint: 2_000, + general: buildGeneral({ special2Code: 'None' }), + daemonResult: { + type: 'inheritanceAction', + ok: false, + action: 'resetSpecialWar', + code: 'BAD_REQUEST', + reason: '이미 전투 특기가 공란입니다.', + }, + }); await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 전투 특기가 공란입니다.', }); - expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.requestCommand).toHaveBeenCalledOnce(); expect(fixture.pointUpsert).not.toHaveBeenCalled(); expect(fixture.logCreate).not.toHaveBeenCalled(); }); @@ -572,34 +618,41 @@ describe('inherit router actor and permission boundaries', () => { previousTurnTimeBase: 123_456, tickSeconds: worldState.tickSeconds, }); + fixture.requestCommand.mockResolvedValueOnce({ + type: 'inheritanceAction', + ok: true, + action: 'resetTurnTime', + generalId: 7, + remainPoint: 1_000, + ...expected, + }); await expect(appRouter.createCaller(fixture.context).inherit.resetTurnTime()).resolves.toEqual({ ok: true, ...expected, }); expect(fixture.requestCommand).toHaveBeenCalledWith({ - type: 'patchGeneral', - generalId: 7, - patch: { - meta: { - nextTurnTimeBase: expected.nextTurnTimeBase, - inheritResetTurnTime: 0, - }, - }, - }); - expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } })); - expect(fixture.logCreate).toHaveBeenCalledWith({ - data: { - userId: 'user-1', - year: 200, - month: 4, - text: `1000 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${expected.nextTurnTimeLabel} 적용`, - }, + type: 'inheritanceAction', + userId: 'user-1', + input: { action: 'resetTurnTime' }, }); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + expect(fixture.logCreate).not.toHaveBeenCalled(); }); it('reveals a target owner to the caller without using the caller general id from input', async () => { - const fixture = buildContext({ inheritancePoint: 1500 }); + const fixture = buildContext({ + inheritancePoint: 1500, + daemonResult: { + type: 'inheritanceAction', + ok: true, + action: 'checkOwner', + generalId: 7, + remainPoint: 500, + ownerName: '위유저', + targetName: '조조', + }, + }); await expect( appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 }) @@ -608,64 +661,27 @@ describe('inherit router actor and permission boundaries', () => { ownerName: '위유저', targetName: '조조', }); - expect(fixture.pointUpsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { userId_key: { userId: 'user-1', key: 'previous' } }, - update: { value: 500 }, - }) - ); - expect(fixture.logCreate).toHaveBeenCalledWith({ - data: { - userId: 'user-1', - year: 200, - month: 4, - text: '1000 포인트로 장수 소유자 확인', - }, + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'inheritanceAction', + userId: 'user-1', + input: { action: 'checkOwner', targetGeneralId: 8 }, }); - expect(fixture.messageRows).toHaveLength(2); - expect(fixture.messageRows).toEqual([ - expect.objectContaining({ - mailbox: 7, - type: 'private', - src: 0, - dest: 7, - payload: expect.objectContaining({ - src: expect.objectContaining({ generalId: 0, nationName: 'System' }), - dest: expect.objectContaining({ generalId: 7, generalName: '유비' }), - text: '조조의 소유자는 위유저 입니다.', - }), - }), - expect.objectContaining({ - mailbox: 8, - type: 'private', - src: 0, - dest: 8, - payload: expect.objectContaining({ - src: expect.objectContaining({ generalId: 0, nationName: 'System' }), - dest: expect.objectContaining({ generalId: 8, generalName: '조조' }), - text: '소유자명이 누군가에 의해 확인되었습니다.', - }), - }), - ]); - expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(1, { - data: [{ eventId: 'message:101', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-1'] }], - skipDuplicates: true, - }); - expect(fixture.webPushOutboxCreateMany).toHaveBeenNthCalledWith(2, { - data: [{ eventId: 'message:102', eventType: 'PRIVATE_MESSAGE_RECEIVED', userIds: ['user-2'] }], - skipDuplicates: true, - }); - expect(fixture.changeJournal.snapshot()).toEqual([ - { domain: 'messages.mailbox', entityId: 7 }, - { domain: 'messages.mailbox', entityId: 8 }, - ]); - expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + expect(fixture.logCreate).not.toHaveBeenCalled(); + expect(fixture.messageRows).toHaveLength(0); }); it('does not charge or send messages when the owner lookup target is the actor', async () => { const fixture = buildContext({ inheritancePoint: 1_500, target: buildGeneral({ id: 7, userId: 'user-1', name: '유비' }), + daemonResult: { + type: 'inheritanceAction', + ok: false, + action: 'checkOwner', + code: 'BAD_REQUEST', + reason: '자신의 정보는 확인할 수 없습니다.', + }, }); await expect( @@ -681,13 +697,22 @@ describe('inherit router actor and permission boundaries', () => { }); it('does not charge or send messages when inheritance points are insufficient', async () => { - const fixture = buildContext({ inheritancePoint: 999 }); + const fixture = buildContext({ + inheritancePoint: 999, + daemonResult: { + type: 'inheritanceAction', + ok: false, + action: 'checkOwner', + code: 'BAD_REQUEST', + reason: '충분한 유산 포인트를 가지고 있지 않습니다.', + }, + }); await expect( appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 }) ).rejects.toMatchObject({ code: 'BAD_REQUEST', - message: '유산 포인트가 부족합니다.', + message: '충분한 유산 포인트를 가지고 있지 않습니다.', }); expect(fixture.pointUpsert).not.toHaveBeenCalled(); expect(fixture.logCreate).not.toHaveBeenCalled(); diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts index 6ad3b566..f6b7a424 100644 --- a/app/game-api/test/rankingRouter.test.ts +++ b/app/game-api/test/rankingRouter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { RANK_DATA_TYPES } from '@sammo-ts/common'; @@ -108,6 +108,7 @@ const buildContext = (options?: { profileId?: string; generals?: RankingGeneralRow[]; rankRows?: Array<{ generalId: number; type: string; value: number }>; + gameHistoryFindMany?: (args: unknown) => Promise>; }): GameApiContext => { const selectedGeneralRows = options?.generals ?? generalRows; const selectedProfile = options?.profileId @@ -198,10 +199,12 @@ const buildContext = (options?: { findMany: async () => [{ targetCode: 'che_명마_15_적토마' }], }, gameHistory: { - findMany: async () => [ - { season: 3, scenario: 22, scenarioName: '가상모드22' }, - { season: 3, scenario: 22, scenarioName: '가상모드22' }, - ], + findMany: + options?.gameHistoryFindMany ?? + (async () => [ + { season: 3, scenario: 22, scenarioName: '가상모드22' }, + { season: 3, scenario: 22, scenarioName: '가상모드22' }, + ]), }, hallOfFame: { findMany: async (args: { where: { type: string } }) => @@ -303,6 +306,28 @@ describe('ranking.getBestGeneral', () => { }); }); + it('excludes 100th-season event mastery from current Best General values', async () => { + const generals = [ + { + ...generalRows[0]!, + meta: { + ...generalRows[0]!.meta, + dex1: 120, + event100_allstar: { granted: { dex1: 70 } }, + } as unknown as RankingGeneralRow['meta'], + }, + ]; + const result = await appRouter.createCaller(buildContext({ generals })).ranking.getBestGeneral({ + view: 'user', + }); + + expect(result.sections.find((section) => section.title === '보 병 숙 련 도')?.entries[0]).toMatchObject({ + id: 1, + value: 50, + printValue: '50', + }); + }); + it('returns positions one through ten for every populated ranking section', async () => { const generals = Array.from({ length: 12 }, (_, index) => { const id = index + 1; @@ -386,6 +411,21 @@ describe('ranking hall of fame', () => { ]); }); + it('includes the active OPEN game while excluding ABANDONED options', async () => { + const findMany = vi.fn(async () => [ + { season: 4, scenario: 23, scenarioName: '현재 시나리오' }, + { season: 3, scenario: 22, scenarioName: '완료 시나리오' }, + ]); + const options = await appRouter + .createCaller(buildContext({ authenticated: false, gameHistoryFindMany: findMany })) + .ranking.getHallOfFameOptions(); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { status: { in: ['OPEN', 'COMPLETED'] } } }) + ); + expect(options.map((entry) => entry.season)).toEqual([4, 3]); + }); + it('scopes previous-server options and rankings to the request profile', async () => { const cheCaller = appRouter.createCaller(buildContext({ authenticated: false })); await expect(cheCaller.ranking.getHallOfFameOptions({ source: 'legacy' })).resolves.toEqual([ diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index ba5bf014..adf16d54 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -265,6 +265,42 @@ const zPatchGeneral = z.object({ }), }); +const zInheritanceAction = z + .object({ + type: z.literal('inheritanceAction'), + requestId: z.string().min(1).optional(), + userId: z.string().min(1), + input: z.discriminatedUnion('action', [ + z.object({ + action: z.literal('buyHiddenBuff'), + buffType: z.enum([ + 'warAvoidRatio', + 'warCriticalRatio', + 'warMagicTrialProb', + 'domesticSuccessProb', + 'domesticFailProb', + 'warAvoidRatioOppose', + 'warCriticalRatioOppose', + 'warMagicTrialProbOppose', + ]), + level: z.number().int().min(1).max(5), + }), + z.object({ action: z.literal('setNextSpecialWar'), specialKey: z.string().min(1) }), + z.object({ action: z.literal('resetSpecialWar') }), + z.object({ action: z.literal('resetTurnTime') }), + z.object({ + action: z.literal('resetStat'), + leadership: z.number().int(), + strength: z.number().int(), + intel: z.number().int(), + inheritBonusStat: z.tuple([z.number().int(), z.number().int(), z.number().int()]).optional(), + }), + z.object({ action: z.literal('buyRandomUnique') }), + z.object({ action: z.literal('checkOwner'), targetGeneralId: z.number().int().positive() }), + ]), + }) + .strict(); + const zAdjustGeneralIcon = z .object({ type: z.literal('adjustGeneralIcon'), @@ -643,6 +679,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => { return { ...command, requestId: envelope.requestId }; }; +const normalizeInheritanceAction: CommandNormalizer<'inheritanceAction'> = (envelope) => { + const command = parseWith(zInheritanceAction, envelope.command); + if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (envelope) => { const command = parseWith(zAdjustGeneralIcon, envelope.command); if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) { @@ -764,6 +808,7 @@ const normalizers: CommandNormalizerMap = { adjustGeneralMeta: normalizeAdjustGeneralMeta, tournamentMatchResult: normalizeTournamentMatchResult, patchGeneral: normalizePatchGeneral, + inheritanceAction: normalizeInheritanceAction, adjustGeneralIcon: normalizeAdjustGeneralIcon, joinCreateGeneral: normalizeJoinCreateGeneral, npcPossessGeneral: normalizeNpcPossessGeneral, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 215f0f31..9480572f 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -44,7 +44,7 @@ import type { InMemoryTurnWorld, TurnWorldChanges } from './inMemoryWorld.js'; import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js'; import { buildDiplomacyMeta } from '@sammo-ts/logic'; import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js'; -import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js'; +import { persistGeneralLifecycleEvents, type GeneralLifecycleArchiveLog } from './generalTurnLifecyclePersistence.js'; import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js'; import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js'; import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; @@ -1094,6 +1094,7 @@ export const createDatabaseTurnHooks = async ( lifecycleEvents, pendingNeutralAuctions, inheritancePointAdjustments, + pendingInheritanceLogs, pendingNationBettingOpens, pendingNationBettingFinishes, pendingYearbookSnapshots, @@ -1137,6 +1138,20 @@ export const createDatabaseTurnHooks = async ( select: { id: true }, }) )?.id ?? 0; + const logContext = { + year: state.currentYear, + month: state.currentMonth, + at: state.lastTurnTime, + }; + const pendingLogRows = logs + .map((entry) => buildLogCreateData(entry, logContext)) + .filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry)); + const pendingLifecycleArchiveLogs: GeneralLifecycleArchiveLog[] = pendingLogRows.flatMap((entry) => + entry.generalId !== null && + (entry.category === LogCategory.HISTORY || entry.category === LogCategory.BATTLE_BRIEF) + ? [{ generalId: entry.generalId, category: entry.category, text: entry.text }] + : [] + ); // Lock and validate the fencing row in the same transaction as every // world mutation. A stale daemon can finish calculating, but it can // never commit after another owner has advanced the epoch. @@ -1257,15 +1272,24 @@ export const createDatabaseTurnHooks = async ( const meta = asRecord(state.meta); const serverId = typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default'; - if (inheritancePointAdjustments.length > 0) { + const persistInheritancePointAdjustments = async ( + entries: typeof inheritancePointAdjustments + ): Promise => { + if (entries.length === 0) { + return; + } const grouped = new Map(); - for (const entry of inheritancePointAdjustments) { + for (const entry of entries) { const groupKey = `${entry.userId}\u0000${entry.key}`; const current = grouped.get(groupKey); if (current) { current.amount += entry.amount; } else { - grouped.set(groupKey, { ...entry }); + grouped.set(groupKey, { + userId: entry.userId, + key: entry.key, + amount: entry.amount, + }); } } for (const entry of grouped.values()) { @@ -1275,14 +1299,41 @@ export const createDatabaseTurnHooks = async ( create: { userId: entry.userId, key: entry.key, value: entry.amount }, }); } - } + }; + const persistInheritanceLogs = async (entries: typeof pendingInheritanceLogs): Promise => { + if (entries.length === 0) { + return; + } + await prisma.inheritanceLog.createMany({ + data: entries.map((entry) => ({ + userId: entry.userId, + year: entry.year, + month: entry.month, + text: entry.text, + })), + }); + }; + const beforeLifecycleAdjustments = inheritancePointAdjustments.filter( + (entry) => entry.phase !== 'after_lifecycle' + ); + const afterLifecycleAdjustments = inheritancePointAdjustments.filter( + (entry) => entry.phase === 'after_lifecycle' + ); + const beforeLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase !== 'after_lifecycle'); + const afterLifecycleLogs = pendingInheritanceLogs.filter((entry) => entry.phase === 'after_lifecycle'); + + await persistInheritancePointAdjustments(beforeLifecycleAdjustments); + await persistInheritanceLogs(beforeLifecycleLogs); await persistGeneralLifecycleEvents( prisma, lifecycleEvents, meta, asRecord(world.getScenarioConfig().const), - world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0) + world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0), + pendingLifecycleArchiveLogs ); + await persistInheritancePointAdjustments(afterLifecycleAdjustments); + await persistInheritanceLogs(afterLifecycleLogs); if (accessScoreResetGeneralIds.length > 0) { await prisma.generalAccessLog.updateMany({ @@ -1611,20 +1662,10 @@ export const createDatabaseTurnHooks = async ( await upsertRankRows(prisma, rankRows); } - if (logs.length > 0) { - const logContext = { - year: state.currentYear, - month: state.currentMonth, - at: state.lastTurnTime, - }; - const payload = logs - .map((entry) => buildLogCreateData(entry, logContext)) - .filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry)); - if (payload.length > 0) { - await prisma.logEntry.createMany({ - data: payload, - }); - } + if (pendingLogRows.length > 0) { + await prisma.logEntry.createMany({ + data: pendingLogRows, + }); } for (const snapshot of pendingYearbookSnapshots) { await persistYearbookSnapshot(prisma, snapshot); diff --git a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts index 6347bec5..485518a6 100644 --- a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts +++ b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts @@ -1,9 +1,23 @@ -import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; +import { + asRecord, + HALL_OF_FAME_TYPES, + RANK_DATA_TYPES, + rankDataMetaKey, + resolveLegacyTextColor, + type HallOfFameType, +} from '@sammo-ts/common'; import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; import { LogCategory, LogScope } from '@sammo-ts/logic'; import { computeInheritanceSettlementBreakdown } from '@sammo-ts/logic/inheritance/pointCalculation.js'; +import { + readCentennialRecordableDexterity, + type CentennialDexKey, +} from '@sammo-ts/logic/scenario/centennialAllStar.js'; import type { GeneralLifecycleEvent } from './inMemoryWorld.js'; +import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js'; +import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js'; +import { buildPersistedRankRows } from './rankData.js'; const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; @@ -24,12 +38,59 @@ const readWorldNumber = (record: Record, key: string, fallback: return value === 0 && record[key] === undefined ? fallback : Math.floor(value); }; +type LifecycleRankValues = Map; + +export interface GeneralLifecycleArchiveLog { + generalId: number; + category: string; + text: string; +} + +const loadLifecycleRankValues = async ( + prisma: GamePrisma.TransactionClient, + event: GeneralLifecycleEvent +): Promise => { + const persisted = await prisma.rankData.findMany({ + where: { generalId: event.generalId }, + select: { type: true, value: true }, + }); + const values = new Map(persisted.map((row) => [row.type, row.value])); + const snapshotMeta = asRecord(event.before.meta); + for (const row of buildPersistedRankRows(event.before)) { + if ( + row.type === 'experience' || + row.type === 'dedication' || + Object.prototype.hasOwnProperty.call(snapshotMeta, rankDataMetaKey(row.type)) + ) { + values.set(row.type, row.value); + } + } + return values; +}; + +const persistPostRetirementRankValues = async ( + prisma: GamePrisma.TransactionClient, + event: GeneralLifecycleEvent +): Promise => { + if (!event.after) { + return; + } + for (const row of buildPersistedRankRows(event.after)) { + await prisma.rankData.upsert({ + where: { generalId_type: { generalId: row.generalId, type: row.type } }, + update: { nationId: row.nationId, value: row.value }, + create: row, + }); + } +}; + const settleInheritance = async ( prisma: GamePrisma.TransactionClient, event: GeneralLifecycleEvent, worldMeta: Record, isRebirth: boolean, - configConst: Record + configConst: Record, + rankValues: ReadonlyMap ): Promise => { const userId = event.before.userId; if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) { @@ -53,29 +114,23 @@ const settleInheritance = async ( } } - 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 rows = await prisma.inheritancePoint.findMany({ + where: { userId }, + select: { key: true, value: true }, + }); const points = new Map(rows.map((row) => [row.key, row.value])); const previous = points.get('previous') ?? 0; - const randomUniqueRefund = meta.inheritRandomUnique - ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) - : 0; - const specificSpecialRefund = meta.inheritSpecificSpecialWar - ? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000) - : 0; + const randomUniqueRefund = + !isRebirth && meta.inheritRandomUnique ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) : 0; + const specificSpecialRefund = + !isRebirth && meta.inheritSpecificSpecialWar + ? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000) + : 0; const refund = randomUniqueRefund + specificSpecialRefund; const calculationMeta = { + ...Object.fromEntries(rankValues), + ...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])), ...meta, - ...Object.fromEntries(rankRows.map((row) => [row.type, row.value])), - ...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])), }; const settlement = computeInheritanceSettlementBreakdown( { @@ -143,14 +198,22 @@ const settleInheritance = async ( }, }); } - await prisma.inheritanceLog.create({ - data: { - userId, - year: event.year, - month: event.month, - text: `${isRebirth ? '은퇴' : '사망'} 정산: ${total.toLocaleString()} 포인트`, - }, - }); + for (const text of buildInheritanceSettlementLogTexts({ + previous: previous + refund, + points: settlement.earned, + storedKeys: new Set([...points.keys(), ...(refund > 0 ? (['previous'] as const) : [])]), + total, + isRebirth, + })) { + await prisma.inheritanceLog.create({ + data: { + userId, + year: event.year, + month: event.month, + text, + }, + }); + } }; const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0); @@ -159,26 +222,23 @@ const settleHall = async ( prisma: GamePrisma.TransactionClient, event: GeneralLifecycleEvent, worldMeta: Record, - gameNow: Date + gameNow: Date, + rank: ReadonlyMap ): Promise => { - const isUnited = readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0)); + const isUnited = + event.isUnitedAtEvent ?? 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 }, - }), + const [nation, serverIdx] = await Promise.all([ event.before.nationId > 0 ? prisma.nation.findUnique({ where: { id: event.before.nationId }, select: { name: true, color: true }, }) : null, - prisma.gameHistory.count(), + resolveOfficialGameIndex(prisma, worldMeta), ]); - 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'); @@ -221,12 +281,13 @@ const settleHall = async ( picture: event.before.picture ?? null, imgsvr: event.before.imageServer ?? 0, serverID: serverId, - serverIdx: historyCount, + serverIdx, scenarioName, serverName: typeof worldMeta.serverName === 'string' ? worldMeta.serverName : '', }; for (const type of HALL_OF_FAME_TYPES) { + const eventMeta = asRecord(event.before.meta); let hallValue = type === 'experience' ? event.before.experience @@ -234,7 +295,9 @@ const settleHall = async ( ? event.before.dedication : type.endsWith('rate') ? (calc[type] ?? 0) - : value(type); + : type.startsWith('dex') + ? readCentennialRecordableDexterity(eventMeta, type as CentennialDexKey) + : value(type); if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue; if (type === 'ttrate' && tt < 50) continue; if (type === 'tlrate' && tl < 50) continue; @@ -244,72 +307,56 @@ const settleHall = async ( 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, + await persistHallOfFameCandidate(prisma, { + serverId, + season, + scenario, + generalNo: event.generalId, + type: type as HallOfFameType, + value: hallValue, + owner: event.before.userId ?? null, + aux, }); } }; -const archiveDeletedGeneral = async ( +const archiveGeneral = async ( prisma: GamePrisma.TransactionClient, event: GeneralLifecycleEvent, - worldMeta: Record + worldMeta: Record, + rankValues: ReadonlyMap, + pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[] ): Promise => { const serverId = typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; - const [recordRows, rankRows] = await Promise.all([ - prisma.logEntry.findMany({ - where: { - generalId: event.generalId, - scope: LogScope.GENERAL, - category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] }, - }, - orderBy: { id: 'desc' }, - select: { category: true, text: true }, - }), - prisma.rankData.findMany({ - where: { generalId: event.generalId }, - select: { type: true, value: true }, - }), - ]); + const recordRows = await prisma.logEntry.findMany({ + where: { + generalId: event.generalId, + scope: LogScope.GENERAL, + category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] }, + }, + orderBy: { id: 'desc' }, + select: { category: true, text: true }, + }); const archivedMeta = { ...asRecord(event.before.meta), - ...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])), + ...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])), }; - delete archivedMeta.inheritRandomUnique; - delete archivedMeta.inheritSpecificSpecialWar; - const history = recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text); - const battleResults = recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text); + const pendingGeneralLogs = pendingArchiveLogs.filter((row) => row.generalId === event.generalId); + const history = [ + ...pendingGeneralLogs + .filter((row) => row.category === LogCategory.HISTORY) + .map((row) => row.text) + .reverse(), + ...recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text), + ]; + const battleResults = [ + ...pendingGeneralLogs + .filter((row) => row.category === LogCategory.BATTLE_BRIEF) + .map((row) => row.text) + .reverse(), + ...recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text), + ]; const data = { ...event.before, meta: archivedMeta, @@ -345,7 +392,8 @@ export const persistGeneralLifecycleEvents = async ( events: GeneralLifecycleEvent[], worldMeta: Record, configConst: Record, - gameNow = new Date() + gameNow = new Date(), + pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[] = [] ): Promise => { if (events.length === 0) { return; @@ -359,17 +407,18 @@ export const persistGeneralLifecycleEvents = async ( if (event.outcome === 'detached' || event.outcome === 'deleted') { await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } }); } + if (event.outcome !== 'deleted' && event.outcome !== 'retired') { + continue; + } + const rankValues = await loadLifecycleRankValues(prisma, event); if (event.outcome === 'deleted') { - await archiveDeletedGeneral(prisma, event, worldMeta); - await settleInheritance(prisma, event, worldMeta, false, configConst); + await settleInheritance(prisma, event, worldMeta, false, configConst, rankValues); + await archiveGeneral(prisma, event, worldMeta, rankValues, pendingArchiveLogs); } if (event.outcome === 'retired') { - await settleHall(prisma, event, worldMeta, gameNow); - await settleInheritance(prisma, event, worldMeta, true, configConst); - await prisma.rankData.updateMany({ - where: { generalId: event.generalId }, - data: { value: 0 }, - }); + await settleHall(prisma, event, worldMeta, gameNow, rankValues); + await settleInheritance(prisma, event, worldMeta, true, configConst, rankValues); + await persistPostRetirementRankValues(prisma, event); } } }; diff --git a/app/game-engine/src/turn/hallOfFamePersistence.ts b/app/game-engine/src/turn/hallOfFamePersistence.ts new file mode 100644 index 00000000..60698b9b --- /dev/null +++ b/app/game-engine/src/turn/hallOfFamePersistence.ts @@ -0,0 +1,83 @@ +import type { HallOfFameType } from '@sammo-ts/common'; +import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; + +const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; + +const readInteger = (value: unknown, fallback: number): number => { + const parsed = typeof value === 'string' ? Number(value) : value; + return typeof parsed === 'number' && Number.isFinite(parsed) ? Math.floor(parsed) : fallback; +}; + +/** + * `gameIdx` is fixed when RESET opens a game and deliberately excludes + * retained ABANDONED rows. Older fixtures may not carry it, so reconstruct the + * same sequence from completed games plus the configured first index. + */ +export const resolveOfficialGameIndex = async ( + prisma: GamePrisma.TransactionClient, + worldMeta: Record +): Promise => { + if (worldMeta.gameIdx !== undefined) { + return readInteger(worldMeta.gameIdx, 0); + } + const completedGames = await prisma.gameHistory.count({ where: { status: 'COMPLETED' } }); + return completedGames + readInteger(worldMeta.firstGameIdx, 1); +}; + +export interface HallOfFameCandidate { + serverId: string; + season: number; + scenario: number; + generalNo: number; + type: HallOfFameType; + value: number; + owner: string | null; + aux: unknown; +} + +/** + * Ref insertIgnore treats an owner record belonging to another general as a + * complete winner: it does not reassign that row even when the new value is + * higher. Only an existing row for the same general and scenario may replace + * value+aux, keeping every identity column unchanged. This avoids the former + * Core state where an old general number was combined with a new general's aux. + */ +export const persistHallOfFameCandidate = async ( + prisma: GamePrisma.TransactionClient, + candidate: HallOfFameCandidate +): Promise<'CREATED' | 'UPDATED' | 'PRESERVED'> => { + const matches = await prisma.hallOfFame.findMany({ + where: { + OR: [ + { serverId: candidate.serverId, type: candidate.type, generalNo: candidate.generalNo }, + ...(candidate.owner + ? [{ serverId: candidate.serverId, type: candidate.type, owner: candidate.owner }] + : []), + ], + }, + }); + const sameGeneral = matches.find((entry) => entry.generalNo === candidate.generalNo); + if (!sameGeneral && matches.length > 0) { + return 'PRESERVED'; + } + if (!sameGeneral) { + await prisma.hallOfFame.create({ + data: { + ...candidate, + aux: asJson(candidate.aux), + }, + }); + return 'CREATED'; + } + if (sameGeneral.scenario !== candidate.scenario || candidate.value <= sameGeneral.value) { + return 'PRESERVED'; + } + await prisma.hallOfFame.update({ + where: { id: sameGeneral.id }, + data: { + value: candidate.value, + aux: asJson(candidate.aux), + }, + }); + return 'UPDATED'; +}; diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 6511d529..7f66223a 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -83,6 +83,8 @@ export interface GeneralLifecycleEvent { outcome: 'active' | 'detached' | 'deleted' | 'retired'; before: TurnGeneral; after?: TurnGeneral; + /** World unification state observed when this lifecycle transition occurred. */ + isUnitedAtEvent?: number; year: number; month: number; } @@ -123,6 +125,23 @@ export interface InMemoryGameClockState { lastTurnTick: number; } +export type InheritancePersistencePhase = 'before_lifecycle' | 'after_lifecycle'; + +export interface PendingInheritancePointAdjustment { + userId: string; + key: string; + amount: number; + phase?: InheritancePersistencePhase; +} + +export interface PendingInheritanceLog { + userId: string; + year: number; + month: number; + text: string; + phase?: InheritancePersistencePhase; +} + export interface TurnWorldChanges { realtimeBacklogShiftTicks: number; accessScoreResetGeneralIds: number[]; @@ -145,7 +164,8 @@ export interface TurnWorldChanges { deletedEvents: number[]; lifecycleEvents: GeneralLifecycleEvent[]; pendingNeutralAuctions: PendingNeutralAuction[]; - inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>; + inheritancePointAdjustments: PendingInheritancePointAdjustment[]; + pendingInheritanceLogs: PendingInheritanceLog[]; pendingNationBettingOpens: PendingNationBettingOpen[]; pendingNationBettingFinishes: PendingNationBettingFinish[]; pendingYearbookSnapshots: PendingYearbookSnapshot[]; @@ -184,7 +204,8 @@ export interface InMemoryTurnWorldStateSnapshot { messages: MessageDraft[]; lifecycleEvents: GeneralLifecycleEvent[]; pendingNeutralAuctions: PendingNeutralAuction[]; - inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>; + inheritancePointAdjustments: PendingInheritancePointAdjustment[]; + pendingInheritanceLogs: PendingInheritanceLog[]; pendingNationBettingOpens: PendingNationBettingOpen[]; pendingNationBettingFinishes: PendingNationBettingFinish[]; pendingYearbookSnapshots: PendingYearbookSnapshot[]; @@ -487,7 +508,8 @@ 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 inheritancePointAdjustments: PendingInheritancePointAdjustment[] = []; + private readonly pendingInheritanceLogs: PendingInheritanceLog[] = []; private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = []; private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = []; private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = []; @@ -786,6 +808,7 @@ export class InMemoryTurnWorld { lifecycleEvents: this.lifecycleEvents, pendingNeutralAuctions: this.pendingNeutralAuctions, inheritancePointAdjustments: this.inheritancePointAdjustments, + pendingInheritanceLogs: this.pendingInheritanceLogs, pendingNationBettingOpens: this.pendingNationBettingOpens, pendingNationBettingFinishes: this.pendingNationBettingFinishes, pendingYearbookSnapshots: this.pendingYearbookSnapshots, @@ -831,6 +854,7 @@ export class InMemoryTurnWorld { this.replaceArray(this.lifecycleEvents, restored.lifecycleEvents); this.replaceArray(this.pendingNeutralAuctions, restored.pendingNeutralAuctions); this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments); + this.replaceArray(this.pendingInheritanceLogs, restored.pendingInheritanceLogs ?? []); this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens); this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes); this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots); @@ -990,11 +1014,23 @@ export class InMemoryTurnWorld { }); } - queueInheritancePointAdjustment(userId: string, key: string, amount: number): void { + queueInheritancePointAdjustment( + userId: string, + key: string, + amount: number, + phase?: InheritancePersistencePhase + ): void { if (!userId || !Number.isFinite(amount) || amount === 0) { return; } - this.inheritancePointAdjustments.push({ userId, key, amount }); + this.inheritancePointAdjustments.push({ userId, key, amount, ...(phase ? { phase } : {}) }); + } + + queueInheritanceLog(log: PendingInheritanceLog): void { + if (!log.userId || !log.text) { + return; + } + this.pendingInheritanceLogs.push({ ...log }); } queueNationBettingOpen(betting: PendingNationBettingOpen): void { @@ -1271,6 +1307,9 @@ export class InMemoryTurnWorld { generalId: id, outcome: 'deleted', before: structuredClone(general), + isUnitedAtEvent: Math.floor( + readMetaNumber(this.state.meta, 'isunited') ?? readMetaNumber(this.state.meta, 'isUnited') ?? 0 + ), year, month, }); @@ -1811,6 +1850,7 @@ export class InMemoryTurnWorld { closeAt: new Date(auction.closeAt.getTime()), })); const inheritancePointAdjustments = this.inheritancePointAdjustments.map((entry) => ({ ...entry })); + const pendingInheritanceLogs = this.pendingInheritanceLogs.map((entry) => ({ ...entry })); const pendingNationBettingOpens = this.pendingNationBettingOpens.map((entry) => ({ ...entry, candidates: entry.candidates.map((candidate) => ({ @@ -1852,6 +1892,7 @@ export class InMemoryTurnWorld { lifecycleEvents, pendingNeutralAuctions, inheritancePointAdjustments, + pendingInheritanceLogs, pendingNationBettingOpens, pendingNationBettingFinishes, pendingYearbookSnapshots, @@ -1889,6 +1930,7 @@ export class InMemoryTurnWorld { this.lifecycleEvents.splice(0, changes.lifecycleEvents.length); this.pendingNeutralAuctions.splice(0, changes.pendingNeutralAuctions.length); this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length); + this.pendingInheritanceLogs.splice(0, changes.pendingInheritanceLogs.length); this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length); this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length); this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length); diff --git a/app/game-engine/src/turn/inheritanceActionService.ts b/app/game-engine/src/turn/inheritanceActionService.ts new file mode 100644 index 00000000..6e6e9535 --- /dev/null +++ b/app/game-engine/src/turn/inheritanceActionService.ts @@ -0,0 +1,670 @@ +import { + asNumber, + asRecord, + LiteHashDRBG, + parseJson, + RandUtil, + rankDataMetaKey, + type TurnDaemonCommand, + type TurnDaemonCommandResult, + type TurnDaemonInheritanceAction, +} from '@sammo-ts/common'; +import { GamePrisma } from '@sammo-ts/infra'; +import { + isCentennialStatResetAllowed, + isWarTraitKey, + loadWarTraitModules, + resolveMessageTargetIcon, + WarTraitLoader, + type InheritBuffType, + type MessageDraft, + type MessageTarget, +} from '@sammo-ts/logic'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { TurnGeneral } from './types.js'; + +type InheritanceActionCommand = Extract; +type InheritanceActionResult = Extract; + +interface InheritConstants { + inheritBornStatPoint: number; + inheritItemRandomPoint: number; + inheritBuffPoints: number[]; + inheritSpecificSpecialPoint: number; + inheritResetAttrPointBase: number[]; + inheritCheckOwnerPoint: number; +} + +const DEFAULT_INHERIT_CONST: InheritConstants = { + inheritBornStatPoint: 1_000, + inheritItemRandomPoint: 3_000, + inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000], + inheritSpecificSpecialPoint: 4_000, + inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000], + inheritCheckOwnerPoint: 1_000, +}; + +const BUFF_LABELS: Record = { + warAvoidRatio: '회피 확률 증가', + warCriticalRatio: '필살 확률 증가', + warMagicTrialProb: '전투계략 시도 확률 증가', + domesticSuccessProb: '내정 성공률 증가', + domesticFailProb: '내정 실패율 감소', + warAvoidRatioOppose: '상대 회피 확률 감소', + warCriticalRatioOppose: '상대 필살 확률 감소', + warMagicTrialProbOppose: '상대 전투계략 시도 확률 감소', +}; + +const SYSTEM_TARGET: MessageTarget = { + generalId: 0, + generalName: '', + nationId: 0, + nationName: 'System', + color: '#000000', + icon: '', +}; + +const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; + +const resolveNumberArray = (value: unknown, fallback: number[]): number[] => { + if (!Array.isArray(value)) return [...fallback]; + const result = value + .map((entry) => (typeof entry === 'number' && Number.isFinite(entry) ? entry : null)) + .filter((entry): entry is number => entry !== null); + return result.length > 0 ? result : [...fallback]; +}; + +const resolveInheritConstants = (world: InMemoryTurnWorld): InheritConstants => { + const configConst = asRecord(world.getScenarioConfig().const); + return { + inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, DEFAULT_INHERIT_CONST.inheritBornStatPoint), + inheritItemRandomPoint: asNumber( + configConst.inheritItemRandomPoint, + DEFAULT_INHERIT_CONST.inheritItemRandomPoint + ), + inheritBuffPoints: resolveNumberArray(configConst.inheritBuffPoints, DEFAULT_INHERIT_CONST.inheritBuffPoints), + inheritSpecificSpecialPoint: asNumber( + configConst.inheritSpecificSpecialPoint, + DEFAULT_INHERIT_CONST.inheritSpecificSpecialPoint + ), + inheritResetAttrPointBase: resolveNumberArray( + configConst.inheritResetAttrPointBase, + DEFAULT_INHERIT_CONST.inheritResetAttrPointBase + ), + inheritCheckOwnerPoint: asNumber( + configConst.inheritCheckOwnerPoint, + DEFAULT_INHERIT_CONST.inheritCheckOwnerPoint + ), + }; +}; + +const buildResetCost = (baseCosts: number[], level: number): number => { + const costs = [...baseCosts]; + while (costs.length <= level) { + const size = costs.length; + costs.push((costs[size - 1] ?? 0) + (costs[size - 2] ?? 0)); + } + return costs[level] ?? 0; +}; + +const readBuffRecord = (raw: unknown): Record => { + const source = typeof raw === 'string' ? (parseJson>(raw) ?? {}) : asRecord(raw); + return Object.fromEntries( + Object.entries(source).filter((entry): entry is [string, number] => { + const value = entry[1]; + return typeof value === 'number' && Number.isFinite(value); + }) + ); +}; + +const readBuffLevel = (buff: Record, key: InheritBuffType): number => { + const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null; + return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0))); +}; + +const readStringList = (raw: unknown): string[] => { + const value = typeof raw === 'string' ? parseJson(raw) : raw; + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []; +}; + +const readMetaNumber = (meta: Record, key: string, fallback: number): number => { + const value = meta[key]; + if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value); + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return Math.floor(parsed); + } + return fallback; +}; + +const resolveSeasonValue = (meta: Record): number | null => { + const value = meta.season; + if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value); + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return Math.floor(parsed); + } + return null; +}; + +const readResetSeasons = (meta: Record): number[] => + Array.isArray(meta.last_stat_reset) + ? meta.last_stat_reset + .map((value) => (typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null)) + .filter((value): value is number => value !== null) + : []; + +export const buildResetStatRandomBonus = ( + rng: RandUtil, + baseStats: [number, number, number] +): [number, number, number] => { + const bonusCount = rng.nextRangeInt(3, 5); + const bonus = [0, 0, 0] as [number, number, number]; + for (let index = 0; index < bonusCount; index += 1) { + const selected = Number( + rng.choiceUsingWeight({ + 0: baseStats[0], + 1: baseStats[1], + 2: baseStats[2], + }) + ) as 0 | 1 | 2; + bonus[selected] += 1; + } + return bonus; +}; + +const formatTurnTimeBaseLabel = (value: number): string => { + const wholeSeconds = Math.trunc(value); + const hours = String(Math.trunc(wholeSeconds / 3_600)).padStart(2, '0'); + const minutes = String(Math.trunc((wholeSeconds % 3_600) / 60)).padStart(2, '0'); + return `${hours}:${minutes}`; +}; + +const resolveResetTurnTimeBase = (options: { + hiddenSeed: string | number; + userId: string; + previousTurnTimeBase: string | number; + tickSeconds: number; +}): { nextTurnTimeBase: number; nextTurnTimeLabel: string } => { + const rng = new LiteHashDRBG( + simpleSerialize(options.hiddenSeed, 'ResetTurnTime', options.userId, options.previousTurnTimeBase) + ); + const nextTurnTimeBase = rng.nextFloat1() * Math.max(60, options.tickSeconds); + return { nextTurnTimeBase, nextTurnTimeLabel: formatTurnTimeBaseLabel(nextTurnTimeBase) }; +}; + +const reject = ( + action: TurnDaemonInheritanceAction['action'], + code: Extract['code'], + reason: string +): InheritanceActionResult => ({ type: 'inheritanceAction', ok: false, action, code, reason }); + +const lockPreviousPoint = async (db: GamePrisma.TransactionClient, userId: string): Promise => { + const rows = await db.$queryRaw>(GamePrisma.sql` + SELECT value + FROM inheritance_point + WHERE user_id = ${userId} AND key = 'previous' + FOR UPDATE + `); + return rows[0]?.value ?? 0; +}; + +const appendInheritanceLog = async ( + db: GamePrisma.TransactionClient, + userId: string, + year: number, + month: number, + text: string +): Promise => { + await db.inheritanceLog.create({ data: { userId, year, month, text } }); +}; + +const buildMessageTarget = (world: InMemoryTurnWorld, general: TurnGeneral): MessageTarget => { + const nation = general.nationId > 0 ? world.getNationById(general.nationId) : null; + return { + generalId: general.id, + generalName: general.name, + nationId: general.nationId, + nationName: nation?.name ?? '재야', + color: nation?.color ?? '#000000', + icon: resolveMessageTargetIcon(general), + }; +}; + +const queueOwnerLookupMessages = ( + world: InMemoryTurnWorld, + actor: TurnGeneral, + target: TurnGeneral, + ownerName: string, + gameNow: Date +): void => { + const validUntil = new Date('9999-12-31T00:00:00.000Z'); + const messages: MessageDraft[] = [ + { + msgType: 'private', + src: SYSTEM_TARGET, + dest: buildMessageTarget(world, actor), + text: `${target.name}의 소유자는 ${ownerName} 입니다.`, + time: gameNow, + validUntil, + option: {}, + sendDestOnly: true, + }, + { + msgType: 'private', + src: SYSTEM_TARGET, + dest: buildMessageTarget(world, target), + text: '소유자명이 누군가에 의해 확인되었습니다.', + time: gameNow, + validUntil, + option: {}, + sendDestOnly: true, + }, + ]; + for (const message of messages) world.queueMessage(message); +}; + +const applyCharge = (options: { + world: InMemoryTurnWorld; + general: TurnGeneral; + userId: string; + previousPoint: number; + cost: number; + patch: Partial; +}): TurnGeneral => { + const { world, general, userId, previousPoint, cost, patch } = options; + const patchMeta = patch.meta ? asRecord(patch.meta) : general.meta; + const spentKey = rankDataMetaKey('inherit_spent_dyn'); + const nextMeta = { + ...patchMeta, + [spentKey]: readMetaNumber(general.meta, spentKey, 0) + cost, + } as TurnGeneral['meta']; + const next = world.updateGeneral(general.id, { + ...patch, + meta: nextMeta, + inheritancePoints: { + ...general.inheritancePoints, + previous: previousPoint - cost, + }, + }); + if (!next) throw new Error(`Inheritance action general ${general.id} disappeared during mutation.`); + world.queueInheritancePointAdjustment(userId, 'previous', -cost); + return next; +}; + +const isUnited = (world: InMemoryTurnWorld): boolean => { + const meta = asRecord(world.getState().meta); + return asNumber(meta.isunited, 0) !== 0 || asNumber(meta.isUnited, 0) !== 0; +}; + +export const resolveOwnerDisplayName = (rawMeta: unknown): string => { + const meta = asRecord(rawMeta); + for (const key of ['ownerDisplayName', 'owner_name', 'ownerName']) { + const value = meta[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + return '알수없음'; +}; + +export const executeInheritanceAction = async (options: { + db: GamePrisma.TransactionClient; + world: InMemoryTurnWorld; + command: InheritanceActionCommand; + gameNow: Date; +}): Promise => { + const { db, world, command, gameNow } = options; + const { input, userId } = command; + const action = input.action; + const general = world.listGenerals().find((candidate) => candidate.userId === userId); + if (!general) return reject(action, 'PRECONDITION_FAILED', '장수가 존재하지 않습니다.'); + + const state = world.getState(); + const worldMeta = asRecord(state.meta); + const config = world.getScenarioConfig(); + const configRecord = asRecord(config); + const constants = resolveInheritConstants(world); + + if (action === 'checkOwner') { + if (input.targetGeneralId === general.id) { + return reject(action, 'BAD_REQUEST', '자신의 정보는 확인할 수 없습니다.'); + } + const target = world.getGeneralById(input.targetGeneralId); + if (!target) return reject(action, 'BAD_REQUEST', '대상 장수가 존재하지 않습니다.'); + if (!target.userId) return reject(action, 'BAD_REQUEST', '대상 장수는 NPC입니다.'); + if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.'); + const previousPoint = await lockPreviousPoint(db, userId); + const cost = constants.inheritCheckOwnerPoint; + if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.'); + const ownerName = resolveOwnerDisplayName(target.meta); + + await appendInheritanceLog( + db, + userId, + state.currentYear, + state.currentMonth, + `${cost} 포인트로 장수 소유자 확인` + ); + queueOwnerLookupMessages(world, general, target, ownerName, gameNow); + applyCharge({ world, general, userId, previousPoint, cost, patch: {} }); + return { + type: 'inheritanceAction', + ok: true, + action, + generalId: general.id, + remainPoint: previousPoint - cost, + ownerName, + targetName: target.name, + }; + } + + if (action === 'buyHiddenBuff') { + const buff = readBuffRecord(general.meta.inheritBuff); + const previousLevel = readBuffLevel(buff, input.buffType); + if (input.level === previousLevel) return reject(action, 'BAD_REQUEST', '이미 구입했습니다.'); + if (input.level < previousLevel) return reject(action, 'BAD_REQUEST', '이미 더 높은 등급을 구입했습니다.'); + if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.'); + const cost = + (constants.inheritBuffPoints[input.level] ?? 0) - (constants.inheritBuffPoints[previousLevel] ?? 0); + const previousPoint = await lockPreviousPoint(db, userId); + if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.'); + const moreText = previousLevel > 0 ? '추가' : ''; + buff[input.buffType] = input.level; + await appendInheritanceLog( + db, + userId, + state.currentYear, + state.currentMonth, + `${cost} 포인트로 ${BUFF_LABELS[input.buffType]} ${input.level} 단계 ${moreText}구입` + ); + applyCharge({ + world, + general, + userId, + previousPoint, + cost, + patch: { meta: { ...general.meta, inheritBuff: JSON.stringify(buff) } }, + }); + return { + type: 'inheritanceAction', + ok: true, + action, + generalId: general.id, + remainPoint: previousPoint - cost, + }; + } + + if (action === 'setNextSpecialWar') { + if (!isWarTraitKey(input.specialKey)) return reject(action, 'BAD_REQUEST', '잘못된 전투 특기입니다.'); + const configConst = asRecord(config.const); + const allowed = Array.isArray(configConst.availableSpecialWar) + ? configConst.availableSpecialWar.filter((key): key is string => typeof key === 'string') + : []; + if (allowed.length > 0 && !allowed.includes(input.specialKey)) { + return reject(action, 'BAD_REQUEST', '허용되지 않은 전투 특기입니다.'); + } + if (general.role.specialWar === input.specialKey) { + return reject(action, 'BAD_REQUEST', '이미 그 특기를 보유하고 있습니다.'); + } + const reserved = + typeof general.meta.inheritSpecificSpecialWar === 'string' ? general.meta.inheritSpecificSpecialWar : null; + if (reserved === input.specialKey) return reject(action, 'BAD_REQUEST', '이미 그 특기를 예약하였습니다.'); + if (reserved) return reject(action, 'BAD_REQUEST', '이미 예약한 특기가 있습니다.'); + if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.'); + const cost = constants.inheritSpecificSpecialPoint; + const previousPoint = await lockPreviousPoint(db, userId); + if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.'); + const [warModule] = await loadWarTraitModules([input.specialKey], new WarTraitLoader()); + const warName = warModule?.name ?? input.specialKey; + await appendInheritanceLog( + db, + userId, + state.currentYear, + state.currentMonth, + `${cost} 포인트로 다음 전투 특기로 ${warName} 지정` + ); + applyCharge({ + world, + general, + userId, + previousPoint, + cost, + patch: { meta: { ...general.meta, inheritSpecificSpecialWar: input.specialKey } }, + }); + return { + type: 'inheritanceAction', + ok: true, + action, + generalId: general.id, + remainPoint: previousPoint - cost, + }; + } + + if (action === 'resetSpecialWar') { + const currentSpecial = general.role.specialWar; + if (!currentSpecial || currentSpecial === 'None') { + return reject(action, 'BAD_REQUEST', '이미 전투 특기가 공란입니다.'); + } + if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.'); + const currentLevel = readMetaNumber(general.meta, 'inheritResetSpecialWar', -1); + const nextLevel = currentLevel + 1; + const cost = buildResetCost(constants.inheritResetAttrPointBase, nextLevel); + const previousPoint = await lockPreviousPoint(db, userId); + if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.'); + const previousTypes = readStringList(general.meta.prev_types_special2); + previousTypes.push(currentSpecial); + await appendInheritanceLog( + db, + userId, + state.currentYear, + state.currentMonth, + `${cost} 포인트로 전투 특기 초기화` + ); + applyCharge({ + world, + general, + userId, + previousPoint, + cost, + patch: { + role: { ...general.role, specialWar: null }, + meta: { + ...general.meta, + inheritResetSpecialWar: nextLevel, + prev_types_special2: previousTypes, + }, + }, + }); + return { + type: 'inheritanceAction', + ok: true, + action, + generalId: general.id, + remainPoint: previousPoint - cost, + }; + } + + if (action === 'resetTurnTime') { + if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.'); + const currentLevel = readMetaNumber(general.meta, 'inheritResetTurnTime', -1); + const nextLevel = currentLevel + 1; + const cost = buildResetCost(constants.inheritResetAttrPointBase, nextLevel); + const previousPoint = await lockPreviousPoint(db, userId); + if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.'); + const rawSeedTurnTime = general.meta.nextTurnTimeBase ?? general.turnTick ?? 0; + const seedTurnTime = + typeof rawSeedTurnTime === 'string' || typeof rawSeedTurnTime === 'number' + ? rawSeedTurnTime + : typeof rawSeedTurnTime === 'bigint' + ? Number(rawSeedTurnTime) + : 0; + const hiddenSeed = + typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number' + ? worldMeta.hiddenSeed + : 'inherit'; + const timing = resolveResetTurnTimeBase({ + hiddenSeed, + userId, + previousTurnTimeBase: seedTurnTime, + tickSeconds: state.tickSeconds, + }); + await appendInheritanceLog( + db, + userId, + state.currentYear, + state.currentMonth, + `${cost} 포인트로 턴 시간을 바꾸어 다다음 턴부터 ${timing.nextTurnTimeLabel} 적용` + ); + applyCharge({ + world, + general, + userId, + previousPoint, + cost, + patch: { + meta: { + ...general.meta, + inheritResetTurnTime: nextLevel, + nextTurnTimeBase: timing.nextTurnTimeBase, + }, + }, + }); + return { + type: 'inheritanceAction', + ok: true, + action, + generalId: general.id, + remainPoint: previousPoint - cost, + ...timing, + }; + } + + if (action === 'resetStat') { + const statConfig = asRecord(configRecord.stat); + const statTotal = asNumber(statConfig.total, input.leadership + input.strength + input.intel); + const statMin = asNumber(statConfig.min, 1); + const statMax = asNumber(statConfig.max, 999); + if (input.leadership + input.strength + input.intel !== statTotal) { + return reject(action, 'BAD_REQUEST', `능력치 총합이 ${statTotal}이 아닙니다. 다시 입력해주세요!`); + } + if ( + input.leadership < statMin || + input.strength < statMin || + input.intel < statMin || + input.leadership > statMax || + input.strength > statMax || + input.intel > statMax + ) { + return reject(action, 'BAD_REQUEST', '능력치 범위를 벗어났습니다.'); + } + const bonus = input.inheritBonusStat ?? [0, 0, 0]; + const bonusSum = bonus.reduce((sum, value) => sum + value, 0); + if (bonus.some((value) => value < 0)) { + return reject(action, 'BAD_REQUEST', '보너스 능력치가 음수입니다. 다시 입력해주세요!'); + } + if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) { + return reject(action, 'BAD_REQUEST', '보너스 능력치 합이 잘못 지정되었습니다. 다시 입력해주세요!'); + } + if (general.npcState !== 0) return reject(action, 'BAD_REQUEST', 'NPC는 능력치 초기화를 할 수 없습니다.'); + if (!isCentennialStatResetAllowed(config)) { + return reject(action, 'BAD_REQUEST', '100기 올스타 장수는 능력치 초기화를 사용할 수 없습니다.'); + } + if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.'); + const cost = bonusSum > 0 ? constants.inheritBornStatPoint : 0; + const season = resolveSeasonValue(worldMeta); + const userStateRow = + season === null + ? null + : await db.inheritanceUserState.findUnique({ where: { userId }, select: { meta: true } }); + const userState = asRecord(userStateRow?.meta); + const resetSeasons = readResetSeasons(userState); + if (season !== null && resetSeasons.includes(season)) { + return reject(action, 'BAD_REQUEST', '이번 시즌에 이미 능력치를 초기화하셨습니다.'); + } + const previousPoint = await lockPreviousPoint(db, userId); + if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.'); + const statHiddenSeed = + typeof worldMeta.hiddenSeed === 'string' || typeof worldMeta.hiddenSeed === 'number' + ? worldMeta.hiddenSeed + : 'inherit'; + const baseStats = [input.leadership, input.strength, input.intel] as [number, number, number]; + const finalBonus = + bonusSum === 0 + ? buildResetStatRandomBonus( + new RandUtil(new LiteHashDRBG(simpleSerialize(statHiddenSeed, 'ResetStat', userId))), + baseStats + ) + : (bonus as [number, number, number]); + const nextStats = { + leadership: input.leadership + finalBonus[0], + strength: input.strength + finalBonus[1], + intel: input.intel + finalBonus[2], + }; + await appendInheritanceLog( + db, + userId, + state.currentYear, + state.currentMonth, + `통솔 ${input.leadership}, 무력 ${input.strength}, 지력 ${input.intel} 스탯 재설정` + ); + await appendInheritanceLog( + db, + userId, + state.currentYear, + state.currentMonth, + bonusSum > 0 + ? `${cost}로 통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용` + : `통솔 ${finalBonus[0]}, 무력 ${finalBonus[1]}, 지력 ${finalBonus[2]} 보너스 능력치 적용` + ); + if (season !== null) { + await db.inheritanceUserState.upsert({ + where: { userId }, + update: { meta: asJson({ ...userState, last_stat_reset: [...resetSeasons, season] }) }, + create: { userId, meta: asJson({ ...userState, last_stat_reset: [...resetSeasons, season] }) }, + }); + } + applyCharge({ + world, + general, + userId, + previousPoint, + cost, + patch: { + stats: { + leadership: nextStats.leadership, + strength: nextStats.strength, + intelligence: nextStats.intel, + }, + }, + }); + return { + type: 'inheritanceAction', + ok: true, + action, + generalId: general.id, + remainPoint: previousPoint - cost, + stats: nextStats, + }; + } + + if (general.meta.inheritRandomUnique !== undefined && general.meta.inheritRandomUnique !== null) { + return reject(action, 'BAD_REQUEST', '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.'); + } + if (isUnited(world)) return reject(action, 'FORBIDDEN', '이미 천하가 통일되었습니다.'); + const previousPoint = await lockPreviousPoint(db, userId); + const cost = constants.inheritItemRandomPoint; + if (previousPoint < cost) return reject(action, 'BAD_REQUEST', '충분한 유산 포인트를 가지고 있지 않습니다.'); + await appendInheritanceLog(db, userId, state.currentYear, state.currentMonth, `${cost} 포인트로 랜덤 유니크 구입`); + applyCharge({ + world, + general, + userId, + previousPoint, + cost, + patch: { meta: { ...general.meta, inheritRandomUnique: 1 } }, + }); + return { type: 'inheritanceAction', ok: true, action, generalId: general.id, remainPoint: previousPoint - cost }; +}; diff --git a/app/game-engine/src/turn/inheritanceSettlementLogs.ts b/app/game-engine/src/turn/inheritanceSettlementLogs.ts new file mode 100644 index 00000000..c84e3719 --- /dev/null +++ b/app/game-engine/src/turn/inheritanceSettlementLogs.ts @@ -0,0 +1,63 @@ +import { + REBIRTH_INHERITANCE_COEFFICIENTS, + type MergedInheritanceKey, +} from '@sammo-ts/logic/inheritance/pointCalculation.js'; + +const LEGACY_KEY_ORDER = [ + 'lived_month', + 'max_belong', + 'max_domestic_critical', + 'active_action', + 'combat', + 'sabotage', + 'unifier', + 'dex', + 'tournament', + 'betting', +] as const satisfies readonly MergedInheritanceKey[]; + +const LEGACY_CALCULATED_KEYS = new Set(['max_belong', 'combat', 'sabotage', 'dex', 'betting']); + +const LEGACY_KEY_LABEL: Readonly> = { + previous: '기존 보유', + lived_month: '생존', + max_belong: '최대 임관년 수', + max_domestic_critical: '최대 연속 내정 성공', + active_action: '능동 행동 수', + combat: '전투 횟수', + sabotage: '계략 성공 횟수', + unifier: '천통 기여', + dex: '숙련도', + tournament: '토너먼트', + betting: '베팅 당첨', +}; + +const formatLegacyPoint = (value: number): string => { + if (!Number.isFinite(value)) { + return '0'; + } + return String(Object.is(value, -0) ? 0 : value); +}; + +export const buildInheritanceSettlementLogTexts = (input: { + previous: number; + points: Readonly>>; + storedKeys: ReadonlySet; + total: number; + isRebirth: boolean; +}): string[] => { + const texts = input.storedKeys.has('previous') + ? [`${LEGACY_KEY_LABEL.previous} 포인트 ${formatLegacyPoint(input.previous)} 증가`] + : []; + for (const key of LEGACY_KEY_ORDER) { + if (!LEGACY_CALCULATED_KEYS.has(key) && !input.storedKeys.has(key)) { + continue; + } + if (input.isRebirth && REBIRTH_INHERITANCE_COEFFICIENTS[key] === null) { + continue; + } + texts.push(`${LEGACY_KEY_LABEL[key]} 포인트 ${formatLegacyPoint(input.points[key] ?? 0)} 증가`); + } + texts.push(`포인트 ${formatLegacyPoint(input.previous)} => ${formatLegacyPoint(input.total)}`); + return texts; +}; diff --git a/app/game-engine/src/turn/monthlyNationLevelAction.ts b/app/game-engine/src/turn/monthlyNationLevelAction.ts index a1a9df76..20bde747 100644 --- a/app/game-engine/src/turn/monthlyNationLevelAction.ts +++ b/app/game-engine/src/turn/monthlyNationLevelAction.ts @@ -377,7 +377,11 @@ export const createUpdateNationLevelHandler = (options: { const isUnited = readNumber(state.meta.isunited ?? state.meta.isUnited); if (chief?.userId && chief.npcState < 2 && isUnited === 0) { const amount = 250 * levelDiff; - world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount); + // General turns (including rebirth settlement) finish before + // monthly actions in the processor. Persist this award after + // lifecycle so the retirement result cannot claim a later + // promotion as pre-rebirth retained state. + world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount, 'after_lifecycle'); world.updateGeneral(chief.id, { inheritancePoints: { ...chief.inheritancePoints, diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index e6647b04..0927be04 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -32,7 +32,7 @@ import { loadItemModules, resolveUniqueConfig, readScenarioGeneralPoolClaim, - rollUniqueLottery, + rollUniqueLotteryDetailed, getNextTurnAt, getBillByLevel, LEGACY_DEFAULT_MAX_LEVEL, @@ -479,6 +479,8 @@ const buildUniqueLotteryRunner = (options: { seedBase: string; itemRegistry: Map; uniqueConfig: ReturnType; + inheritItemRandomPoint: number; + inheritanceWorld?: InMemoryTurnWorld | null; getAdditionalOccupiedUniqueItemKeys?: () => Iterable; }): UniqueLotteryRunner => { if (!options.worldView) { @@ -523,7 +525,7 @@ const buildUniqueLotteryRunner = (options: { const relMonthByInit = joinYearMonth(world.currentYear, world.currentMonth) - joinYearMonth(initYear, initMonth); const availableBuyUnique = relMonthByInit >= minMonthToAllowInherit; - const itemKey = rollUniqueLottery({ + const outcome = rollUniqueLotteryDetailed({ rng, config: options.uniqueConfig, itemRegistry: options.itemRegistry, @@ -539,13 +541,54 @@ const buildUniqueLotteryRunner = (options: { acquireType, inheritRandomUnique, }); - if (!itemKey) { + if (outcome.status === 'NO_SLOT' || outcome.status === 'NO_SUPPLY') { + if (inheritRandomUnique) { + const turnGeneral = general as TurnGeneral; + const cost = options.inheritItemRandomPoint; + const nextMeta = { + ...turnGeneral.meta, + // Explicit retirement resets every rank before this lottery in Ref, + // so a failed pending purchase leaves the post-rebirth delta at -cost. + inherit_spent_dyn: + reason === '은퇴' + ? -cost + : readMetaNumber(asRecord(turnGeneral.meta), 'inherit_spent_dyn', 0) - cost, + } as TurnGeneral['meta']; + delete nextMeta.inheritRandomUnique; + turnGeneral.meta = nextMeta; + turnGeneral.inheritancePoints = { + ...turnGeneral.inheritancePoints, + previous: readInheritanceNumber(turnGeneral.inheritancePoints?.previous) + cost, + }; + if (turnGeneral.userId) { + const persistencePhase = reason === '은퇴' ? 'after_lifecycle' : undefined; + options.inheritanceWorld?.queueInheritancePointAdjustment( + turnGeneral.userId, + 'previous', + cost, + persistencePhase + ); + options.inheritanceWorld?.queueInheritanceLog({ + userId: turnGeneral.userId, + year: world.currentYear, + month: world.currentMonth, + text: + outcome.status === 'NO_SLOT' + ? `유니크를 얻을 공간이 없어 ${cost} 포인트 반환` + : `얻을 유니크가 없어 ${cost} 포인트 반환`, + ...(persistencePhase ? { phase: persistencePhase } : {}), + }); + } + } + return null; + } + if (outcome.status === 'ROLL_FAILED') { return null; } if (inheritRandomUnique && availableBuyUnique) { delete asRecord(general.meta).inheritRandomUnique; } - return options.itemRegistry.get(itemKey) ?? null; + return options.itemRegistry.get(outcome.itemKey) ?? null; }; }; @@ -885,6 +928,11 @@ export const createReservedTurnHandler = async (options: { const env = options.commandEnv ?? buildCommandEnv(options.scenarioConfig, options.unitSet); const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])); const uniqueConfig = resolveUniqueConfig(asRecord(options.scenarioConfig.const)); + const inheritItemRandomPoint = readMetaNumber( + asRecord(options.scenarioConfig.const), + 'inheritItemRandomPoint', + 3_000 + ); if (Object.keys(uniqueConfig.allItems).length === 0) { uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); } @@ -1133,6 +1181,8 @@ export const createReservedTurnHandler = async (options: { seedBase, itemRegistry, uniqueConfig, + inheritItemRandomPoint, + inheritanceWorld: worldRef, getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys, }); let actionRng = sharedActionRng ?? buildRng(actionKey); @@ -2086,6 +2136,10 @@ export const createReservedTurnHandler = async (options: { } generalAiState = ai.getDebugState(); } + // che_은퇴 performs the rebirth inside the action, as Ref does. Preserve + // the fully accumulated pre-command state so lifecycle persistence can + // settle Hall/inheritance before observing that reset. + const explicitRetirementSnapshot = cloneTurnGeneral(currentGeneral); const generalActionStartedAt = options.onActionProfiled ? process.hrtime.bigint() : 0n; const generalResult = isBlocked ? { @@ -2176,7 +2230,10 @@ export const createReservedTurnHandler = async (options: { delete currentGeneral.meta.nextTurnTimeBase; } - let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = 'active'; + const explicitlyRetired = generalResult.actionKey === 'che_은퇴' && generalResult.completed; + let lifecycleOutcome: 'active' | 'detached' | 'deleted' | 'retired' = explicitlyRetired + ? 'retired' + : 'active'; let deleteGeneral = false; const deletedTroopIds = Array.from(commandDeletedTroopIds); const lifecycleSnapshot = cloneTurnGeneral(currentGeneral); @@ -2353,8 +2410,18 @@ export const createReservedTurnHandler = async (options: { lifecycleEvent: { generalId: currentGeneral.id, outcome: lifecycleOutcome, - before: lifecycleOutcome === 'active' ? lifecycleBefore : lifecycleSnapshot, + before: + lifecycleOutcome === 'active' + ? lifecycleBefore + : explicitlyRetired + ? explicitRetirementSnapshot + : lifecycleSnapshot, ...(deleteGeneral ? {} : { after: currentGeneral }), + isUnitedAtEvent: readMetaNumber( + asRecord(context.world.meta), + 'isunited', + readMetaNumber(asRecord(context.world.meta), 'isUnited', 0) + ), year: context.world.currentYear, month: context.world.currentMonth, }, @@ -2418,6 +2485,11 @@ export const createImmediateGeneralActionExecutor = async (options: { const itemRegistry = createItemModuleRegistry(await loadItemModules([...ITEM_KEYS])); const uniqueConfig = resolveUniqueConfig(asRecord(options.world.getScenarioConfig().const)); + const inheritItemRandomPoint = readMetaNumber( + asRecord(options.world.getScenarioConfig().const), + 'inheritItemRandomPoint', + 3_000 + ); if (Object.keys(uniqueConfig.allItems).length === 0) { uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); } @@ -2488,6 +2560,8 @@ export const createImmediateGeneralActionExecutor = async (options: { seedBase, itemRegistry, uniqueConfig, + inheritItemRandomPoint, + inheritanceWorld: options.world, getAdditionalOccupiedUniqueItemKeys: () => additionalOccupiedUniqueItemKeys, }); const startYear = resolveStartYear(state, options.scenarioMeta); diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index 010e3c92..96ebb9c9 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -2,11 +2,17 @@ import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameTy import { acquireGameSchemaAdvisoryXactLock, enqueuePrivateMessageWebPush } from '@sammo-ts/infra'; import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic'; +import { + readCentennialRecordableDexterity, + type CentennialDexKey, +} from '@sammo-ts/logic/scenario/centennialAllStar.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js'; import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js'; +import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js'; import { buildOldNationArchiveData } from './oldNationArchive.js'; -import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js'; +import type { PendingUnificationAuctionCancellation } from './types.js'; const UNIFIER_POINT = 2000; const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; @@ -49,14 +55,13 @@ const ownerDisplayName = (meta: Record): string | null => { export const resolveStoredInheritancePoint = ( currentPoints: ReadonlyMap, - general: Pick, - key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number], - unifierAward: number -): number => - currentPoints.get(key) ?? - (key === 'unifier' - ? Math.max(0, (general.inheritancePoints?.[key] ?? 0) - unifierAward) - : (general.inheritancePoints?.[key] ?? 0)); + key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number] +): number => { + // All turn/month/auction mutations are persisted before finalization. A + // missing row therefore means zero; the general snapshot can still contain + // a rebirth-paid bucket that the lifecycle transaction deliberately deleted. + return currentPoints.get(key) ?? 0; +}; const formatHistogram = (value: unknown): string => Object.entries(asRecord(value)) @@ -329,7 +334,7 @@ export const persistUnificationFinalization = async ( const unifierAward = general.nationId === input.winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0; const mergedPoints = Object.fromEntries( ALL_MERGED_INHERITANCE_KEYS.map((key) => { - const stored = resolveStoredInheritancePoint(currentPoints, general, key, unifierAward); + const stored = resolveStoredInheritancePoint(currentPoints, key); const effectiveStored = key === 'unifier' ? stored + unifierAward : stored; return [key, computeActiveInheritancePoint(general, key, effectiveStored)]; }) @@ -359,15 +364,23 @@ export const persistUnificationFinalization = async ( }, }, }); - await transaction.inheritanceLog.create({ - data: { - userId, - serverId, - year: input.year, - month: input.month, - text: `천하 통일 정산: ${total.toLocaleString('ko-KR')} 포인트`, - }, - }); + for (const text of buildInheritanceSettlementLogTexts({ + previous, + points: mergedPoints, + storedKeys: new Set([...currentPoints.keys(), ...(unifierAward > 0 ? (['unifier'] as const) : [])]), + total, + isRebirth: false, + })) { + await transaction.inheritanceLog.create({ + data: { + userId, + serverId, + year: input.year, + month: input.month, + text, + }, + }); + } } const rankRows = generals.length @@ -388,7 +401,7 @@ export const persistUnificationFinalization = async ( const scenarioName = String(asRecord(meta.scenarioMeta).title ?? ''); const startTime = typeof meta.starttime === 'string' ? meta.starttime : null; const unitedTime = input.completedAt.toISOString(); - const serverCount = await transaction.gameHistory.count(); + const serverIdx = await resolveOfficialGameIndex(transaction, meta); const minHallAge = readInteger(asRecord(world.getScenarioConfig().const).minPushHallAge, 30); const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => { @@ -429,7 +442,7 @@ export const persistUnificationFinalization = async ( unitedTime, ownerDisplayName: ownerDisplayName(generalMeta), serverID: serverId, - serverIdx: serverCount, + serverIdx, serverName, scenarioName, generationKey: input.generationKey, @@ -445,7 +458,7 @@ export const persistUnificationFinalization = async ( ? general.experience : type === 'dedication' ? general.dedication - : readNumber(generalMeta[type]); + : readCentennialRecordableDexterity(generalMeta, type as CentennialDexKey); if ((type === 'winrate' || type === 'killrate') && (ranks.warnum ?? 0) < 10) continue; if (type === 'ttrate' && totals.tt < 50) continue; if (type === 'tlrate' && totals.tl < 50) continue; @@ -454,30 +467,16 @@ export const persistUnificationFinalization = async ( if (type === 'betrate' && (ranks.betgold ?? 0) < 1000) continue; if (value <= 0) continue; - const existing = await transaction.hallOfFame.findFirst({ - where: { - OR: [ - { serverId, type, generalNo: general.id }, - { serverId, type, owner: general.userId }, - ], - }, + await persistHallOfFameCandidate(transaction, { + serverId, + season, + scenario, + generalNo: general.id, + type, + value, + owner: general.userId ?? null, + aux, }); - if (!existing) { - await transaction.hallOfFame.create({ - data: { - serverId, - season, - scenario, - generalNo: general.id, - type, - value, - owner: general.userId ?? null, - aux, - }, - }); - } else if (value > existing.value) { - await transaction.hallOfFame.update({ where: { id: existing.id }, data: { value, aux } }); - } } } @@ -631,7 +630,7 @@ export const persistUnificationFinalization = async ( await transaction.emperor.create({ data: { serverId, - phase: `${serverName}${serverCount}기`, + phase: `${serverName}${serverIdx}기`, nationCount, nationName: statisticNationNames || archivedNationNames.join(', '), nationHist: formatHistogram(statistics.maxNationHist), diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 53ad3e1a..e1e69a84 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -62,6 +62,7 @@ import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGener import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js'; import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js'; import { respondToActionableMessage } from './actionableMessageResponse.js'; +import { executeInheritanceAction } from './inheritanceActionService.js'; let itemRegistryPromise: Promise> | null = null; @@ -162,7 +163,8 @@ const resolveCommandAcceptedAt = async ( | 'selectPoolReserve' | 'selectPoolCreate' | 'selectPoolReselect' - | 'adjustGeneralIcon'; + | 'adjustGeneralIcon' + | 'inheritanceAction'; } > ): Promise => { @@ -802,6 +804,20 @@ async function handlePatchGeneral( return { type: 'patchGeneral', ok: true, generalId: command.generalId }; } +async function handleInheritanceAction( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const db = requireCommandDatabase(ctx) as unknown as GamePrisma.TransactionClient; + const acceptedAt = await resolveCommandAcceptedAt(db as unknown as DatabaseClient, command); + return executeInheritanceAction({ + db, + world: ctx.world, + command, + gameNow: ctx.world.getGameNow(acceptedAt), + }); +} + async function handleAdjustGeneralIcon( ctx: CommandHandlerContext, command: Extract @@ -2936,6 +2952,8 @@ export const createTurnDaemonCommandHandler = (options: { handleTournamentMatchResult(ctx, command as Extract), patchGeneral: (command) => handlePatchGeneral(ctx, command as Extract), + inheritanceAction: (command) => + handleInheritanceAction(ctx, command as Extract), adjustGeneralIcon: (command) => handleAdjustGeneralIcon(ctx, command as Extract), joinCreateGeneral: (command) => diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts index dd14e599..71397b7d 100644 --- a/app/game-engine/test/generalTurnLifecycle.test.ts +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -399,4 +399,153 @@ describe('legacy general turn lifecycle', () => { } expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired'); }); + + it('emits an explicit retirement lifecycle event with the pre-rebirth snapshot', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([ + makeGeneral({ + age: 65, + experience: 1_001, + dedication: 801, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { + horse: 'che_명마_07_백마', + weapon: 'che_무기_07_동추', + book: 'che_서적_07_위료자', + item: 'che_의술_정력견혈산', + }, + }, + meta: { + killturn: 24, + rank_warnum: 11, + firenum: 9, + inherit_earned: 4_321, + inherit_lived_month: 10, + inherit_active_action: 4, + inheritRandomUnique: 1, + inherit_spent_dyn: 3_000, + dex1: 101, + }, + inheritancePoints: { previous: 50 }, + }), + ]), + state: makeState(), + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_은퇴', args: {} }; + harness.reservedTurnStore.getGeneralTurns(1)[1] = { action: 'che_은퇴', args: {} }; + + await harness.runOneTick(); + await harness.runOneTick(); + + const current = harness.world.getGeneralById(1)!; + const lifecycle = harness.world.peekDirtyState().lifecycleEvents.find((event) => event.outcome === 'retired'); + expect(lifecycle).toMatchObject({ + outcome: 'retired', + isUnitedAtEvent: 0, + before: { + age: 65, + experience: 1_001, + dedication: 801, + meta: { + rank_warnum: 11, + firenum: 9, + inherit_earned: 4_321, + inherit_lived_month: 12, + inherit_active_action: 4, + inheritRandomUnique: 1, + inherit_spent_dyn: 3_000, + dex1: 101, + }, + }, + after: { + age: 20, + meta: { inherit_lived_month: 0, inherit_active_action: 0 }, + }, + }); + expect(current).toMatchObject({ + age: 20, + experience: 501, + dedication: 401, + inheritancePoints: { previous: 3_050 }, + meta: { + rank_warnum: 0, + firenum: 0, + inherit_earned: 0, + inherit_lived_month: 0, + inherit_active_action: 0, + inherit_spent_dyn: -3_000, + dex1: 51, + }, + }); + expect(current.meta).not.toHaveProperty('inheritRandomUnique'); + expect(harness.world.peekDirtyState().inheritancePointAdjustments).toContainEqual({ + userId: 'user-1', + key: 'previous', + amount: 3_000, + phase: 'after_lifecycle', + }); + expect(harness.world.peekDirtyState().pendingInheritanceLogs).toContainEqual({ + userId: 'user-1', + year: 200, + month: 2, + text: '유니크를 얻을 공간이 없어 3000 포인트 반환', + phase: 'after_lifecycle', + }); + }); + + it('refunds a failed pending lottery before automatic retirement and resets the spent rank to zero', async () => { + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot([ + makeGeneral({ + age: 80, + crew: 100, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { + horse: 'che_명마_07_백마', + weapon: 'che_무기_07_동추', + book: 'che_서적_07_위료자', + item: 'che_의술_정력견혈산', + }, + }, + meta: { + killturn: 24, + inheritRandomUnique: true, + inherit_spent_dyn: 3_000, + inherit_lived_month: 10, + }, + inheritancePoints: { previous: 70 }, + }), + ]), + state: makeState(), + schedule, + map, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_훈련', args: {} }; + + await harness.runOneTick(); + + const current = harness.world.getGeneralById(1)!; + expect(current).toMatchObject({ + age: 20, + inheritancePoints: { previous: 3_070 }, + meta: { inherit_spent_dyn: 0, inherit_lived_month: 0 }, + }); + expect(current.meta).not.toHaveProperty('inheritRandomUnique'); + expect(harness.world.peekDirtyState().inheritancePointAdjustments).toContainEqual({ + userId: 'user-1', + key: 'previous', + amount: 3_000, + }); + const lifecycle = harness.world.peekDirtyState().lifecycleEvents.find((event) => event.outcome === 'retired'); + expect(lifecycle?.before.meta).toMatchObject({ inherit_spent_dyn: 0 }); + expect(lifecycle?.before.meta).not.toHaveProperty('inheritRandomUnique'); + }); }); diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts index 26e3fe01..8be37696 100644 --- a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts +++ b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts @@ -1,17 +1,41 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { asRecord, normalizeArchivedGeneral, type ArchivedJsonValue } from '@sammo-ts/common'; -import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; +import { asRecord, normalizeArchivedGeneral, RANK_DATA_TYPES, type ArchivedJsonValue } from '@sammo-ts/common'; +import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { LogCategory, LogScope } from '@sammo-ts/logic'; +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; import type { GeneralLifecycleEvent } from '../src/turn/inMemoryWorld.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { InMemoryTurnProcessor } from '../src/turn/inMemoryTurnProcessor.js'; import { persistGeneralLifecycleEvents } from '../src/turn/generalTurnLifecyclePersistence.js'; -import type { TurnGeneral } from '../src/turn/types.js'; +import { createReservedTurnHandler } from '../src/turn/reservedTurnHandler.js'; +import { InMemoryReservedTurnStore } from '../src/turn/reservedTurnStore.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; const databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); -const generalIds = [990_001, 990_002, 990_003]; -const userIds = ['integration-lifecycle-dead', 'integration-lifecycle-retired', 'integration-lifecycle-possessed']; +const generalIds = [990_001, 990_002, 990_003, 990_004, 990_005, 990_006, 990_007]; +const userIds = [ + 'integration-lifecycle-dead', + 'integration-lifecycle-retired', + 'integration-lifecycle-possessed', + 'integration-lifecycle-explicit-retired', + 'integration-lifecycle-automatic-retired', + 'integration-lifecycle-death-archive', + 'integration-lifecycle-retire-before-unification', +]; const serverId = 'lifecycle-int'; +const sameFlushServerId = `${serverId}-retire-before-unification`; +const worldId = 990_004; +const deathArchiveWorldId = 990_006; +const sameFlushWorldId = 990_007; +const nationId = 990_004; +const cityId = 990_004; +const sameFlushNationId = 990_007; +const sameFlushCityId = 990_007; +const archiveServerIds = [serverId, sameFlushServerId]; +const historyServerIds = [serverId, `${serverId}-completed`, `${serverId}-abandoned`, sameFlushServerId]; const makeGeneral = (id: number, userId: string, patch: Partial = {}): TurnGeneral => ({ id, @@ -69,12 +93,30 @@ integration('general turn lifecycle persistence', () => { const cleanup = async () => { await db.logEntry.deleteMany({ where: { generalId: { in: generalIds } } }); await db.generalAccessLog.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.generalTurnRevision.deleteMany({ where: { generalId: { in: generalIds } } }); + await db.generalTurn.deleteMany({ where: { generalId: { in: generalIds } } }); await db.rankData.deleteMany({ where: { generalId: { in: generalIds } } }); - await db.oldGeneral.deleteMany({ where: { serverId, generalNo: { in: generalIds } } }); - await db.hallOfFame.deleteMany({ where: { serverId, generalNo: { in: generalIds } } }); - await db.inheritanceResult.deleteMany({ where: { serverId, owner: { in: userIds } } }); + await db.unificationFinalization.deleteMany({ where: { serverId: sameFlushServerId } }); + await db.emperor.deleteMany({ where: { serverId: sameFlushServerId } }); + await db.oldNation.deleteMany({ where: { serverId: sameFlushServerId } }); + await db.oldGeneral.deleteMany({ + where: { serverId: { in: archiveServerIds }, generalNo: { in: generalIds } }, + }); + await db.hallOfFame.deleteMany({ + where: { serverId: { in: archiveServerIds }, generalNo: { in: generalIds } }, + }); + await db.inheritanceResult.deleteMany({ + where: { serverId: { in: archiveServerIds }, owner: { in: userIds } }, + }); await db.inheritanceLog.deleteMany({ where: { userId: { in: userIds } } }); await db.inheritancePoint.deleteMany({ where: { userId: { in: userIds } } }); + await db.general.deleteMany({ where: { id: { in: generalIds } } }); + await db.city.deleteMany({ where: { id: { in: [cityId, sameFlushCityId] } } }); + await db.nation.deleteMany({ where: { id: { in: [nationId, sameFlushNationId] } } }); + await db.worldState.deleteMany({ + where: { id: { in: [worldId, deathArchiveWorldId, sameFlushWorldId] } }, + }); + await db.gameHistory.deleteMany({ where: { serverId: { in: historyServerIds } } }); }; beforeAll(async () => { @@ -189,8 +231,7 @@ integration('general turn lifecycle persistence', () => { }); const archivedData = asRecord(archived.data); expect(archivedData.history).toEqual(['●둘째 기록', '●첫 기록']); - expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique'); - expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritSpecificSpecialWar'); + expect(asRecord(archivedData.meta)).toMatchObject({ inheritRandomUnique: true }); const snapshot = normalizeArchivedGeneral(archived.data as ArchivedJsonValue, archived.name).snapshot; expect(snapshot).toMatchObject({ mastery: { infantry: 1_000, archery: 1, cavalry: 1, special: 1, siege: 1 }, @@ -220,7 +261,19 @@ integration('general turn lifecycle persistence', () => { select: { text: true }, }) ).map(({ text }) => text) - ).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,622 포인트']); + ).toEqual([ + '사망으로 랜덤 유니크 구입 3000 포인트 반환', + '기존 보유 포인트 3100 증가', + '최대 임관년 수 포인트 90 증가', + '최대 연속 내정 성공 포인트 80 증가', + '전투 횟수 포인트 10 증가', + '계략 성공 횟수 포인트 20 증가', + '천통 기여 포인트 250 증가', + '숙련도 포인트 1.004 증가', + '토너먼트 포인트 50 증가', + '베팅 당첨 포인트 5 증가', + '포인트 3100 => 3622', + ]); }); it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => { @@ -255,10 +308,15 @@ integration('general turn lifecycle persistence', () => { data: { generalId: general.id, nationId: 0, type: 'inherit_earned', value: 4_321 }, }); + const retirementEvent = event(general, 'retired'); + retirementEvent.after = { + ...general, + meta: { ...general.meta, rank_warnum: 0, inherit_earned: 0 }, + }; await db.$transaction((tx) => persistGeneralLifecycleEvents( tx, - [event(general, 'retired')], + [retirementEvent], { serverId, season: 1, scenarioId: 2, isUnited: 0 }, {} ) @@ -311,6 +369,533 @@ integration('general turn lifecycle persistence', () => { ]); }); + it('archives the death turn history and battle brief through execute and database flush', async () => { + const general = makeGeneral(generalIds[5]!, userIds[5]!, { + npcState: 2, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + }); + const scenarioConfig = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: { killturn: 0 }, + environment: { mapName: 'che', unitSet: 'che' }, + }; + const scenarioMeta = { + title: '사망 기록 archive integration', + startYear: 200, + life: null, + fiction: 0, + history: [], + ignoreDefaultEvents: false, + }; + const worldMeta = { serverId, killturn: 0, scenarioMeta }; + await db.worldState.create({ + data: { + id: deathArchiveWorldId, + scenarioCode: 'death-archive-integration', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: scenarioConfig as GamePrisma.InputJsonValue, + meta: worldMeta as GamePrisma.InputJsonValue, + }, + }); + await db.general.create({ + data: { + id: general.id, + userId: general.userId, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + npcState: general.npcState, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + experience: general.experience, + dedication: general.dedication, + officerLevel: general.officerLevel, + injury: general.injury, + gold: general.gold, + rice: general.rice, + crew: general.crew, + crewTypeId: general.crewTypeId, + train: general.train, + atmos: general.atmos, + turnTime: general.turnTime, + age: general.age, + bornYear: general.bornYear, + deadYear: general.deadYear, + meta: general.meta as GamePrisma.InputJsonValue, + }, + }); + await db.logEntry.createMany({ + data: [ + { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + year: 199, + month: 12, + generalId: general.id, + text: '이전 열전', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_BRIEF, + year: 199, + month: 12, + generalId: general.id, + text: '이전 전투 결과', + }, + ], + }); + const state: TurnWorldState = { + id: deathArchiveWorldId, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: general.turnTime, + meta: worldMeta, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig, + scenarioMeta, + map: { + id: 'death-archive', + name: '사망 기록 archive', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + generals: [general], + nations: [], + cities: [], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + const world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler: { + execute: ({ general: currentGeneral, world: currentWorld }) => ({ + deleted: { general: true }, + lifecycleEvent: { + generalId: currentGeneral.id, + outcome: 'deleted', + before: currentGeneral, + year: currentWorld.currentYear, + month: currentWorld.currentMonth, + }, + logs: [ + { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + generalId: currentGeneral.id, + text: '마지막 열전 첫째', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_BRIEF, + generalId: currentGeneral.id, + text: '마지막 전투 결과 첫째', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + generalId: currentGeneral.id, + text: '마지막 열전 둘째', + }, + { + scope: LogScope.GENERAL, + category: LogCategory.BATTLE_BRIEF, + generalId: currentGeneral.id, + text: '마지막 전투 결과 둘째', + }, + ], + }), + }, + }); + + world.executeGeneralTurn(world.getGeneralById(general.id)!); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + await hooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 1, + processedTurns: 1, + durationMs: 0, + partial: false, + }); + } finally { + await hooks.close(); + } + + const archived = await db.oldGeneral.findUniqueOrThrow({ + where: { by_no: { serverId, generalNo: general.id } }, + }); + const archivedData = asRecord(archived.data); + expect(archivedData.history).toEqual(['마지막 열전 둘째', '마지막 열전 첫째', '이전 열전']); + expect(asRecord(archivedData.records).battleResult).toEqual([ + '마지막 전투 결과 둘째', + '마지막 전투 결과 첫째', + '이전 전투 결과', + ]); + await expect( + db.logEntry.count({ + where: { + generalId: general.id, + category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] }, + }, + }) + ).resolves.toBe(6); + }); + + it('settles a pre-month retirement before same-flush unification without losing Hall or repaying stored points', async () => { + const turnTime = new Date('0200-01-01T00:05:00.000Z'); + const monthBoundary = new Date('0200-01-01T00:10:00.000Z'); + const general = makeGeneral(generalIds[6]!, userIds[6]!, { + nationId: sameFlushNationId, + cityId: sameFlushCityId, + age: 80, + officerLevel: 1, + turnTime, + meta: { + killturn: 24, + owner_name: '월경계 은퇴 사용자', + rank_warnum: 11, + firenum: 2, + inherit_lived_month: 10, + inherit_active_action: 4, + dex1: 200, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + event100_allstar: { granted: { dex1: 80 } }, + }, + inheritancePoints: { + previous: 100, + lived_month: 10, + active_action: 4, + tournament: 11, + }, + }); + const scenarioConfig = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: { + retirementYear: 80, + minPushHallAge: 30, + incDefSettingChange: 3, + maxDefSettingChange: 9, + }, + environment: { mapName: 'che', unitSet: 'che' }, + }; + const scenarioMeta = { + title: '월경계 은퇴 후 통일 integration', + startYear: 200, + life: null, + fiction: 0, + history: [], + ignoreDefaultEvents: false, + }; + const worldMeta = { + serverId: sameFlushServerId, + serverName: '월경계 서버', + season: 9, + scenarioId: 77, + gameIdx: 12, + isUnited: 0, + isunited: 0, + killturn: 24, + scenarioMeta, + }; + const nation = { + id: sameFlushNationId, + name: '월경계국', + color: '#224466', + capitalCityId: sameFlushCityId, + chiefGeneralId: general.id, + gold: 10_000, + rice: 10_000, + power: 1_000, + level: 1, + typeCode: 'che_중립', + meta: { gennum: 1, tech: 0 }, + }; + const city = { + id: sameFlushCityId, + name: '월경계성', + nationId: sameFlushNationId, + level: 5, + state: 0, + population: 10_000, + populationMax: 20_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + meta: { trust: 50, trade: 100, region: 1 }, + }; + const map = { + id: 'retire-before-unification', + name: '월경계 은퇴 후 통일', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }; + await db.worldState.create({ + data: { + id: sameFlushWorldId, + scenarioCode: 'retire-before-unification-integration', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: scenarioConfig as GamePrisma.InputJsonValue, + meta: worldMeta as GamePrisma.InputJsonValue, + }, + }); + await db.nation.create({ + data: { + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId, + chiefGeneralId: nation.chiefGeneralId, + gold: nation.gold, + rice: nation.rice, + tech: 0, + level: nation.level, + typeCode: nation.typeCode, + meta: nation.meta, + }, + }); + await db.city.create({ + data: { + id: city.id, + name: city.name, + nationId: city.nationId, + level: city.level, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + supplyState: city.supplyState, + frontState: city.frontState, + region: 1, + meta: city.meta, + }, + }); + await db.general.create({ + data: { + id: general.id, + userId: general.userId, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + npcState: general.npcState, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + experience: general.experience, + dedication: general.dedication, + officerLevel: general.officerLevel, + injury: general.injury, + gold: general.gold, + rice: general.rice, + crew: general.crew, + crewTypeId: general.crewTypeId, + train: general.train, + atmos: general.atmos, + turnTime: general.turnTime, + age: general.age, + bornYear: general.bornYear, + deadYear: general.deadYear, + meta: general.meta as GamePrisma.InputJsonValue, + }, + }); + await db.rankData.createMany({ + data: RANK_DATA_TYPES.map((type) => ({ + generalId: general.id, + nationId: general.nationId, + type, + value: type === 'warnum' ? 11 : type === 'firenum' ? 2 : 0, + })), + }); + await db.inheritancePoint.createMany({ + data: [ + { userId: general.userId!, key: 'previous', value: 100 }, + { userId: general.userId!, key: 'lived_month', value: 10 }, + { userId: general.userId!, key: 'active_action', value: 4 }, + { userId: general.userId!, key: 'tournament', value: 11 }, + ], + }); + await db.gameHistory.create({ + data: { + serverId: sameFlushServerId, + date: new Date('0200-01-01T00:00:00.000Z'), + season: 9, + scenario: 77, + scenarioName: scenarioMeta.title, + status: 'OPEN', + }, + }); + + const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 3, maxNationTurns: 3 }); + await reservedTurns.loadAll(); + reservedTurns.setGeneralTurn(general.id, 0, { action: '휴식', args: {} }); + const state: TurnWorldState = { + id: sameFlushWorldId, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: worldMeta, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig, + scenarioMeta, + map, + generals: [general], + nations: [nation], + cities: [city], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + let world: InMemoryTurnWorld | null = null; + const handler = await createReservedTurnHandler({ + reservedTurns, + scenarioConfig, + scenarioMeta, + map, + getWorld: () => world, + }); + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler: handler, + calendarHandler: { + onMonthChanged: (context) => { + if (!world) throw new Error('world is unavailable'); + const reborn = world.getGeneralById(general.id); + if (!reborn?.userId) throw new Error('reborn general is unavailable'); + world.queueInheritancePointAdjustment(reborn.userId, 'unifier', 250, 'after_lifecycle'); + world.updateGeneral(reborn.id, { + inheritancePoints: { + ...reborn.inheritancePoints, + unifier: (reborn.inheritancePoints?.unifier ?? 0) + 250, + }, + }); + world.updateWorldMeta({ isUnited: 2, isunited: 2 }); + world.queueUnificationFinalization({ + generationKey: `unification:${sameFlushServerId}`, + serverId: sameFlushServerId, + profileName: 'che', + winnerNationId: sameFlushNationId, + year: context.currentYear, + month: context.currentMonth, + completedAt: new Date(context.turnTime.getTime()), + auctionCancellations: [], + }); + }, + }, + }); + world.advanceGameClockTo(monthBoundary, monthBoundary); + const processor = new InMemoryTurnProcessor(world); + const result = await processor.run(monthBoundary, { + budgetMs: 10_000, + maxGenerals: 10, + catchUpCap: 1, + }); + expect(result).toMatchObject({ processedGenerals: 1, processedTurns: 1, partial: false }); + expect(world.getState().meta).toMatchObject({ isUnited: 2, isunited: 2 }); + expect(world.peekDirtyState().lifecycleEvents).toContainEqual( + expect.objectContaining({ + generalId: general.id, + outcome: 'retired', + isUnitedAtEvent: 0, + }) + ); + expect(world.getGeneralById(general.id)).toMatchObject({ + age: 20, + inheritancePoints: { tournament: 11, lived_month: 11, active_action: 4 }, + meta: { rank_warnum: 0, inherit_lived_month: 0, inherit_active_action: 0, dex1: 100 }, + }); + + const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns, profileName: 'che' }); + try { + await hooks.hooks.flushChanges?.(result); + } finally { + await hooks.close(); + } + + await expect( + db.hallOfFame.findUniqueOrThrow({ + where: { + serverId_type_generalNo: { + serverId: sameFlushServerId, + type: 'warnum', + generalNo: general.id, + }, + }, + }) + ).resolves.toMatchObject({ value: 11 }); + const dexHall = await db.hallOfFame.findUniqueOrThrow({ + where: { + serverId_type_generalNo: { + serverId: sameFlushServerId, + type: 'dex1', + generalNo: general.id, + }, + }, + }); + expect(dexHall).toMatchObject({ value: 120 }); + expect(asRecord(dexHall.aux)).toMatchObject({ unitedTime: monthBoundary.toISOString() }); + + const results = await db.inheritanceResult.findMany({ + where: { serverId: sameFlushServerId, owner: general.userId! }, + orderBy: { id: 'asc' }, + select: { value: true }, + }); + expect(results).toHaveLength(2); + const rebirth = asRecord(results[0]!.value); + const unification = asRecord(results[1]!.value); + expect(rebirth).toMatchObject({ rebirth: true, tournament: 11 }); + expect(asRecord(rebirth.retained)).toMatchObject({ unifier: 0 }); + expect(unification).toMatchObject({ + generationKey: `unification:${sameFlushServerId}`, + previous: rebirth.total, + lived_month: 0, + active_action: 0, + tournament: 0, + unifier: 250, + unifierBeforeAward: 250, + unifierAward: 0, + }); + await expect( + db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: general.userId!, key: 'previous' } }, + }) + ).resolves.toMatchObject({ value: Math.floor(Number(unification.total)) }); + }); + it('does not settle a possessed NPC before the legacy minimum possession period', async () => { const general = makeGeneral(generalIds[2]!, userIds[2]!, { npcState: 1, @@ -340,4 +925,429 @@ integration('general turn lifecycle persistence', () => { }) ).toBe(0); }); + + it('executes explicit retirement, flushes pre-reset settlement values, and reloads only the reborn state', async () => { + const general = makeGeneral(generalIds[3]!, userIds[3]!, { + nationId, + cityId, + age: 65, + experience: 1_001, + dedication: 801, + turnTime: new Date('0200-01-01T00:10:00.000Z'), + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: 'che_무기_12_칠성검', book: null, item: null }, + }, + meta: { + killturn: 24, + rank_warnum: 11, + firenum: 9, + inherit_earned: 4_321, + inherit_lived_month: 10, + inherit_active_action: 4, + inheritRandomUnique: 1, + inherit_spent_dyn: 3_000, + dex1: 200, + dex2: 0, + dex3: 0, + dex4: 0, + dex5: 0, + event100_allstar: { granted: { dex1: 80 } }, + }, + inheritancePoints: { previous: 50, lived_month: 10, active_action: 4 }, + }); + const automaticGeneral = makeGeneral(generalIds[4]!, userIds[4]!, { + nationId, + cityId, + age: 80, + crew: 100, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: 'che_무기_12_칠성검', book: null, item: null }, + }, + meta: { + killturn: 24, + rank_warnum: 6, + inherit_lived_month: 10, + inherit_active_action: 4, + inheritRandomUnique: 1, + inherit_spent_dyn: 3_000, + dex1: 40, + }, + inheritancePoints: { previous: 70, lived_month: 10, active_action: 4 }, + }); + const scenarioConfig = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: { + retirementYear: 80, + incDefSettingChange: 3, + maxDefSettingChange: 9, + inheritItemRandomPoint: 3_000, + allItems: { weapon: { che_무기_12_칠성검: 1 } }, + }, + environment: { mapName: 'che', unitSet: 'che' }, + }; + const scenarioMeta = { + title: '명시적 은퇴 integration', + startYear: 200, + life: null, + fiction: 0, + history: [], + ignoreDefaultEvents: false, + }; + const worldMeta = { + serverId, + season: 4, + scenarioId: 22, + gameIdx: 7, + isUnited: 0, + killturn: 24, + scenarioMeta, + }; + await db.worldState.create({ + data: { + id: worldId, + scenarioCode: 'explicit-retirement-integration', + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + config: scenarioConfig as GamePrisma.InputJsonValue, + meta: worldMeta as GamePrisma.InputJsonValue, + }, + }); + await db.nation.create({ + data: { id: nationId, name: '은퇴국', color: '#330000', level: 1, capitalCityId: cityId }, + }); + await db.city.create({ + data: { + id: cityId, + name: '은퇴성', + level: 5, + nationId, + population: 10_000, + populationMax: 20_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + region: 1, + }, + }); + await db.general.create({ + data: { + id: general.id, + userId: general.userId, + name: general.name, + nationId, + cityId, + npcState: 0, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + experience: general.experience, + dedication: general.dedication, + officerLevel: general.officerLevel, + injury: general.injury, + gold: general.gold, + rice: general.rice, + crew: general.crew, + crewTypeId: general.crewTypeId, + train: general.train, + atmos: general.atmos, + turnTime: general.turnTime, + age: general.age, + bornYear: general.bornYear, + deadYear: general.deadYear, + meta: general.meta as GamePrisma.InputJsonValue, + }, + }); + await db.general.create({ + data: { + id: automaticGeneral.id, + userId: automaticGeneral.userId, + name: automaticGeneral.name, + nationId, + cityId, + npcState: 0, + leadership: automaticGeneral.stats.leadership, + strength: automaticGeneral.stats.strength, + intel: automaticGeneral.stats.intelligence, + experience: automaticGeneral.experience, + dedication: automaticGeneral.dedication, + officerLevel: automaticGeneral.officerLevel, + injury: automaticGeneral.injury, + gold: automaticGeneral.gold, + rice: automaticGeneral.rice, + crew: automaticGeneral.crew, + crewTypeId: automaticGeneral.crewTypeId, + train: automaticGeneral.train, + atmos: automaticGeneral.atmos, + turnTime: automaticGeneral.turnTime, + age: automaticGeneral.age, + bornYear: automaticGeneral.bornYear, + deadYear: automaticGeneral.deadYear, + meta: automaticGeneral.meta as GamePrisma.InputJsonValue, + }, + }); + await db.rankData.createMany({ + data: [ + ...RANK_DATA_TYPES.map((type) => ({ + generalId: general.id, + nationId, + type, + value: type === 'warnum' ? 10 : type === 'firenum' ? 8 : type === 'inherit_earned' ? 123 : 0, + })), + ...RANK_DATA_TYPES.map((type) => ({ + generalId: automaticGeneral.id, + nationId, + type, + value: type === 'warnum' ? 5 : type === 'inherit_spent_dyn' ? 3_000 : 0, + })), + ], + }); + await db.inheritancePoint.createMany({ + data: [ + { userId: general.userId!, key: 'previous', value: 50 }, + { userId: general.userId!, key: 'lived_month', value: 10 }, + { userId: general.userId!, key: 'active_action', value: 4 }, + { userId: automaticGeneral.userId!, key: 'previous', value: 70 }, + { userId: automaticGeneral.userId!, key: 'lived_month', value: 10 }, + { userId: automaticGeneral.userId!, key: 'active_action', value: 4 }, + ], + }); + await db.gameHistory.createMany({ + data: [ + { + serverId, + date: new Date('2026-08-24T00:00:00.000Z'), + season: 4, + scenario: 22, + scenarioName: scenarioMeta.title, + status: 'OPEN', + }, + { + serverId: `${serverId}-completed`, + date: new Date('2026-08-23T00:00:00.000Z'), + season: 3, + scenario: 22, + scenarioName: '완료', + status: 'COMPLETED', + }, + { + serverId: `${serverId}-abandoned`, + date: new Date('2026-08-22T00:00:00.000Z'), + season: 3, + scenario: 22, + scenarioName: '취소', + status: 'ABANDONED', + }, + ], + }); + + const reservedTurns = new InMemoryReservedTurnStore(db, { maxGeneralTurns: 3, maxNationTurns: 3 }); + await reservedTurns.loadAll(); + reservedTurns.setGeneralTurn(general.id, 0, { action: 'che_은퇴', args: {} }); + reservedTurns.setGeneralTurn(general.id, 1, { action: 'che_은퇴', args: {} }); + reservedTurns.setGeneralTurn(automaticGeneral.id, 0, { action: 'che_훈련', args: {} }); + const state: TurnWorldState = { + id: worldId, + currentYear: 200, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: worldMeta, + }; + const nation = { + id: nationId, + name: '은퇴국', + color: '#330000', + capitalCityId: cityId, + chiefGeneralId: general.id, + gold: 10_000, + rice: 10_000, + power: 0, + level: 1, + typeCode: 'che_중립', + meta: { gennum: 2, tech: 0 }, + }; + const city = { + id: cityId, + name: '은퇴성', + nationId, + level: 5, + state: 0, + population: 10_000, + populationMax: 20_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + meta: { trust: 50, trade: 100, region: 1 }, + }; + const map = { + id: 'explicit-retirement', + name: '명시적 은퇴', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }; + let world: InMemoryTurnWorld | null = null; + const handler = await createReservedTurnHandler({ + reservedTurns, + scenarioConfig, + scenarioMeta, + map, + getWorld: () => world, + }); + const snapshot: TurnWorldSnapshot = { + scenarioConfig, + scenarioMeta, + map, + generals: [general, automaticGeneral], + nations: [nation], + cities: [city], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + world = new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + generalTurnHandler: handler, + }); + world.executeGeneralTurn(world.getGeneralById(automaticGeneral.id)!); + world.executeGeneralTurn(world.getGeneralById(general.id)!); + world.executeGeneralTurn(world.getGeneralById(general.id)!); + expect(world.peekDirtyState().lifecycleEvents.some((entry) => entry.outcome === 'retired')).toBe(true); + expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({ + userId: general.userId, + key: 'previous', + amount: 3_000, + phase: 'after_lifecycle', + }); + expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({ + userId: automaticGeneral.userId, + key: 'previous', + amount: 3_000, + }); + + const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns }); + try { + await hooks.hooks.flushChanges?.({ + lastTurnTime: state.lastTurnTime.toISOString(), + processedGenerals: 2, + processedTurns: 3, + durationMs: 0, + partial: false, + }); + } finally { + await hooks.close(); + } + + const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect(reloaded.snapshot.generals.find((entry) => entry.id === general.id)).toMatchObject({ + age: 20, + experience: 501, + dedication: 401, + meta: { + rank_warnum: 0, + firenum: 0, + inherit_earned: 0, + inherit_lived_month: 0, + inherit_active_action: 0, + inherit_spent_dyn: -3_000, + dex1: 100, + }, + }); + expect(reloaded.snapshot.generals.find((entry) => entry.id === general.id)?.meta).not.toHaveProperty( + 'inheritRandomUnique' + ); + expect(reloaded.snapshot.generals.find((entry) => entry.id === automaticGeneral.id)).toMatchObject({ + age: 20, + meta: { + rank_warnum: 0, + inherit_lived_month: 0, + inherit_active_action: 0, + inherit_spent_dyn: 0, + }, + }); + expect(reloaded.snapshot.generals.find((entry) => entry.id === automaticGeneral.id)?.meta).not.toHaveProperty( + 'inheritRandomUnique' + ); + await expect( + db.hallOfFame.findUniqueOrThrow({ + where: { serverId_type_generalNo: { serverId, type: 'warnum', generalNo: general.id } }, + }) + ).resolves.toMatchObject({ value: 11, aux: expect.objectContaining({ serverIdx: 7 }) }); + await expect( + db.hallOfFame.findUniqueOrThrow({ + where: { serverId_type_generalNo: { serverId, type: 'firenum', generalNo: general.id } }, + }) + ).resolves.toMatchObject({ value: 9 }); + await expect( + db.hallOfFame.findUniqueOrThrow({ + where: { serverId_type_generalNo: { serverId, type: 'inherit_earned', generalNo: general.id } }, + }) + ).resolves.toMatchObject({ value: 4_321 }); + await expect( + db.hallOfFame.findUniqueOrThrow({ + where: { serverId_type_generalNo: { serverId, type: 'dex1', generalNo: general.id } }, + }) + ).resolves.toMatchObject({ value: 120 }); + const result = await db.inheritanceResult.findFirstOrThrow({ + where: { serverId, owner: general.userId! }, + orderBy: { id: 'desc' }, + }); + expect(result.value).toMatchObject({ combat: 55, sabotage: 180, dex: 0.06, rebirth: true }); + const inheritanceLogs = await db.inheritanceLog.findMany({ + where: { userId: general.userId! }, + orderBy: { id: 'asc' }, + select: { text: true }, + }); + const settlementLogIndex = inheritanceLogs.findIndex(({ text }) => text.startsWith('포인트 ')); + const refundLogIndex = inheritanceLogs.findIndex( + ({ text }) => text === '유니크를 얻을 공간이 없어 3000 포인트 반환' + ); + expect(settlementLogIndex).toBeGreaterThanOrEqual(0); + expect(refundLogIndex).toBeGreaterThan(settlementLogIndex); + const persistedPrevious = await db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: general.userId!, key: 'previous' } }, + }); + expect(persistedPrevious.value).toBeGreaterThan(3_000); + const automaticInheritanceLogs = await db.inheritanceLog.findMany({ + where: { userId: automaticGeneral.userId! }, + orderBy: { id: 'asc' }, + select: { text: true }, + }); + const automaticRefundLogIndex = automaticInheritanceLogs.findIndex( + ({ text }) => text === '유니크를 얻을 공간이 없어 3000 포인트 반환' + ); + const automaticSettlementLogIndex = automaticInheritanceLogs.findIndex(({ text }) => + text.startsWith('포인트 ') + ); + expect(automaticRefundLogIndex).toBeGreaterThanOrEqual(0); + expect(automaticSettlementLogIndex).toBeGreaterThan(automaticRefundLogIndex); + await expect(db.oldGeneral.count({ where: { serverId, generalNo: general.id } })).resolves.toBe(0); + await expect(db.oldGeneral.count({ where: { serverId, generalNo: automaticGeneral.id } })).resolves.toBe(0); + }); }); diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts index 87dda617..23ad215c 100644 --- a/app/game-engine/test/generalTurnLifecyclePersistence.test.ts +++ b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts @@ -97,7 +97,14 @@ describe('general lifecycle archive history', () => { history: ['●둘째 기록', '●첫 기록'], records: { battleResult: ['둘째 전투 결과', '첫째 전투 결과'] }, availability: { battleResultLogs: true }, - meta: { killturn: 0, dex1: 1_000, rank_warnum: 2, rank_killnum: 1 }, + meta: expect.objectContaining({ + killturn: 0, + dex1: 1_000, + rank_warnum: 2, + rank_killnum: 1, + inheritRandomUnique: true, + inheritSpecificSpecialWar: true, + }), }), }), }) @@ -106,32 +113,60 @@ describe('general lifecycle archive history', () => { it('stores the inheritance earned rank in the hall before a rebirth resets ranks', async () => { const general = archivedGeneral(); - const hallCreateMany = vi.fn(async () => ({ count: 1 })); + const hallCreate = vi.fn(async () => undefined); + general.userId = 'hall-owner'; + general.meta = { + ...general.meta, + rank_warnum: 11, + inherit_earned: 4_321, + dex1: 200, + event100_allstar: { granted: { dex1: 80 } }, + }; + const postRetirement = { + ...general, + meta: { + ...general.meta, + rank_warnum: 0, + inherit_earned: 0, + }, + }; + const rankUpsert = vi.fn(async () => undefined); const prisma = { generalAccessLog: { updateMany: vi.fn(async () => ({ count: 1 })), }, rankData: { - findMany: vi.fn(async () => [{ type: 'inherit_earned', value: 4_321 }]), - updateMany: vi.fn(async () => ({ count: 1 })), + findMany: vi.fn(async () => [ + { type: 'warnum', value: 10 }, + { type: 'inherit_earned', value: 123 }, + ]), + upsert: rankUpsert, }, nation: { findUnique: vi.fn(async () => null), }, gameHistory: { - count: vi.fn(async () => 2), + count: vi.fn(async () => 99), }, hallOfFame: { - findUnique: vi.fn(async () => null), - createMany: hallCreateMany, + findMany: vi.fn(async () => []), + create: hallCreate, update: vi.fn(async () => undefined), }, + inheritancePoint: { + findMany: vi.fn(async () => [{ key: 'previous', value: 0 }]), + upsert: vi.fn(async () => undefined), + deleteMany: vi.fn(async () => ({ count: 0 })), + }, + inheritanceResult: { create: vi.fn(async () => undefined) }, + inheritanceLog: { create: vi.fn(async () => undefined) }, } as unknown as GamePrisma.TransactionClient; const event: GeneralLifecycleEvent = { generalId: general.id, outcome: 'retired', before: general, - after: general, + after: postRetirement, + isUnitedAtEvent: 0, year: 200, month: 1, }; @@ -139,26 +174,39 @@ describe('general lifecycle archive history', () => { await persistGeneralLifecycleEvents( prisma, [event], - { serverId: 'hall-fixture', season: 4, scenarioId: 22, isUnited: 0 }, - {} + { serverId: 'hall-fixture', season: 4, scenarioId: 22, isUnited: 2, gameIdx: 7 }, + {}, + new Date('0200-02-01T00:00:00.000Z') ); - expect(hallCreateMany).toHaveBeenCalledWith({ - data: [ - expect.objectContaining({ - serverId: 'hall-fixture', - season: 4, - scenario: 22, - generalNo: general.id, - type: 'inherit_earned', - value: 4_321, - }), - ], - skipDuplicates: true, + expect(hallCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + serverId: 'hall-fixture', + season: 4, + scenario: 22, + generalNo: general.id, + type: 'inherit_earned', + value: 4_321, + }), }); - expect(prisma.rankData.updateMany).toHaveBeenCalledWith({ - where: { generalId: general.id }, - data: { value: 0 }, + expect(hallCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ type: 'warnum', value: 11 }), + }); + expect(hallCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + type: 'dex1', + value: 120, + aux: expect.objectContaining({ serverIdx: 7, unitedTime: '0200-02-01T00:00:00.000Z' }), + }), + }); + expect(prisma.gameHistory.count).not.toHaveBeenCalled(); + expect(prisma.inheritanceLog.create).not.toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ text: expect.stringContaining('반환') }) }) + ); + expect(rankUpsert).toHaveBeenCalledWith({ + where: { generalId_type: { generalId: general.id, type: 'warnum' } }, + update: { nationId: general.nationId, value: 0 }, + create: { generalId: general.id, nationId: general.nationId, type: 'warnum', value: 0 }, }); }); }); diff --git a/app/game-engine/test/hallOfFamePersistence.test.ts b/app/game-engine/test/hallOfFamePersistence.test.ts new file mode 100644 index 00000000..736a9013 --- /dev/null +++ b/app/game-engine/test/hallOfFamePersistence.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GamePrisma } from '@sammo-ts/infra'; + +import { persistHallOfFameCandidate, resolveOfficialGameIndex } from '../src/turn/hallOfFamePersistence.js'; + +const candidate = { + serverId: 'hall-server', + season: 3, + scenario: 22, + generalNo: 20, + type: 'experience' as const, + value: 2_000, + owner: 'same-owner', + aux: { name: '새장수' }, +}; + +describe('Hall of Fame persistence policy', () => { + it('preserves an owner record belonging to another general for both higher and lower new values', async () => { + const update = vi.fn(async () => undefined); + const prisma = { + hallOfFame: { + findMany: vi.fn(async () => [ + { + id: 1, + serverId: candidate.serverId, + season: 3, + scenario: 22, + generalNo: 10, + type: candidate.type, + value: 1_000, + owner: candidate.owner, + aux: { name: '기존장수' }, + }, + ]), + create: vi.fn(async () => undefined), + update, + }, + } as unknown as GamePrisma.TransactionClient; + + await expect(persistHallOfFameCandidate(prisma, candidate)).resolves.toBe('PRESERVED'); + await expect(persistHallOfFameCandidate(prisma, { ...candidate, value: 500 })).resolves.toBe('PRESERVED'); + expect(update).not.toHaveBeenCalled(); + expect(prisma.hallOfFame.create).not.toHaveBeenCalled(); + }); + + it('updates only value and aux for a higher same-general record, and preserves a lower value', async () => { + const existing = { + id: 2, + serverId: candidate.serverId, + season: 3, + scenario: 22, + generalNo: candidate.generalNo, + type: candidate.type, + value: 1_500, + owner: 'old-owner', + aux: { name: '기존장수' }, + }; + const update = vi.fn(async () => undefined); + const prisma = { + hallOfFame: { + findMany: vi.fn(async () => [existing]), + create: vi.fn(async () => undefined), + update, + }, + } as unknown as GamePrisma.TransactionClient; + + await expect(persistHallOfFameCandidate(prisma, candidate)).resolves.toBe('UPDATED'); + expect(update).toHaveBeenCalledWith({ + where: { id: existing.id }, + data: { value: candidate.value, aux: candidate.aux }, + }); + + update.mockClear(); + await expect(persistHallOfFameCandidate(prisma, { ...candidate, value: 1_000 })).resolves.toBe('PRESERVED'); + expect(update).not.toHaveBeenCalled(); + }); + + it('uses persisted gameIdx and reconstructs fallback from COMPLETED games only', async () => { + const count = vi.fn(async () => 4); + const prisma = { gameHistory: { count } } as unknown as GamePrisma.TransactionClient; + + await expect(resolveOfficialGameIndex(prisma, { gameIdx: 0 })).resolves.toBe(0); + expect(count).not.toHaveBeenCalled(); + + await expect(resolveOfficialGameIndex(prisma, { firstGameIdx: 0 })).resolves.toBe(4); + expect(count).toHaveBeenCalledWith({ where: { status: 'COMPLETED' } }); + }); +}); diff --git a/app/game-engine/test/inheritanceActionPersistence.integration.test.ts b/app/game-engine/test/inheritanceActionPersistence.integration.test.ts new file mode 100644 index 00000000..24d47fad --- /dev/null +++ b/app/game-engine/test/inheritanceActionPersistence.integration.test.ts @@ -0,0 +1,399 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common'; +import { createGamePostgresConnector, type GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; +import type { MapDefinition, ScenarioConfig, ScenarioMeta, TurnSchedule } from '@sammo-ts/logic'; + +import { createDatabaseTurnHooks, type DatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { EngineStateManager } from '../src/turn/engineStateManager.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; +import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; + +const databaseUrl = process.env.IMMEDIATE_ACTION_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const worldId = 992_310; +const actorGeneralId = 7_310; +const targetGeneralId = 7_311; +const nationId = 7_312; +const actorUserId = 'inheritance-atomic-actor'; +const targetUserId = 'inheritance-atomic-target'; +const requestPrefix = 'integration:inheritance-atomic'; +const pointConstraint = 'inheritance_atomic_point_failure'; +const rankConstraint = 'inheritance_atomic_rank_failure'; +const logConstraint = 'inheritance_atomic_log_failure'; +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; + +const scenarioConfig: ScenarioConfig = { + stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: { + inheritBornStatPoint: 1_000, + inheritItemRandomPoint: 3_000, + inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000], + inheritSpecificSpecialPoint: 4_000, + inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000], + inheritCheckOwnerPoint: 1_000, + availableSpecialWar: ['che_의술'], + }, + environment: { mapName: 'che', unitSet: 'che' }, +}; +const scenarioMeta: ScenarioMeta = { + title: '유산 원자성 통합', + startYear: 200, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, +}; +const map: MapDefinition = { id: 'inheritance-atomic', name: scenarioMeta.title, cities: [] }; +const state: TurnWorldState = { + id: worldId, + currentYear: 200, + currentMonth: 4, + tickSeconds: 600, + lastTurnTime: new Date('2026-08-24T00:00:00.000Z'), + meta: { hiddenSeed: 'inheritance-atomic-seed', season: 77, isunited: 0, scenarioMeta }, +}; + +const buildGeneral = (overrides: Partial): TurnGeneral => ({ + id: actorGeneralId, + userId: actorUserId, + name: '확인장수', + nationId, + cityId: 0, + troopId: 0, + stats: { leadership: 70, strength: 45, intelligence: 85 }, + turnTime: new Date('2026-08-24T00:10:00.000Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, inherit_spent_dyn: 17 }, + inheritancePoints: { previous: 10_000 }, + penalty: {}, + officerLevel: 1, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + ...overrides, +}); +const actorGeneral = buildGeneral({}); +const targetGeneral = buildGeneral({ + id: targetGeneralId, + userId: targetUserId, + name: '피확인장수', + meta: { killturn: 24, owner_name: '레거시 소유자' }, + inheritancePoints: { previous: 0 }, +}); +const generals = [actorGeneral, targetGeneral]; + +const assertDedicatedDatabase = (rawUrl: string): void => { + const schema = new URL(rawUrl).searchParams.get('schema'); + if (!schema?.endsWith('immediate_action_integration')) { + throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`); + } +}; + +const toGeneralCreate = (general: TurnGeneral): GamePrisma.GeneralCreateManyInput => ({ + id: general.id, + userId: general.userId, + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + npcState: general.npcState, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + officerLevel: general.officerLevel, + experience: general.experience, + dedication: general.dedication, + injury: general.injury, + gold: general.gold, + rice: general.rice, + crew: general.crew, + crewTypeId: general.crewTypeId, + train: general.train, + atmos: general.atmos, + turnTime: general.turnTime, + recentWarTime: general.recentWarTime, + age: general.age, + meta: general.meta as GamePrisma.InputJsonValue, + penalty: general.penalty as GamePrisma.InputJsonValue, +}); + +const buildCommand = ( + suffix: string, + input: Extract['input'] +): Extract => ({ + type: 'inheritanceAction', + requestId: `${requestPrefix}:${suffix}`, + userId: actorUserId, + input, +}); + +integration('inheritance action PostgreSQL atomic persistence', () => { + let db: GamePrismaClient; + let disconnect: (() => Promise) | undefined; + let hooks: DatabaseTurnHooks | undefined; + + const dropFailureConstraints = async (): Promise => { + await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT IF EXISTS ${pointConstraint}`); + await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT IF EXISTS ${rankConstraint}`); + await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT IF EXISTS ${logConstraint}`); + }; + + beforeAll(async () => { + assertDedicatedDatabase(databaseUrl!); + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + disconnect = () => connector.disconnect(); + await dropFailureConstraints(); + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); + await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); + await db.inheritanceUserState.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); + await db.rankData.deleteMany({ where: { generalId: { in: [actorGeneralId, targetGeneralId] } } }); + await db.general.deleteMany({ where: { id: { in: [actorGeneralId, targetGeneralId] } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await db.worldState.deleteMany({ where: { id: worldId } }); + + await db.worldState.create({ + data: { + id: worldId, + scenarioCode: 'inheritance-atomic', + currentYear: state.currentYear, + currentMonth: state.currentMonth, + tickSeconds: state.tickSeconds, + config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue, + meta: state.meta as GamePrisma.InputJsonValue, + }, + }); + await db.nation.create({ data: { id: nationId, name: '통합국', color: '#123456', level: 1 } }); + await db.general.createMany({ data: generals.map(toGeneralCreate) }); + await db.inheritancePoint.create({ data: { userId: actorUserId, key: 'previous', value: 10_000 } }); + await db.rankData.create({ + data: { generalId: actorGeneralId, nationId, type: 'inherit_spent_dyn', value: 17 }, + }); + }); + + afterAll(async () => { + await hooks?.close(); + if (db) { + await dropFailureConstraints(); + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); + await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); + await db.inheritanceUserState.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); + await db.inheritancePoint.deleteMany({ where: { userId: { in: [actorUserId, targetUserId] } } }); + await db.rankData.deleteMany({ where: { generalId: { in: [actorGeneralId, targetGeneralId] } } }); + await db.general.deleteMany({ where: { id: { in: [actorGeneralId, targetGeneralId] } } }); + await db.nation.deleteMany({ where: { id: nationId } }); + await db.worldState.deleteMany({ where: { id: worldId } }); + } + await disconnect?.(); + }); + + it('rolls patch/point/rank/log/messages back at each injected failure and reloads one committed mutation', async () => { + const snapshot: TurnWorldSnapshot = { + generals, + cities: [], + nations: [ + { + id: nationId, + name: '통합국', + color: '#123456', + capitalCityId: null, + chiefGeneralId: actorGeneralId, + gold: 0, + rice: 0, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, + }, + ], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig, + scenarioMeta, + map, + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const handler = createTurnDaemonCommandHandler({ world }); + hooks = await createDatabaseTurnHooks(databaseUrl!, world); + const stateManager = new EngineStateManager(); + stateManager.register('world', { + capture: () => world.captureState(), + restore: (captured) => world.restoreState(captured), + }); + const execute = async ( + command: Extract + ): Promise => { + if (!hooks?.hooks.executeCommand || !command.requestId) { + throw new Error('Database command execution hook is unavailable.'); + } + return stateManager.transaction(() => + hooks!.hooks.executeCommand!(command.requestId!, async (context) => { + const result = await handler.handle(command, context); + if (!result) throw new Error('inheritanceAction command was not handled.'); + return result; + }) + ); + }; + const createInputEvent = async ( + command: Extract + ): Promise => { + await db.inputEvent.create({ + data: { + requestId: command.requestId!, + target: 'ENGINE', + eventType: command.type, + actorUserId: command.userId, + status: 'PROCESSING', + lockedBy: 'inheritance-atomic-worker', + leaseUntil: new Date('2026-08-24T01:00:00.000Z'), + attempts: 1, + payload: command as GamePrisma.InputJsonValue, + }, + }); + }; + const assertStored = async (point: number, spent: number, logCount: number, messageCount: number) => { + await expect( + db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: actorUserId, key: 'previous' } }, + }) + ).resolves.toMatchObject({ value: point }); + await expect( + db.rankData.findUniqueOrThrow({ + where: { generalId_type: { generalId: actorGeneralId, type: 'inherit_spent_dyn' } }, + }) + ).resolves.toMatchObject({ value: spent }); + await expect(db.inheritanceLog.count({ where: { userId: actorUserId } })).resolves.toBe(logCount); + await expect( + db.message.count({ where: { mailbox: { in: [actorGeneralId, targetGeneralId] } } }) + ).resolves.toBe(messageCount); + }; + + const pointCommand = buildCommand('point', { + action: 'buyHiddenBuff', + buffType: 'warAvoidRatio', + level: 1, + }); + await createInputEvent(pointCommand); + await db.$executeRawUnsafe(` + ALTER TABLE inheritance_point + ADD CONSTRAINT ${pointConstraint} + CHECK (user_id <> '${actorUserId}' OR key <> 'previous' OR value = 10000) + `); + await expect(execute(pointCommand)).rejects.toThrow(`violates check constraint "${pointConstraint}"`); + expect(world.getGeneralById(actorGeneralId)?.meta).toMatchObject({ inherit_spent_dyn: 17 }); + expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritBuff'); + await assertStored(10_000, 17, 0, 0); + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId: pointCommand.requestId! } }) + ).resolves.toMatchObject({ + status: 'PROCESSING', + result: null, + }); + await db.$executeRawUnsafe(`ALTER TABLE inheritance_point DROP CONSTRAINT ${pointConstraint}`); + await expect(execute(pointCommand)).resolves.toMatchObject({ ok: true, remainPoint: 9_800 }); + await assertStored(9_800, 217, 1, 0); + + const rankCommand = buildCommand('rank', { action: 'checkOwner', targetGeneralId }); + await createInputEvent(rankCommand); + await db.$executeRawUnsafe(` + ALTER TABLE rank_data + ADD CONSTRAINT ${rankConstraint} + CHECK (general_id <> ${actorGeneralId} OR type <> 'inherit_spent_dyn' OR value = 217) + `); + await expect(execute(rankCommand)).rejects.toThrow(`violates check constraint "${rankConstraint}"`); + expect(world.peekDirtyState().messages).toEqual([]); + await assertStored(9_800, 217, 1, 0); + await db.$executeRawUnsafe(`ALTER TABLE rank_data DROP CONSTRAINT ${rankConstraint}`); + await expect(execute(rankCommand)).resolves.toMatchObject({ + ok: true, + remainPoint: 8_800, + ownerName: '레거시 소유자', + }); + await assertStored(8_800, 1_217, 2, 2); + + const currentLog = await db.inheritanceLog.findFirstOrThrow({ + where: { userId: actorUserId }, + orderBy: { id: 'desc' }, + select: { id: true }, + }); + const logCommand = buildCommand('log', { action: 'buyRandomUnique' }); + await createInputEvent(logCommand); + await db.$executeRawUnsafe(` + ALTER TABLE inheritance_log + ADD CONSTRAINT ${logConstraint} + CHECK (user_id <> '${actorUserId}' OR id <= ${currentLog.id}) + `); + await expect(execute(logCommand)).rejects.toThrow(`violates check constraint "${logConstraint}"`); + expect(world.getGeneralById(actorGeneralId)?.meta).not.toHaveProperty('inheritRandomUnique'); + await assertStored(8_800, 1_217, 2, 2); + await db.$executeRawUnsafe(`ALTER TABLE inheritance_log DROP CONSTRAINT ${logConstraint}`); + await expect(execute(logCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 }); + await assertStored(5_800, 4_217, 3, 2); + + const freeStatCommand = buildCommand('free-stat', { + action: 'resetStat', + leadership: 70, + strength: 45, + intel: 85, + inheritBonusStat: [0, 0, 0], + }); + await createInputEvent(freeStatCommand); + await expect(execute(freeStatCommand)).resolves.toMatchObject({ ok: true, remainPoint: 5_800 }); + await assertStored(5_800, 4_217, 5, 2); + + const messages = await db.message.findMany({ + where: { mailbox: { in: [actorGeneralId, targetGeneralId] } }, + orderBy: { id: 'asc' }, + select: { mailbox: true, message: true }, + }); + expect(messages.map((entry) => [entry.mailbox, (entry.message as { text: string }).text])).toEqual([ + [actorGeneralId, '피확인장수의 소유자는 레거시 소유자 입니다.'], + [targetGeneralId, '소유자명이 누군가에 의해 확인되었습니다.'], + ]); + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId: rankCommand.requestId! } }) + ).resolves.toMatchObject({ + status: 'SUCCEEDED', + attempts: 1, + result: expect.objectContaining({ type: 'inheritanceAction', ok: true, action: 'checkOwner' }), + lockedBy: null, + }); + + const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect(reloaded.snapshot.generals.find((general) => general.id === actorGeneralId)).toMatchObject({ + stats: { leadership: 71, strength: 47, intelligence: 86 }, + meta: { + inherit_spent_dyn: 4_217, + inheritRandomUnique: 1, + inheritBuff: JSON.stringify({ warAvoidRatio: 1 }), + }, + inheritancePoints: { previous: 5_800 }, + }); + }, 30_000); +}); diff --git a/app/game-engine/test/inheritanceActionService.test.ts b/app/game-engine/test/inheritanceActionService.test.ts new file mode 100644 index 00000000..6d08bd00 --- /dev/null +++ b/app/game-engine/test/inheritanceActionService.test.ts @@ -0,0 +1,357 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LiteHashDRBG, RandUtil, type TurnDaemonCommand } from '@sammo-ts/common'; +import type { GamePrisma } from '@sammo-ts/infra'; +import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; + +import { + buildResetStatRandomBonus, + executeInheritanceAction, + resolveOwnerDisplayName, +} from '../src/turn/inheritanceActionService.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +type InheritanceCommand = Extract; + +const buildGeneral = (overrides: Partial = {}): TurnGeneral => ({ + id: 1, + userId: 'user-1', + name: '유비', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 45, intelligence: 85 }, + experience: 0, + dedication: 0, + officerLevel: 1, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24, inherit_spent_dyn: 17 }, + inheritancePoints: { previous: 10_000 }, + turnTime: new Date('0200-04-01T00:00:00.000Z'), + ...overrides, +}); + +const buildWorld = (options: { + general?: TurnGeneral; + target?: TurnGeneral; + worldMeta?: Record; + configConst?: Record; + configMap?: Record; +}) => { + const state: TurnWorldState = { + id: 1, + currentYear: 200, + currentMonth: 4, + tickSeconds: 3_600, + lastTurnTime: new Date('0200-04-01T00:00:00.000Z'), + meta: { hiddenSeed: 'test-seed', season: 7, isunited: 0, ...(options.worldMeta ?? {}) }, + }; + const snapshot: TurnWorldSnapshot = { + scenarioConfig: { + stat: { total: 200, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: options.configMap ?? {}, + const: { + availableSpecialWar: ['che_의술'], + inheritBornStatPoint: 1_000, + inheritItemRandomPoint: 3_000, + inheritBuffPoints: [0, 200, 600, 1_200, 2_000, 3_000], + inheritSpecificSpecialPoint: 4_000, + inheritResetAttrPointBase: [1_000, 1_000, 2_000, 3_000], + inheritCheckOwnerPoint: 1_000, + ...(options.configConst ?? {}), + }, + environment: { mapName: 'test', unitSet: 'default' }, + }, + map: { id: 'test', name: 'test', cities: [] }, + generals: [options.general ?? buildGeneral(), ...(options.target ? [options.target] : [])], + cities: [], + nations: [ + { + id: 1, + name: '촉', + color: '#ff0000', + capitalCityId: null, + chiefGeneralId: 1, + gold: 0, + rice: 0, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, + }, + ], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + }; + return new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); +}; + +const buildDatabase = (options: { point?: number; resetSeasons?: number[] } = {}) => { + const createLog = vi.fn(async () => ({})); + const findUserState = vi.fn(async () => + options.resetSeasons ? { meta: { last_stat_reset: options.resetSeasons } } : null + ); + const upsertUserState = vi.fn(async () => ({})); + const queryRaw = vi.fn(async () => [{ value: options.point ?? 10_000 }]); + return { + db: { + $queryRaw: queryRaw, + inheritanceLog: { create: createLog }, + inheritanceUserState: { findUnique: findUserState, upsert: upsertUserState }, + } as unknown as GamePrisma.TransactionClient, + createLog, + findUserState, + upsertUserState, + queryRaw, + }; +}; + +const execute = async ( + world: InMemoryTurnWorld, + db: GamePrisma.TransactionClient, + input: InheritanceCommand['input'] +) => + executeInheritanceAction({ + db, + world, + command: { type: 'inheritanceAction', userId: 'user-1', input }, + gameNow: new Date('0200-04-01T00:00:00.000Z'), + }); + +describe('inheritance action service', () => { + it.each([ + { + name: 'hidden buff', + input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const, + cost: 200, + general: buildGeneral(), + }, + { + name: 'specific special', + input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const, + cost: 4_000, + general: buildGeneral(), + }, + { + name: 'special reset', + input: { action: 'resetSpecialWar' } as const, + cost: 1_000, + general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_선봉' } }), + }, + { + name: 'turn-time reset', + input: { action: 'resetTurnTime' } as const, + cost: 1_000, + general: buildGeneral(), + }, + { + name: 'paid stat reset', + input: { + action: 'resetStat', + leadership: 70, + strength: 45, + intel: 85, + inheritBonusStat: [2, 1, 1] as [number, number, number], + } as const, + cost: 1_000, + general: buildGeneral(), + }, + { + name: 'random unique reservation', + input: { action: 'buyRandomUnique' } as const, + cost: 3_000, + general: buildGeneral(), + }, + { + name: 'owner lookup', + input: { action: 'checkOwner', targetGeneralId: 2 } as const, + cost: 1_000, + general: buildGeneral(), + }, + ])('charges $name in runtime rank, points, and the same dirty flush', async ({ input, cost, general }) => { + const target = + input.action === 'checkOwner' + ? buildGeneral({ + id: 2, + userId: 'user-2', + name: '조조', + meta: { killturn: 24, owner_name: '위유저' }, + }) + : undefined; + const world = buildWorld({ general, target }); + const { db, createLog } = buildDatabase(); + + await expect(execute(world, db, input)).resolves.toMatchObject({ ok: true, remainPoint: 10_000 - cost }); + + expect(world.getGeneralById(1)).toMatchObject({ + meta: { inherit_spent_dyn: 17 + cost }, + inheritancePoints: { previous: 10_000 - cost }, + }); + expect(world.peekDirtyState().inheritancePointAdjustments).toEqual( + cost === 0 ? [] : [{ userId: 'user-1', key: 'previous', amount: -cost }] + ); + expect(createLog).toHaveBeenCalled(); + }); + + it('keeps free ResetStat at zero spend and uses the Ref-compatible fixed-seed bonus', async () => { + const world = buildWorld({}); + const { db } = buildDatabase({ point: 0 }); + + const result = await execute(world, db, { + action: 'resetStat', + leadership: 70, + strength: 45, + intel: 85, + inheritBonusStat: [0, 0, 0], + }); + + expect(result).toMatchObject({ ok: true, remainPoint: 0 }); + expect(world.getGeneralById(1)?.meta.inherit_spent_dyn).toBe(17); + expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([]); + expect(result.ok && result.stats).toEqual({ leadership: 73, strength: 45, intel: 87 }); + }); + + it('matches the Ref ResetStat DRBG seed, inclusive 3..5 count, and weighted choices', () => { + const bonus = buildResetStatRandomBonus( + new RandUtil(new LiteHashDRBG(simpleSerialize('test-seed', 'ResetStat', 'user-1'))), + [70, 45, 85] + ); + expect(bonus).toEqual([3, 0, 2]); + expect(bonus.reduce((sum, value) => sum + value, 0)).toBeGreaterThanOrEqual(3); + expect(bonus.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(5); + }); + + it.each([1, 2])('rejects npcState=%s ResetStat exactly like Ref npc != 0', async (npcState) => { + const world = buildWorld({ general: buildGeneral({ npcState }) }); + const { db, queryRaw } = buildDatabase(); + + await expect( + execute(world, db, { + action: 'resetStat', + leadership: 70, + strength: 45, + intel: 85, + inheritBonusStat: [2, 1, 1], + }) + ).resolves.toMatchObject({ ok: false, reason: 'NPC는 능력치 초기화를 할 수 없습니다.' }); + expect(queryRaw).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: 'purchased buff before unification', + general: buildGeneral({ meta: { killturn: 24, inheritBuff: JSON.stringify({ warAvoidRatio: 1 }) } }), + input: { action: 'buyHiddenBuff', buffType: 'warAvoidRatio', level: 1 } as const, + reason: '이미 구입했습니다.', + }, + { + name: 'owned special before unification', + general: buildGeneral({ role: { ...buildGeneral().role, specialWar: 'che_의술' } }), + input: { action: 'setNextSpecialWar', specialKey: 'che_의술' } as const, + reason: '이미 그 특기를 보유하고 있습니다.', + }, + { + name: 'blank special before unification', + general: buildGeneral(), + input: { action: 'resetSpecialWar' } as const, + reason: '이미 전투 특기가 공란입니다.', + }, + { + name: 'random reservation before unification', + general: buildGeneral({ meta: { killturn: 24, inheritRandomUnique: true } }), + input: { action: 'buyRandomUnique' } as const, + reason: '이미 구입 명령을 내렸습니다. 다음 턴까지 기다려주세요.', + }, + ])('preserves Ref combined-invalid precedence: $name', async ({ general, input, reason }) => { + const world = buildWorld({ general, worldMeta: { isunited: 1 } }); + const { db, queryRaw } = buildDatabase(); + + await expect(execute(world, db, input)).resolves.toMatchObject({ ok: false, reason }); + expect(queryRaw).not.toHaveBeenCalled(); + }); + + it('checks ResetStat shape, npc/S100, unification, season duplicate, then points', async () => { + const npcWorld = buildWorld({ general: buildGeneral({ npcState: 1 }), worldMeta: { isunited: 1 } }); + const npcDb = buildDatabase({ point: 0 }); + await expect( + execute(npcWorld, npcDb.db, { + action: 'resetStat', + leadership: 70, + strength: 45, + intel: 84, + inheritBonusStat: [2, 1, 1], + }) + ).resolves.toMatchObject({ ok: false, reason: '능력치 총합이 200이 아닙니다. 다시 입력해주세요!' }); + + const seasonWorld = buildWorld({}); + const seasonDb = buildDatabase({ point: 0, resetSeasons: [7] }); + await expect( + execute(seasonWorld, seasonDb.db, { + action: 'resetStat', + leadership: 70, + strength: 45, + intel: 85, + inheritBonusStat: [2, 1, 1], + }) + ).resolves.toMatchObject({ ok: false, reason: '이번 시즌에 이미 능력치를 초기화하셨습니다.' }); + expect(seasonDb.queryRaw).not.toHaveBeenCalled(); + }); + + it('uses both unification keys and preserves owner display-name compatibility order', async () => { + const world = buildWorld({ worldMeta: { isUnited: 1, isunited: 0 } }); + const { db } = buildDatabase(); + await expect(execute(world, db, { action: 'resetTurnTime' })).resolves.toMatchObject({ + ok: false, + reason: '이미 천하가 통일되었습니다.', + }); + + expect(resolveOwnerDisplayName({ ownerDisplayName: '현재', owner_name: '레거시', ownerName: '호환' })).toBe( + '현재' + ); + expect(resolveOwnerDisplayName({ owner_name: '레거시', ownerName: '호환' })).toBe('레거시'); + expect(resolveOwnerDisplayName({ ownerName: '호환' })).toBe('호환'); + expect(resolveOwnerDisplayName({})).toBe('알수없음'); + }); + + it('queues CheckOwner messages in Ref requester-then-target order', async () => { + const world = buildWorld({ + target: buildGeneral({ + id: 2, + userId: 'user-2', + name: '조조', + meta: { killturn: 24, owner_name: '위유저' }, + }), + }); + const { db } = buildDatabase(); + + await expect(execute(world, db, { action: 'checkOwner', targetGeneralId: 2 })).resolves.toMatchObject({ + ok: true, + ownerName: '위유저', + }); + expect(world.peekDirtyState().messages.map((message) => [message.dest.generalId, message.text])).toEqual([ + [1, '조조의 소유자는 위유저 입니다.'], + [2, '소유자명이 누군가에 의해 확인되었습니다.'], + ]); + }); +}); diff --git a/app/game-engine/test/inheritanceSettlementLogs.test.ts b/app/game-engine/test/inheritanceSettlementLogs.test.ts new file mode 100644 index 00000000..88e6cad1 --- /dev/null +++ b/app/game-engine/test/inheritanceSettlementLogs.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { buildInheritanceSettlementLogTexts } from '../src/turn/inheritanceSettlementLogs.js'; + +describe('legacy inheritance settlement logs', () => { + it('logs calculated keys plus only direct stored keys that are present, in Ref order', () => { + expect( + buildInheritanceSettlementLogTexts({ + previous: 100, + points: { + lived_month: 12, + max_belong: 90, + max_domestic_critical: 80, + active_action: 3, + combat: 15, + sabotage: 40, + unifier: 250, + dex: 1.25, + tournament: 50, + betting: 5, + }, + storedKeys: new Set(['previous', 'lived_month', 'unifier']), + total: 521, + isRebirth: false, + }) + ).toEqual([ + '기존 보유 포인트 100 증가', + '생존 포인트 12 증가', + '최대 임관년 수 포인트 90 증가', + '전투 횟수 포인트 15 증가', + '계략 성공 횟수 포인트 40 증가', + '천통 기여 포인트 250 증가', + '숙련도 포인트 1.25 증가', + '베팅 당첨 포인트 5 증가', + '포인트 100 => 521', + ]); + }); + + it('skips delayed rebirth keys and logs coefficient-adjusted values', () => { + expect( + buildInheritanceSettlementLogTexts({ + previous: 50, + points: { + lived_month: 12, + max_belong: 0, + max_domestic_critical: 0, + active_action: 3, + combat: 15, + sabotage: 40, + unifier: 0, + dex: 0.5, + tournament: 7, + betting: 5, + }, + storedKeys: new Set([ + 'previous', + 'lived_month', + 'max_domestic_critical', + 'active_action', + 'unifier', + 'tournament', + ]), + total: 132, + isRebirth: true, + }) + ).toEqual([ + '기존 보유 포인트 50 증가', + '생존 포인트 12 증가', + '능동 행동 수 포인트 3 증가', + '전투 횟수 포인트 15 증가', + '계략 성공 횟수 포인트 40 증가', + '숙련도 포인트 0.5 증가', + '토너먼트 포인트 7 증가', + '베팅 당첨 포인트 5 증가', + '포인트 50 => 132', + ]); + }); +}); diff --git a/app/game-engine/test/monthlyNationLevelAction.test.ts b/app/game-engine/test/monthlyNationLevelAction.test.ts index 923bc10c..79e0ad64 100644 --- a/app/game-engine/test/monthlyNationLevelAction.test.ts +++ b/app/game-engine/test/monthlyNationLevelAction.test.ts @@ -214,7 +214,7 @@ describe('UpdateNationLevel monthly action', () => { expect(world.getGeneralById(1)?.role.items.horse).toBe(uniqueHorse.key); expect(world.getGeneralById(2)?.role.items.horse).toBeNull(); expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ - { userId: 'user-1', key: 'unifier', amount: 500 }, + { userId: 'user-1', key: 'unifier', amount: 500, phase: 'after_lifecycle' }, ]); expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(500); expect(world.peekDirtyState().logs).toEqual( @@ -291,7 +291,7 @@ describe('UpdateNationLevel monthly action', () => { meta: { marker: 1, can_국기변경: 1, can_국호변경: 1 }, }); expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ - { userId: 'user-1', key: 'unifier', amount: 250 }, + { userId: 'user-1', key: 'unifier', amount: 250, phase: 'after_lifecycle' }, ]); expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250); }); @@ -306,7 +306,7 @@ describe('UpdateNationLevel monthly action', () => { expect(world.getGeneralById(1)?.role.items.horse).toBeNull(); expect(world.peekDirtyState().logs.some((entry) => entry.text.includes('작위보상'))).toBe(false); expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ - { userId: 'user-1', key: 'unifier', amount: 250 }, + { userId: 'user-1', key: 'unifier', amount: 250, phase: 'after_lifecycle' }, ]); expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250); }); diff --git a/app/game-engine/test/realtimeReadModelChanges.test.ts b/app/game-engine/test/realtimeReadModelChanges.test.ts index 786658ea..5c43c0b2 100644 --- a/app/game-engine/test/realtimeReadModelChanges.test.ts +++ b/app/game-engine/test/realtimeReadModelChanges.test.ts @@ -84,6 +84,7 @@ describe('durable read-model change journal mapping', () => { lifecycleEvents: [], pendingNeutralAuctions: [], inheritancePointAdjustments: [], + pendingInheritanceLogs: [], pendingNationBettingOpens: [], pendingNationBettingFinishes: [], pendingYearbookSnapshots: [], diff --git a/app/game-engine/test/unificationPersistence.test.ts b/app/game-engine/test/unificationPersistence.test.ts index d95a61c1..e180fad9 100644 --- a/app/game-engine/test/unificationPersistence.test.ts +++ b/app/game-engine/test/unificationPersistence.test.ts @@ -35,6 +35,7 @@ const buildWorld = (): InMemoryTurnWorld => { rank_warnum: 4, firenum: 2, dex1: 100, + event100_allstar: { granted: { dex1: 40 } }, }, officerLevel: 12, experience: 10, @@ -141,18 +142,12 @@ const input = { describe('persistUnificationFinalization', () => { it.each([ - { label: 'missing row', rows: [], memoryValue: 2_000, expected: 0 }, - { label: 'zero row', rows: [['unifier', 0] as const], memoryValue: 2_000, expected: 0 }, - { label: 'positive row', rows: [['unifier', 7] as const], memoryValue: 2_007, expected: 7 }, - ])('resolves the pre-award unifier value for $label', ({ rows, memoryValue, expected }) => { - expect( - resolveStoredInheritancePoint( - new Map(rows), - { inheritancePoints: { unifier: memoryValue } }, - 'unifier', - 2_000 - ) - ).toBe(expected); + { label: 'missing unifier row', rows: [], key: 'unifier' as const, expected: 0 }, + { label: 'missing resettable row', rows: [], key: 'tournament' as const, expected: 0 }, + { label: 'zero row', rows: [['unifier', 0] as const], key: 'unifier' as const, expected: 0 }, + { label: 'positive row', rows: [['unifier', 7] as const], key: 'unifier' as const, expected: 7 }, + ])('uses only transaction-visible inheritance storage for $label', ({ rows, key, expected }) => { + expect(resolveStoredInheritancePoint(new Map(rows), key)).toBe(expected); }); it('does not write when the transaction-scoped generation was already applied', async () => { @@ -222,7 +217,7 @@ describe('persistUnificationFinalization', () => { }, gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate }, hallOfFame: { - findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([]), create: hallCreate, update: vi.fn().mockResolvedValue({}), }, @@ -274,6 +269,11 @@ describe('persistUnificationFinalization', () => { data: expect.objectContaining({ type: 'inherit_earned', value: 4_321 }), }) ); + expect(hallCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ type: 'dex1', value: 60 }), + }) + ); expect(gameHistoryUpdate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) }) ); diff --git a/app/game-engine/test/uniqueLotteryCommand.test.ts b/app/game-engine/test/uniqueLotteryCommand.test.ts index 6b0b7642..41aa8023 100644 --- a/app/game-engine/test/uniqueLotteryCommand.test.ts +++ b/app/game-engine/test/uniqueLotteryCommand.test.ts @@ -186,9 +186,13 @@ describe('unique lottery on general commands', () => { expect(dedicationIndex).toBeLessThan(uniqueIndex); }); - it('does not award a unique item reserved by an active auction', async () => { + it('refunds a pending inheritance purchase when active auctions exhaust the supply', async () => { const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; - const generals = [buildGeneral(1)]; + const lotteryGeneral = buildGeneral(1); + lotteryGeneral.userId = 'inherit-user'; + lotteryGeneral.meta = { killturn: 24, inheritRandomUnique: true, inherit_spent_dyn: 3_000 }; + lotteryGeneral.inheritancePoints = { previous: 200 }; + const generals = [lotteryGeneral]; const snapshot: TurnWorldSnapshot = { generals: generals as any, cities: [ @@ -267,6 +271,7 @@ describe('unique lottery on general commands', () => { uniqueTrialCoef: 10, maxUniqueTrialProb: 10, minMonthToAllowInheritItem: 0, + inheritItemRandomPoint: 3_000, }, environment: { mapName: 'test_map', unitSet: 'default' }, }, @@ -317,6 +322,22 @@ describe('unique lottery on general commands', () => { }); expect(result.general?.role.items.weapon).toBeNull(); + expect(result.general?.meta).toMatchObject({ inherit_spent_dyn: 0 }); + expect(result.general?.meta).not.toHaveProperty('inheritRandomUnique'); + expect(result.general?.inheritancePoints?.previous).toBe(3_200); expect((result.logs ?? []).some((entry) => entry.text.includes('【아이템】'))).toBe(false); + expect(world.peekDirtyState().inheritancePointAdjustments).toContainEqual({ + userId: 'inherit-user', + key: 'previous', + amount: 3_000, + }); + expect(world.peekDirtyState().pendingInheritanceLogs).toEqual([ + { + userId: 'inherit-user', + year: 180, + month: 1, + text: '얻을 유니크가 없어 3000 포인트 반환', + }, + ]); }); }); diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index e8e8c95f..2eb263c6 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -79,6 +79,33 @@ export interface TurnDaemonSelectPoolReservation { candidates: TurnDaemonSelectPoolCandidate[]; } +export type TurnDaemonInheritanceAction = + | { + action: 'buyHiddenBuff'; + buffType: + | 'warAvoidRatio' + | 'warCriticalRatio' + | 'warMagicTrialProb' + | 'domesticSuccessProb' + | 'domesticFailProb' + | 'warAvoidRatioOppose' + | 'warCriticalRatioOppose' + | 'warMagicTrialProbOppose'; + level: number; + } + | { action: 'setNextSpecialWar'; specialKey: string } + | { action: 'resetSpecialWar' } + | { action: 'resetTurnTime' } + | { + action: 'resetStat'; + leadership: number; + strength: number; + intel: number; + inheritBonusStat?: [number, number, number]; + } + | { action: 'buyRandomUnique' } + | { action: 'checkOwner'; targetGeneralId: number }; + export type TurnDaemonCommand = | { type: 'run'; @@ -266,6 +293,12 @@ export type TurnDaemonCommand = specialWar?: string | null; }; } + | { + type: 'inheritanceAction'; + requestId?: string; + userId: string; + input: TurnDaemonInheritanceAction; + } | { type: 'adjustGeneralIcon'; requestId?: string; @@ -635,6 +668,25 @@ export type TurnDaemonCommandResult = generalId: number; reason: string; } + | { + type: 'inheritanceAction'; + ok: true; + action: TurnDaemonInheritanceAction['action']; + generalId: number; + remainPoint: number; + nextTurnTimeBase?: number; + nextTurnTimeLabel?: string; + stats?: { leadership: number; strength: number; intel: number }; + ownerName?: string; + targetName?: string; + } + | { + type: 'inheritanceAction'; + ok: false; + action: TurnDaemonInheritanceAction['action']; + code: 'BAD_REQUEST' | 'FORBIDDEN' | 'PRECONDITION_FAILED' | 'INTERNAL_SERVER_ERROR'; + reason: string; + } | { type: 'adjustGeneralIcon'; ok: true; diff --git a/packages/logic/src/actions/turn/general/che_은퇴.ts b/packages/logic/src/actions/turn/general/che_은퇴.ts index 0848a320..0b88f16c 100644 --- a/packages/logic/src/actions/turn/general/che_은퇴.ts +++ b/packages/logic/src/actions/turn/general/che_은퇴.ts @@ -22,6 +22,9 @@ const ACTION_NAME = '은퇴'; const ACTION_KEY = 'che_은퇴'; const REQ_AGE = 60; +const hasPendingRandomUnique = (value: unknown): boolean => + value === true || value === 1 || (typeof value === 'string' && (value === '1' || value.toLowerCase() === 'true')); + const reqGeneralValue = (): Constraint => ({ name: 'reqGeneralValue', requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], @@ -43,18 +46,6 @@ export class ActionResolver< const general = context.general; const effects: GeneralActionEffect[] = []; - const nextMeta = { ...general.meta }; - for (const key of ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const) { - const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0; - nextMeta[key] = Math.round(value * 0.5); - } - delete nextMeta.specAge; - delete nextMeta.specAge2; - nextMeta.specage = 0; - nextMeta.specage2 = 0; - for (const type of LEGACY_RANK_DATA_TYPES) { - nextMeta[rankDataMetaKey(type)] = 0; - } const josaYi = JosaUtil.pick(general.name, '이'); context.addLog(`${general.name}${josaYi} 은퇴하고 그 자손이 유지를 이어받았습니다.`, { @@ -75,7 +66,32 @@ export class ActionResolver< format: LogFormat.MONTH, }); - tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + const hadPendingRandomUnique = hasPendingRandomUnique(general.meta.inheritRandomUnique); + const acquiredUnique = tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); + const refundedPendingRandomUnique = + hadPendingRandomUnique && !acquiredUnique && !hasPendingRandomUnique(general.meta.inheritRandomUnique); + const postLotterySpentDynamic = general.meta.inherit_spent_dyn; + + // The lottery can consume a pending inheritance reservation and mutate meta. + // Build the reborn projection afterwards so the consumed flag is not restored + // by the action patch while still applying the retirement resets atomically. + const nextMeta = { ...general.meta }; + for (const key of ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const) { + const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0; + nextMeta[key] = Math.round(value * 0.5); + } + delete nextMeta.specAge; + delete nextMeta.specAge2; + nextMeta.specage = 0; + nextMeta.specage2 = 0; + nextMeta.inherit_lived_month = 0; + nextMeta.inherit_active_action = 0; + for (const type of LEGACY_RANK_DATA_TYPES) { + nextMeta[rankDataMetaKey(type)] = 0; + } + if (refundedPendingRandomUnique && typeof postLotterySpentDynamic === 'number') { + nextMeta.inherit_spent_dyn = postLotterySpentDynamic; + } effects.push( createGeneralPatchEffect( diff --git a/packages/logic/src/inheritance/pointCalculation.ts b/packages/logic/src/inheritance/pointCalculation.ts index a9b8b09c..b31f25f2 100644 --- a/packages/logic/src/inheritance/pointCalculation.ts +++ b/packages/logic/src/inheritance/pointCalculation.ts @@ -1,3 +1,5 @@ +import { readCentennialRecordableDexterity, type CentennialDexKey } from '../scenario/centennialAllStar.js'; + export const LEGACY_DEX_INHERITANCE_LIMIT = 1_275_975; export const ALL_MERGED_INHERITANCE_KEYS = [ @@ -38,9 +40,6 @@ export const REBIRTH_INHERITANCE_COEFFICIENTS: Readonly => - typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; - const readNumber = (source: Record, ...keys: string[]): number => { for (const key of keys) { const value = source[key]; @@ -59,17 +58,10 @@ const readStoredPoint = ( storedOverride?: number ): number => storedOverride ?? general.inheritancePoints?.[key] ?? 0; -const readRecordableDexterity = (general: InheritancePointGeneral, key: string): number => { - const value = readNumber(general.meta, key); - const allStar = asRecord(general.meta.event100_allstar); - const granted = readNumber(asRecord(allStar.granted), key); - return Math.max(0, value - Math.min(Math.max(0, value), Math.max(0, granted))); -}; - export const computeDexInheritancePoint = (general: InheritancePointGeneral): number => { let totalDexterity = 0; for (let index = 1; index <= 5; index += 1) { - let dexterity = readRecordableDexterity(general, `dex${index}`); + let dexterity = readCentennialRecordableDexterity(general.meta, `dex${index}` as CentennialDexKey); if (dexterity > LEGACY_DEX_INHERITANCE_LIMIT) { totalDexterity += (dexterity - LEGACY_DEX_INHERITANCE_LIMIT) / 3; dexterity = LEGACY_DEX_INHERITANCE_LIMIT; diff --git a/packages/logic/src/rewards/uniqueLottery.ts b/packages/logic/src/rewards/uniqueLottery.ts index 8a22475a..bc850c8e 100644 --- a/packages/logic/src/rewards/uniqueLottery.ts +++ b/packages/logic/src/rewards/uniqueLottery.ts @@ -44,6 +44,12 @@ export type UniqueLotteryInput = { inheritRandomUnique?: boolean; }; +export type UniqueLotteryOutcome = + | { status: 'NO_SLOT' } + | { status: 'ROLL_FAILED' } + | { status: 'NO_SUPPLY' } + | { status: 'ACQUIRED'; itemKey: string }; + const DEFAULT_MAX_UNIQUE_ITEM_LIMIT: Array<[number, number]> = [ [-1, 1], [3, 2], @@ -183,7 +189,7 @@ export const buildGenericUniqueSeed = ( export const buildVoteUniqueSeed = (hiddenSeed: string | number, voteId: number, generalId: number): string => serializeSeed(hiddenSeed, 'voteUnique', voteId, generalId); -export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => { +export const rollUniqueLotteryDetailed = (input: UniqueLotteryInput): UniqueLotteryOutcome => { const { rng, config, @@ -203,13 +209,13 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => { const resolvedAcquireType = acquireType ?? '아이템'; if (userCount <= 0) { - return null; + return { status: 'ROLL_FAILED' }; } const itemTypes = Object.keys(config.allItems); const itemTypeCnt = itemTypes.length; if (itemTypeCnt <= 0) { - return null; + return { status: 'NO_SLOT' }; } const relYear = currentYear - startYear; @@ -246,7 +252,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => { } if (trialCnt <= 0 || maxCnt <= 0) { - return null; + return { status: 'NO_SLOT' }; } const relMonthByInit = joinYearMonth(currentYear, currentMonth) - joinYearMonth(initYear, initMonth); @@ -289,7 +295,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => { } if (!success) { - return null; + return { status: 'ROLL_FAILED' }; } const availableUnique: Array<[string, number]> = []; @@ -315,10 +321,15 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => { } if (availableUnique.length === 0) { - return null; + return { status: 'NO_SUPPLY' }; } - return rng.choiceUsingWeightPair(availableUnique); + return { status: 'ACQUIRED', itemKey: rng.choiceUsingWeightPair(availableUnique) }; +}; + +export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => { + const outcome = rollUniqueLotteryDetailed(input); + return outcome.status === 'ACQUIRED' ? outcome.itemKey : null; }; const applyUniqueItemGain = ( diff --git a/packages/logic/src/scenario/centennialAllStar.ts b/packages/logic/src/scenario/centennialAllStar.ts index fb0b1d8d..93886c39 100644 --- a/packages/logic/src/scenario/centennialAllStar.ts +++ b/packages/logic/src/scenario/centennialAllStar.ts @@ -20,7 +20,7 @@ const STAT_KEYS = ['leadership', 'strength', 'intel'] as const; const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const; type CentennialStatKey = (typeof STAT_KEYS)[number]; -type CentennialDexKey = (typeof DEX_KEYS)[number]; +export type CentennialDexKey = (typeof DEX_KEYS)[number]; export interface CentennialAllStarTarget { uniqueName: string; @@ -670,3 +670,14 @@ export const reconcileCentennialDexConversion = ( export const centennialRecordableValue = (current: number, granted: number): number => Math.max(0, current - Math.max(0, granted)); + +/** + * Ref CentennialAllStarGrowthService::recordableRawValue. Event-provided + * mastery is useful during the season, but must not enter permanent ranking, + * Hall of Fame, or inheritance records. + */ +export const readCentennialRecordableDexterity = (meta: Record, key: CentennialDexKey): number => { + const current = asNumber(meta[key], 0); + const granted = asNumber(asRecord(asRecord(meta[CENTENNIAL_ALL_STAR_AUX_KEY]).granted)[key], 0); + return centennialRecordableValue(current, granted); +}; diff --git a/packages/logic/test/uniqueLottery.test.ts b/packages/logic/test/uniqueLottery.test.ts index f76f84e5..c1a8dbdb 100644 --- a/packages/logic/test/uniqueLottery.test.ts +++ b/packages/logic/test/uniqueLottery.test.ts @@ -10,6 +10,7 @@ import { countOccupiedUniqueItems, resolveUniqueConfig, rollUniqueLottery, + rollUniqueLotteryDetailed, } from '../src/rewards/uniqueLottery.js'; const buildItem = (key: string, slot: ItemModule['slot'], buyable = false): ItemModule => ({ @@ -118,6 +119,51 @@ describe('unique lottery', () => { expect(result).toBe('itemB'); }); + it('distinguishes no slot, a failed roll, and exhausted supply', () => { + const itemRegistry = buildRegistry(); + const base = { + itemRegistry, + scenarioId: 200, + userCount: 1, + currentYear: 200, + currentMonth: 1, + startYear: 180, + initYear: 180, + initMonth: 1, + }; + + expect( + rollUniqueLotteryDetailed({ + ...base, + rng: new RandUtil(LiteHashDRBG.build('no-slot')), + config: buildConfig(), + generalItems: { horse: null, weapon: 'itemB', book: null, item: null }, + occupiedUniqueCounts: new Map([['itemB', 1]]), + }) + ).toEqual({ status: 'NO_SLOT' }); + + expect( + rollUniqueLotteryDetailed({ + ...base, + rng: new RandUtil(LiteHashDRBG.build('roll-failed')), + config: buildConfig({ uniqueTrialCoef: 0, maxUniqueTrialProb: 0 }), + generalItems: { horse: null, weapon: null, book: null, item: null }, + occupiedUniqueCounts: new Map(), + }) + ).toEqual({ status: 'ROLL_FAILED' }); + + expect( + rollUniqueLotteryDetailed({ + ...base, + rng: new RandUtil(LiteHashDRBG.build('no-supply')), + config: buildConfig(), + generalItems: { horse: null, weapon: null, book: null, item: null }, + occupiedUniqueCounts: new Map([['itemB', 1]]), + acquireType: '건국', + }) + ).toEqual({ status: 'NO_SUPPLY' }); + }); + it('counts only non-buyable equipped items', () => { const itemRegistry = new Map([ ['uniqueItem', buildItem('uniqueItem', 'weapon', false)],