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/battleSimulator/processor.ts b/packages/logic/src/battleSimulator/processor.ts index bb649ab3..260f8057 100644 --- a/packages/logic/src/battleSimulator/processor.ts +++ b/packages/logic/src/battleSimulator/processor.ts @@ -21,7 +21,7 @@ import { compileCrewTypeCatalog } from '../crewType/index.js'; import type { City, General, Nation } from '../domain/entities.js'; import { createInheritBuffModules } from '../inheritance/inheritBuff.js'; import { createItemActionModules, createItemModuleRegistry, ITEM_KEYS, loadItemModules } from '../items/index.js'; -import { formatLogText, LogCategory, LogFormat, LogScope } from '../logging/index.js'; +import { formatLogText, LogCategory, LogFormat, LogScope, type ActionLogger } from '../logging/index.js'; import { createCrewTypeWarTriggerRegistry, resolveDefenderOrder, @@ -344,6 +344,10 @@ const resolveDefenderOrderPayload = (payload: BattleSimJobPayload): number[] => export interface BattleSimProcessorOptions { trace?: (event: WarBattleTraceEvent) => void; rngFactory?: (seed: string) => RandUtil; + /** Comparison-only logger instrumentation; production callers omit it. */ + loggerFactory?: (options: { generalId?: number; nationId?: number }) => ActionLogger; + /** Comparison-only observation of the resolved pure battle outcome. */ + onBattleResolved?: (outcome: WarBattleOutcome) => void; } export const processBattleSimJob = ( @@ -416,9 +420,12 @@ export const processBattleSimJob = ( })), defenderCity, defenderNation, + ...(options.loggerFactory ? { loggerFactory: options.loggerFactory } : {}), ...(options.trace ? { trace: options.trace } : {}), }); + options.onBattleResolved?.(outcome); + lastBattle = outcome; const attackerReport = outcome.reports.find( (report: WarUnitReport) => report.type === 'general' && report.isAttacker 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/items/che_부적_태현청생부.ts b/packages/logic/src/items/che_부적_태현청생부.ts index 1af72822..457747b4 100644 --- a/packages/logic/src/items/che_부적_태현청생부.ts +++ b/packages/logic/src/items/che_부적_태현청생부.ts @@ -1,11 +1,13 @@ import type { GeneralActionContext } from '@sammo-ts/logic/triggers/general.js'; import type { GeneralStatName, WarStatName } from '@sammo-ts/logic/actionModules/types.js'; import type { WarActionContext } from '@sammo-ts/logic/war/actions.js'; -import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; import { che_부적 } from '@sammo-ts/logic/war/triggers/che_부적.js'; +import { che_부상무효 } from '@sammo-ts/logic/war/triggers/che_견고.js'; import type { ItemModule } from './types.js'; const ITEM_KEY = 'che_부적_태현청생부'; +const RAISE_TYPE = BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * 303; export const itemModule: ItemModule = { key: ITEM_KEY, @@ -28,8 +30,12 @@ export const itemModule: ItemModule = { } return value; } as NonNullable, + getBattleInitTriggerList: (context) => { + if (!context.unit) return null; + return new WarTriggerCaller(new che_부상무효(context.unit, RAISE_TYPE), new che_부적(context.unit, RAISE_TYPE)); + }, getBattlePhaseTriggerList: (context) => { if (!context.unit) return null; - return new WarTriggerCaller(new che_부적(context.unit)); + return new WarTriggerCaller(new che_부적(context.unit, RAISE_TYPE)); }, }; diff --git a/packages/logic/src/items/che_진압_박혁론.ts b/packages/logic/src/items/che_진압_박혁론.ts index 749291ea..9e79ef8f 100644 --- a/packages/logic/src/items/che_진압_박혁론.ts +++ b/packages/logic/src/items/che_진압_박혁론.ts @@ -1,9 +1,10 @@ import { che_진압 } from '@sammo-ts/logic/war/triggers/che_진압.js'; -import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; import type { ItemModule } from './types.js'; const ITEM_KEY = 'che_진압_박혁론'; +const RAISE_TYPE = BaseWarUnitTrigger.TYPE_NONE; export const itemModule: ItemModule = { key: ITEM_KEY, @@ -18,6 +19,6 @@ export const itemModule: ItemModule = { unique: false, getBattlePhaseTriggerList: (context) => { if (!context.unit) return null; - return new WarTriggerCaller(new che_진압(context.unit)); + return new WarTriggerCaller(new che_진압(context.unit, RAISE_TYPE)); }, }; diff --git a/packages/logic/src/items/createMedicalItem.ts b/packages/logic/src/items/createMedicalItem.ts index 39d117b2..8fb86b32 100644 --- a/packages/logic/src/items/createMedicalItem.ts +++ b/packages/logic/src/items/createMedicalItem.ts @@ -1,11 +1,19 @@ import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js'; import { CheUisulCityHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_도시치료.js'; -import { triggerModule as medicalWarTriggerModule } from '@sammo-ts/logic/war/triggers/che_의술.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js'; import type { ItemModule } from './types.js'; const INFO = '[군사] 매 턴마다 자신(100%)과 소속 도시 장수(적 포함 50%) 부상 회복
[전투] 페이즈마다 40% 확률로 치료 발동(아군 피해 30% 감소, 부상 회복)'; +const ATTEMPT_DEDUP_TYPE: Record = { + che_의술_상한잡병론: 301, + che_의술_정력견혈산: 302, + che_의술_청낭서: 302, + che_의술_태평청령: 303, +}; + export const createMedicalItem = (key: string, rawName: string): ItemModule => ({ key, rawName, @@ -18,6 +26,15 @@ export const createMedicalItem = (key: string, rawName: string): ItemModule => ( reqSecu: 0, unique: true, getPreTurnExecuteTriggerList: (context) => new GeneralTriggerCaller(new CheUisulCityHealTrigger(context.general)), - getBattlePhaseTriggerList: (context) => - context.unit ? medicalWarTriggerModule.createTriggerList(context.unit) : null, + getBattlePhaseTriggerList: (context) => { + if (!context.unit) { + return null; + } + const attemptRaiseType = + BaseWarUnitTrigger.TYPE_ITEM + BaseWarUnitTrigger.TYPE_DEDUP_TYPE_BASE * (ATTEMPT_DEDUP_TYPE[key] ?? 0); + return new WarTriggerCaller( + new che_의술시도(context.unit, attemptRaiseType), + new che_의술발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM) + ); + }, }); diff --git a/packages/logic/src/items/eventBattleTrait.ts b/packages/logic/src/items/eventBattleTrait.ts index e45fc16d..6cd5cdf7 100644 --- a/packages/logic/src/items/eventBattleTrait.ts +++ b/packages/logic/src/items/eventBattleTrait.ts @@ -1,4 +1,7 @@ import type { TraitModule } from '@sammo-ts/logic/actionModules/traits/types.js'; +import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; +import { che_의술발동, che_의술시도 } from '@sammo-ts/logic/war/triggers/che_의술.js'; +import { che_저격발동, che_저격시도 } from '@sammo-ts/logic/war/triggers/che_저격.js'; import type { ItemModule } from './types.js'; export const createEventBattleTraitItemModule = ( @@ -45,7 +48,25 @@ export const createEventBattleTraitItemModule = ( itemModule.getBattleInitTriggerList = traitModule.getBattleInitTriggerList; } if (traitModule.getBattlePhaseTriggerList) { - itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList; + if (traitModule.key === 'che_저격') { + itemModule.getBattlePhaseTriggerList = (context) => + context.unit + ? new WarTriggerCaller( + new che_저격시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM, 0.5, 20, 40), + new che_저격발동(context.unit, BaseWarUnitTrigger.TYPE_ITEM) + ) + : null; + } else if (traitModule.key === 'che_의술') { + itemModule.getBattlePhaseTriggerList = (context) => + context.unit + ? new WarTriggerCaller( + new che_의술시도(context.unit, BaseWarUnitTrigger.TYPE_ITEM), + new che_의술발동(context.unit) + ) + : null; + } else { + itemModule.getBattlePhaseTriggerList = traitModule.getBattlePhaseTriggerList; + } } if (traitModule.getWarPowerMultiplier) { itemModule.getWarPowerMultiplier = diff --git a/packages/logic/src/items/event_충차.ts b/packages/logic/src/items/event_충차.ts index e7274e5e..250efc56 100644 --- a/packages/logic/src/items/event_충차.ts +++ b/packages/logic/src/items/event_충차.ts @@ -1,5 +1,5 @@ import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; -import { consumeEquippedItemCharge, getEquippedItemInstance } from './inventory.js'; +import { getEquippedItemInstance } from './inventory.js'; import { BaseWarUnitTrigger, WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; import { WarUnitCity, WarUnitGeneral, type WarUnit } from '@sammo-ts/logic/war/units.js'; import type { ItemModule } from './types.js'; @@ -17,6 +17,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger { _selfEnv: Record, _opposeEnv: Record ): boolean { + if (self.hasActivatedSkillOnLog('충차공격') > 0 && self.getPhase() === self.getMaxPhase() - 1) { + if (self instanceof WarUnitGeneral) { + const equipped = getEquippedItemInstance(self.getGeneral(), 'item'); + if (equipped?.itemKey === ITEM_KEY && (equipped.state.charges ?? 0) <= 0) { + this.processConsumableItem(); + } + } + return true; + } if (!(self instanceof WarUnitGeneral) || !(oppose instanceof WarUnitCity)) { return true; } @@ -28,9 +37,15 @@ class EventRamConsumptionTrigger extends BaseWarUnitTrigger { return true; } - self.activateSkill('충차공격', '아이템사용'); self.getLogger().pushGeneralBattleDetailLog('충차로 성벽을 공격합니다.'); - consumeEquippedItemCharge(general, 'item', ITEM_KEY, 2); + self.activateSkill('충차공격'); + const equipped = getEquippedItemInstance(general, 'item'); + if (equipped?.itemKey === ITEM_KEY) { + const remaining = equipped.state.charges ?? 2; + // Ref decrements the purchase-time remain값 at first city contact, + // but only deletes the item in the last battle phase. + equipped.state.charges = remaining - 1; + } return true; } } diff --git a/packages/logic/src/items/index.ts b/packages/logic/src/items/index.ts index 587ec53c..2e443929 100644 --- a/packages/logic/src/items/index.ts +++ b/packages/logic/src/items/index.ts @@ -22,7 +22,7 @@ import { WarTriggerCaller } from '@sammo-ts/logic/war/triggers.js'; import type { WarUnit } from '@sammo-ts/logic/war/units.js'; import type { ItemModule, ItemModuleExport } from './types.js'; import { listEquippedItemKeys } from './utils.js'; -import { removeEquippedItem } from './inventory.js'; +import { registerLegacyBattleItemIdentity, removeEquippedItem } from './inventory.js'; export const ITEM_KEYS = [ 'che_간파_노군입산부', @@ -664,12 +664,17 @@ class ItemWarActionRouter< private resolveModules(context: WarActionContext): Array> { const keys = listEquippedItemKeys(context.general); const modules: Array> = []; + let itemIdentity = { name: '-', rawName: '-' }; for (const key of keys) { const module = this.registry.get(key); if (module) { modules.push(module); + if (module.slot === 'item') { + itemIdentity = { name: module.name, rawName: module.rawName }; + } } } + registerLegacyBattleItemIdentity(context.general, itemIdentity); return modules; } diff --git a/packages/logic/src/items/inventory.ts b/packages/logic/src/items/inventory.ts index 7687b7de..99d3b8ab 100644 --- a/packages/logic/src/items/inventory.ts +++ b/packages/logic/src/items/inventory.ts @@ -12,6 +12,27 @@ import type { const ITEM_SLOTS: GeneralItemSlot[] = ['horse', 'weapon', 'book', 'item']; const INVENTORY_META_KEY = 'itemInventory'; +export interface LegacyBattleItemIdentity { + name: string; + rawName: string; +} + +// Ref's BaseWarUnitTrigger::processConsumableItem() asks General::getItem() +// for the *item-slot* display name even when a weapon trigger raised the +// shared item flag. Keep that transient lookup out of persisted General meta. +const legacyBattleItemIdentities = new WeakMap(); + +export const registerLegacyBattleItemIdentity = ( + general: General, + identity: LegacyBattleItemIdentity +): void => { + legacyBattleItemIdentities.set(general, identity); +}; + +export const getLegacyBattleItemIdentity = ( + general: General +): LegacyBattleItemIdentity => legacyBattleItemIdentities.get(general) ?? { name: '-', rawName: '-' }; + const emptyState = (): GeneralItemInstanceState => ({ values: {} }); const cloneState = (state: GeneralItemInstanceState): GeneralItemInstanceState => ({ @@ -57,9 +78,7 @@ const readState = (value: unknown): GeneralItemInstanceState | null => { return null; } const charges = - typeof record['charges'] === 'number' && Number.isInteger(record['charges']) && record['charges'] >= 0 - ? record['charges'] - : undefined; + typeof record['charges'] === 'number' && Number.isInteger(record['charges']) ? record['charges'] : undefined; const valuesRecord = asRecord(record['values']) ?? {}; const values: Record = {}; for (const [key, entry] of Object.entries(valuesRecord)) { 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/src/war/engine.ts b/packages/logic/src/war/engine.ts index c74db278..86c80bf0 100644 --- a/packages/logic/src/war/engine.ts +++ b/packages/logic/src/war/engine.ts @@ -1,6 +1,6 @@ import { JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; -import type { City, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; +import type { City, General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js'; import { compileCrewTypeCatalog, isCrewTypeWarActionRouter } from '@sammo-ts/logic/crewType/catalog.js'; import { ActionLogger } from '@sammo-ts/logic/logging/actionLogger.js'; import { LogFormat, type LogEntryDraft } from '@sammo-ts/logic/logging/types.js'; @@ -110,6 +110,17 @@ const isSupplyCity = (city: City): boolean => { return city.supplyState > 0; }; +const resolveLegacyTurnHourMinute = (general: General): string => { + const raw = general.meta['turnTime']; + if (typeof raw === 'string' && raw.length >= 16) { + return raw.slice(11, 16); + } + if (general.turnTime instanceof Date && Number.isFinite(general.turnTime.getTime())) { + return general.turnTime.toISOString().slice(11, 16); + } + return '00:00'; +}; + export const computeBattleOrder = ( defender: WarUnit, attacker: WarUnitGeneral @@ -390,7 +401,8 @@ export const resolveWarBattle = (전투시드: ${input.seed})` : ''; + const seedText = input.seed ? `(전투시드: ${input.seed})` : ''; + const turnHourMinute = resolveLegacyTurnHourMinute(attackerUnit.getGeneral()); const josaRo = JosaUtil.pick(cityName, '로'); const josaYi = JosaUtil.pick(attackerName, '이'); @@ -400,7 +412,7 @@ export const resolveWarBattle = ${cityName}${josaRo} 진격합니다.${seedText}`, + `${cityName}${josaRo} 진격합니다.${seedText} <1>${turnHourMinute}`, LogFormat.MONTH ); diff --git a/packages/logic/src/war/triggers.ts b/packages/logic/src/war/triggers.ts index 28abd2c4..e236a072 100644 --- a/packages/logic/src/war/triggers.ts +++ b/packages/logic/src/war/triggers.ts @@ -1,9 +1,10 @@ -import type { RandUtil } from '@sammo-ts/common'; +import { JosaUtil, type RandUtil } from '@sammo-ts/common'; import type { General } from '@sammo-ts/logic/domain/entities.js'; +import { getLegacyBattleItemIdentity, removeEquippedItem } from '@sammo-ts/logic/items/inventory.js'; +import { LogFormat } from '@sammo-ts/logic/logging/types.js'; import { TriggerCaller, type Trigger } from '@sammo-ts/logic/triggers/core.js'; import type { WarUnit } from './units.js'; -import { removeEquippedItem } from '@sammo-ts/logic/items/inventory.js'; export interface WarTriggerContext { rng: RandUtil; @@ -97,12 +98,6 @@ export abstract class BaseWarUnitTrigger implements WarTrigger { return false; } this.unit.activateSkill('아이템사용'); - if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) { - return false; - } - if (this.unit.hasActivatedSkill('아이템소모')) { - return false; - } const unit = this.unit as WarUnit & { getGeneral?: () => General; }; @@ -110,7 +105,18 @@ export abstract class BaseWarUnitTrigger implements WarTrigger { if (!general) { return false; } + const item = getLegacyBattleItemIdentity(general); + this.unit.activateSkill(item.name); + if (this.raiseType !== BaseWarUnitTrigger.TYPE_CONSUMABLE_ITEM) { + return false; + } + if (this.unit.hasActivatedSkill('아이템소모')) { + return false; + } this.unit.activateSkill('아이템소모'); - return removeEquippedItem(general, 'item') !== null; + const josaUl = JosaUtil.pick(item.rawName, '을'); + this.unit.getLogger().pushGeneralActionLog(`${item.name}${josaUl} 사용!`, LogFormat.PLAIN); + removeEquippedItem(general, 'item'); + return true; } } diff --git a/packages/logic/src/war/triggers/che_부적.ts b/packages/logic/src/war/triggers/che_부적.ts index 663e1cfd..86494fc4 100644 --- a/packages/logic/src/war/triggers/che_부적.ts +++ b/packages/logic/src/war/triggers/che_부적.ts @@ -1,13 +1,16 @@ +import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; import { BaseWarUnitTrigger } from '../triggers.js'; import type { WarUnit } from '../units.js'; export class che_부적 extends BaseWarUnitTrigger { constructor(unit: WarUnit, raiseType: number = 0) { - super(unit, 0, raiseType); + super(unit, TriggerPriority.Begin, raiseType); } - protected actionWar(self: WarUnit): boolean { - self.activateSkill('저격불가', '부상무효'); + protected actionWar(_self: WarUnit, oppose: WarUnit): boolean { + // Ref WarActivateSkills(..., isSelf=false): the talisman's owner is + // injury-proof, while the opposing unit is prevented from sniping. + oppose.activateSkill('저격불가'); return true; } } diff --git a/packages/logic/src/war/triggers/che_의술.ts b/packages/logic/src/war/triggers/che_의술.ts index 2ce6e7a2..08d7d58a 100644 --- a/packages/logic/src/war/triggers/che_의술.ts +++ b/packages/logic/src/war/triggers/che_의술.ts @@ -6,8 +6,8 @@ import type { WarTriggerModule } from './types.js'; // 의술: 치료 시도 export class che_의술시도 extends BaseWarUnitTrigger { - constructor(unit: WarUnit) { - super(unit, TriggerPriority.Pre + 350); + constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) { + super(unit, TriggerPriority.Pre + 350, raiseType); } protected actionWar( @@ -36,8 +36,8 @@ export class che_의술시도 extends BaseWarUnitTrigger { // 의술: 치료 발동 export class che_의술발동 extends BaseWarUnitTrigger { - constructor(unit: WarUnit) { - super(unit, TriggerPriority.Post + 550); + constructor(unit: WarUnit, raiseType = BaseWarUnitTrigger.TYPE_NONE) { + super(unit, TriggerPriority.Post + 550, raiseType); } protected actionWar( diff --git a/packages/logic/src/war/triggers/che_저지.ts b/packages/logic/src/war/triggers/che_저지.ts index c724a38a..ecee3c80 100644 --- a/packages/logic/src/war/triggers/che_저지.ts +++ b/packages/logic/src/war/triggers/che_저지.ts @@ -58,7 +58,9 @@ export class che_저지 extends BaseWarUnitTrigger { self.addDex(self.getCrewType(), calcDamage); self.addLevelExp(calcDamage / 50); - let rice = self.calcRiceConsumption(calcDamage); + // Ref calcRiceConsumption() declares an int parameter, so the + // fractional 90% counter-damage is truncated before rice cost. + let rice = self.calcRiceConsumption(Math.trunc(calcDamage)); rice *= 0.25; const general = self.getGeneral(); general.rice = Math.max(0, general.rice - rice); diff --git a/packages/logic/src/war/triggers/che_진압.ts b/packages/logic/src/war/triggers/che_진압.ts index dea97da8..814451ea 100644 --- a/packages/logic/src/war/triggers/che_진압.ts +++ b/packages/logic/src/war/triggers/che_진압.ts @@ -1,13 +1,15 @@ +import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; import { BaseWarUnitTrigger } from '../triggers.js'; import type { WarUnit } from '../units.js'; export class che_진압 extends BaseWarUnitTrigger { constructor(unit: WarUnit, raiseType: number = 0) { - super(unit, 0, raiseType); + super(unit, TriggerPriority.Begin, raiseType); } - protected actionWar(self: WarUnit): boolean { - self.activateSkill('반계불가', '격노불가'); + protected actionWar(_self: WarUnit, oppose: WarUnit): boolean { + // Ref's 진압 is an opposing-unit restriction, not a self debuff. + oppose.activateSkill('반계불가', '격노불가'); return true; } } diff --git a/packages/logic/src/war/units/general.ts b/packages/logic/src/war/units/general.ts index 67feb92c..07fd40fc 100644 --- a/packages/logic/src/war/units/general.ts +++ b/packages/logic/src/war/units/general.ts @@ -192,10 +192,16 @@ export class WarUnitGeneral< return truncate ? Math.trunc(clamped) : clamped; } - private resolveMainStat(armType: number, withInjury = true): number { - const leadership = this.getComputedStat('leadership', this.general.stats.leadership, { withInjury }); - const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury }); - const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, { withInjury }); + private resolveMainStat(armType: number, withInjury = true, truncate = true): number { + const leadership = this.getComputedStat('leadership', this.general.stats.leadership, { + withInjury, + truncate, + }); + const strength = this.getComputedStat('strength', this.general.stats.strength, { withInjury, truncate }); + const intelligence = this.getComputedStat('intelligence', this.general.stats.intelligence, { + withInjury, + truncate, + }); if (armType === this.config.armTypes.wizard) { return intelligence; @@ -279,7 +285,10 @@ export class WarUnitGeneral< return 0; } - const mainStat = this.resolveMainStat(armType, false); + // GameUnitDetail::getCriticalRatio requests each Ref stat with + // useFloor=false, so action bonuses such as 징병's +25% leadership must + // retain their fractional part until after the probability is formed. + const mainStat = this.resolveMainStat(armType, false, false); const coef = armType === this.config.armTypes.wizard || armType === this.config.armTypes.siege || diff --git a/packages/logic/test/itemInventory.test.ts b/packages/logic/test/itemInventory.test.ts index 0561b1cd..d2c478ac 100644 --- a/packages/logic/test/itemInventory.test.ts +++ b/packages/logic/test/itemInventory.test.ts @@ -101,4 +101,13 @@ describe('GeneralItemInventory', () => { values: { source: 'shop' }, }); }); + + it('round-trips the negative remain value produced by the legacy ram lifecycle', () => { + const general = makeGeneral(); + equipNewItem(general, 'item', 'event_충차', { charges: -1 }); + + const parsed = parseItemInventory(serializeItemInventory(general.itemInventory!), general.role.items); + + expect(getEquippedItemInstance({ ...general, itemInventory: parsed }, 'item')?.state.charges).toBe(-1); + }); }); 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)], diff --git a/packages/logic/test/warEngine.test.ts b/packages/logic/test/warEngine.test.ts index 06056ccc..3ac3a3f2 100644 --- a/packages/logic/test/warEngine.test.ts +++ b/packages/logic/test/warEngine.test.ts @@ -167,6 +167,93 @@ const buildGeneral = (strength: number): General => ({ }); describe('war triggers', () => { + it('applies the legacy talisman immunity to self and snipe restriction to the opponent', async () => { + const attacker = buildGeneral(80); + attacker.role.items.item = 'che_부적_태현청생부'; + const defender = { ...buildGeneral(80), id: 2, name: 'Defender' }; + const itemModules = createItemActionModules( + createItemModuleRegistry(await loadItemModules(['che_부적_태현청생부'])) + ).war; + const events: Array<{ + event: string; + attacker: { activatedSkills: Record }; + defender: { activatedSkills: Record } | null; + }> = []; + + resolveWarBattle({ + rng: new RandUtil(new ConstantRNG(0)), + unitSet: buildUnitSet(), + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + attacker: { + general: attacker, + city: buildCity(), + nation: buildNation(), + modules: itemModules, + }, + defenders: [ + { + general: defender, + city: buildCity(), + nation: buildNation(), + modules: itemModules, + }, + ], + defenderCity: buildCity(), + defenderNation: buildNation(), + trace: (event) => events.push(event), + }); + + const initialized = events.find((event) => event.event === 'opponent_initialized'); + expect(initialized?.attacker.activatedSkills).toMatchObject({ 부상무효: 1 }); + expect(initialized?.attacker.activatedSkills).not.toHaveProperty('저격불가'); + expect(initialized?.defender?.activatedSkills).toMatchObject({ 저격불가: 1 }); + expect(initialized?.defender?.activatedSkills).not.toHaveProperty('부상무효'); + }); + + it('applies the legacy suppression restrictions to the opponent', async () => { + const attacker = buildGeneral(80); + attacker.role.items.item = 'che_진압_박혁론'; + const defender = { ...buildGeneral(80), id: 2, name: 'Defender' }; + const itemModules = createItemActionModules( + createItemModuleRegistry(await loadItemModules(['che_진압_박혁론'])) + ).war; + const events: Array<{ + event: string; + attacker: { activatedSkills: Record }; + defender: { activatedSkills: Record } | null; + }> = []; + + resolveWarBattle({ + rng: new RandUtil(new ConstantRNG(0)), + unitSet: buildUnitSet(), + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + attacker: { + general: attacker, + city: buildCity(), + nation: buildNation(), + modules: itemModules, + }, + defenders: [ + { + general: defender, + city: buildCity(), + nation: buildNation(), + modules: itemModules, + }, + ], + defenderCity: buildCity(), + defenderNation: buildNation(), + trace: (event) => events.push(event), + }); + + const phase = events.find((event) => event.event === 'phase_triggered'); + expect(phase?.attacker.activatedSkills).not.toHaveProperty('반계불가'); + expect(phase?.attacker.activatedSkills).not.toHaveProperty('격노불가'); + expect(phase?.defender?.activatedSkills).toMatchObject({ 반계불가: 1, 격노불가: 1 }); + }); + it('passes battle time and maximum tech level to year-scaling stat items', async () => { const general = buildGeneral(80); const [leadershipWine] = await loadItemModules(['che_능력치_통솔_보령압주']); @@ -604,8 +691,14 @@ describe('resolveWarBattle', () => { for (const expectedCharges of [1, null] as const) { general.crew = 5000; general.rice = 10000; - const defenderCity = { ...buildCity(), wall: 3000, wallMax: 3000 }; - resolveWarBattle({ + const defenderCity = { + ...buildCity(), + defence: 100_000, + defenceMax: 100_000, + wall: 3000, + wallMax: 3000, + }; + const outcome = resolveWarBattle({ rng: new RandUtil(new ConstantRNG(0)), unitSet: buildUnitSet(), config: buildConfig(), @@ -625,13 +718,51 @@ describe('resolveWarBattle', () => { if (expectedCharges === null) { expect(equipped).toBeNull(); expect(general.role.items.item).toBeNull(); + expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({ + 충차공격: 1, + 아이템사용: 1, + 충차: 1, + 아이템소모: 1, + }); + expect(outcome.logs.some((entry) => entry.text === '충차를 사용!')).toBe(true); } else { expect(equipped?.state.charges).toBe(expectedCharges); expect(general.role.items.item).toBe('event_충차'); + expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({ 충차공격: 1 }); + expect(outcome.metrics?.attackerActivatedSkills).not.toHaveProperty('아이템사용'); } } }); + it('preserves a negative legacy ram remain value when combat ends before the final phase', async () => { + const general = { ...buildGeneral(100), crew: 5000, rice: 10000 }; + equipNewItem(general, 'item', 'event_충차', { charges: 0 }); + const itemModules = createItemActionModules( + createItemModuleRegistry(await loadItemModules(['event_충차'])) + ).war; + + const outcome = resolveWarBattle({ + rng: new RandUtil(new ConstantRNG(0)), + unitSet: buildUnitSet(), + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + attacker: { + general, + city: buildCity(), + nation: buildNation(), + modules: itemModules, + }, + defenders: [], + defenderCity: { ...buildCity(), defence: 1, defenceMax: 1 }, + defenderNation: buildNation(), + }); + + expect(outcome.conquered).toBe(true); + expect(getEquippedItemInstance(general, 'item')?.state.charges).toBe(-1); + expect(general.role.items.item).toBe('event_충차'); + expect(outcome.metrics?.attackerActivatedSkills).not.toHaveProperty('아이템사용'); + }); + it('removes a one-use battle item through the canonical inventory', async () => { const general = buildGeneral(100); equipNewItem(general, 'item', 'che_저격_수극'); @@ -639,7 +770,7 @@ describe('resolveWarBattle', () => { createItemModuleRegistry(await loadItemModules(['che_저격_수극'])) ).war; - resolveWarBattle({ + const outcome = resolveWarBattle({ rng: new RandUtil(new ConstantRNG(0)), unitSet: buildUnitSet(), config: buildConfig(), @@ -657,6 +788,42 @@ describe('resolveWarBattle', () => { expect(getEquippedItemInstance(general, 'item')).toBeNull(); expect(general.role.items.item).toBeNull(); + expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({ + 아이템사용: 1, + '수극(저격)': 1, + 아이템소모: 1, + }); + expect(outcome.logs.some((entry) => entry.text === '수극(저격)을 사용!')).toBe(true); + }); + + it("keeps Ref's '-' item-name activation when a weapon trigger fires without an item-slot item", async () => { + const general = { ...buildGeneral(100), crew: 5000, rice: 10000 }; + equipNewItem(general, 'weapon', 'che_무기_07_맥궁'); + const itemModules = createItemActionModules( + createItemModuleRegistry(await loadItemModules(['che_무기_07_맥궁'])) + ).war; + + const outcome = resolveWarBattle({ + rng: new RandUtil(new ConstantRNG(0)), + unitSet: buildUnitSet(), + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + attacker: { + general, + city: buildCity(), + nation: buildNation(), + modules: itemModules, + }, + defenders: [], + defenderCity: buildCity(), + defenderNation: buildNation(), + }); + + expect(outcome.metrics?.attackerActivatedSkills).toMatchObject({ + 아이템사용: 1, + '-': 1, + }); + expect(general.role.items.weapon).toBe('che_무기_07_맥궁'); }); it('handles supply rout when defender nation has no rice', () => { diff --git a/tools/integration-tests/fixtures/battle/README.md b/tools/integration-tests/fixtures/battle/README.md new file mode 100644 index 00000000..3f6f1e8d --- /dev/null +++ b/tools/integration-tests/fixtures/battle/README.md @@ -0,0 +1,23 @@ +# Battle differential fixtures + +`basic-infantry.json` is the tracked, deterministic smoke fixture for the Ref ↔ Core battle comparator. Every fixture must explicitly provide a positive integer `city` for the attacker and every defender. The attacker city must equal `attackerCity.city`; each defender city must equal `defenderCity.city`. The runner rejects omitted or inconsistent current-city state before invoking either engine. + +The captured corpus test is intentionally conditional. It is skipped unless `BATTLE_CORPUS_PATH` points to an existing JSONL fixture corpus; this repository does not generate or silently substitute a corpus. Each corpus row is validated by the same city contract. + +Reference execution can use an already instrumented container through `REF_COMPARE_CONTAINER`, or an instrumentation checkout through `REF_COMPARE_SOURCE_ROOT`. Source-root execution creates only a temporary bind-mounted copy. It discovers the single network of the official reference Compose PHP service, or accepts an existing network named by `REF_COMPARE_NETWORK`. Missing or ambiguous networks fail closed. The runner never removes Compose containers, networks, volumes, or databases. + +The Ref trace runner seeds `year`, `month`, and `startyear` only in KVStorage's process-local cache. This is required for legacy battle items that read the game clock from `game_env`; it keeps their fixture time deterministic without writing to the shared reference database. + +Precomputed traces are accepted only when `BATTLE_REFERENCE_TRACE_PATH` is accompanied by `BATTLE_REFERENCE_MANIFEST_PATH`, or by a sibling `.manifest.json`. Manifest schema version 1 requires: + +```json +{ + "schemaVersion": 1, + "fixtureCount": 0, + "fixtureJsonlSha256": "sha256 of normalized fixture JSONL", + "traceCount": 0, + "traceJsonlSha256": "sha256 of the exact trace file bytes" +} +``` + +Counts and hashes must match exactly. Every trace row must also carry `fixtureIdentity.schemaVersion`, the exact fixture `seed`, and the SHA-256 of that fixture row. Missing, reordered, truncated, appended, or stale trace data is rejected. diff --git a/tools/integration-tests/fixtures/battle/basic-infantry.json b/tools/integration-tests/fixtures/battle/basic-infantry.json index 200d42e2..279d3af3 100644 --- a/tools/integration-tests/fixtures/battle/basic-infantry.json +++ b/tools/integration-tests/fixtures/battle/basic-infantry.json @@ -6,7 +6,7 @@ "repeatCnt": 1, "action": "battle", "attackerGeneral": { - "no": 1, "name": "공격자", "nation": 1, "turntime": "2026-01-01 00:00:00", + "no": 1, "name": "공격자", "nation": 1, "city": 1, "turntime": "2026-01-01 00:00:00", "personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100, "atmos": 100, "train": 100, "intel": 70, "intel_exp": 0, "book": "None", "strength": 70, "strength_exp": 0, "weapon": "None", "injury": 0, @@ -28,7 +28,7 @@ "name": "공격국", "gold": 1000, "rice": 10000, "gennum": 1 }, "defenderGenerals": [{ - "no": 2, "name": "수비자", "nation": 2, "turntime": "2026-01-01 00:00:00", + "no": 2, "name": "수비자", "nation": 2, "city": 2, "turntime": "2026-01-01 00:00:00", "personal": "che_안전", "special2": "che_징병", "crew": 1000, "crewtype": 1100, "atmos": 100, "train": 100, "intel": 60, "intel_exp": 0, "book": "None", "strength": 60, "strength_exp": 0, "weapon": "None", "injury": 0, diff --git a/tools/integration-tests/test/battleDifferential.test.ts b/tools/integration-tests/test/battleDifferential.test.ts index 7d22128e..1aaf3580 100644 --- a/tools/integration-tests/test/battleDifferential.test.ts +++ b/tools/integration-tests/test/battleDifferential.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -16,8 +17,16 @@ import { loadNationTraitModules, loadPersonalityTraitModules, loadWarTraitModules, + ActionLogger, + formatLogText, + LogCategory, + LogFormat, + LogScope, + type LogEntryDraft, type UnitSetDefinition, + type WarBattleOutcome, type WarBattleTraceEvent, + type WarBattleTraceUnitSnapshot, type WarEngineConfig, } from '@sammo-ts/logic'; import { describe, expect, it } from 'vitest'; @@ -32,13 +41,19 @@ import type { interface ReferenceTrace { engine: 'ref'; + seed: string; + fixtureIdentity: FixtureIdentity; conquered: boolean; + attacker: WarBattleTraceEvent['attacker']; + city: WarBattleTraceEvent['city']; + finishedDefenders: WarBattleTraceEvent['attacker'][]; defenderOrder?: { before: Array<{ id: number; order: number }>; after: Array<{ id: number; order: number }>; }; events: WarBattleTraceEvent[]; rng: RandomCall[]; + boolRng: BoolRandomCall[]; logs: { attacker: ReferenceLogBuckets; defenders: Record; @@ -46,6 +61,18 @@ interface ReferenceTrace { }; } +interface FixtureIdentity { + schemaVersion: 1; + seed: string; + sha256: string; +} + +interface BoolRandomCall { + rngSeq: number; + probability: number; + result: boolean; +} + interface ReferenceLogBuckets { generalHistoryLog: string[]; generalActionLog: string[]; @@ -56,6 +83,65 @@ interface ReferenceLogBuckets { globalActionLog: string[]; } +interface CapturedCoreLogger { + generalId?: number; + nationId?: number; + entries: LogEntryDraft[]; +} + +interface CoreLogCapture { + loggerFactory: (options: { generalId?: number; nationId?: number }) => ActionLogger; + byGeneralId: Map; + city: CapturedCoreLogger | null; +} + +class ComparisonCapturingActionLogger extends ActionLogger { + public constructor( + options: { generalId?: number; nationId?: number }, + private readonly capture: (entries: LogEntryDraft[]) => void + ) { + super(options); + } + + public override flush(): LogEntryDraft[] { + const entries = super.flush(); + this.capture(entries); + return entries; + } + + public override rollback(): LogEntryDraft[] { + const entries = super.rollback(); + this.capture(entries); + return entries; + } +} + +const createCoreLogCapture = (): CoreLogCapture => { + const capture: CoreLogCapture = { + byGeneralId: new Map(), + city: null, + loggerFactory: () => { + throw new Error('loggerFactory is not initialized'); + }, + }; + capture.loggerFactory = (options) => { + const bucket: CapturedCoreLogger = { ...options, entries: [] }; + if (options.generalId === undefined) { + if (capture.city) { + throw new Error('battle comparison created more than one city logger'); + } + capture.city = bucket; + } else { + if (capture.byGeneralId.has(options.generalId)) { + throw new Error(`battle comparison duplicated general logger ${options.generalId}`); + } + capture.byGeneralId.set(options.generalId, bucket); + } + return new ComparisonCapturingActionLogger(options, (entries) => bucket.entries.push(...entries)); + }; + return capture; +}; + interface RandomCall { seq: number; operation: string; @@ -80,6 +166,7 @@ type ReferenceTraitCatalog = Record< class TracingRng implements RNG { public readonly calls: RandomCall[] = []; + public readonly boolCalls: BoolRandomCall[] = []; public constructor(private readonly inner: RNG) {} @@ -111,6 +198,19 @@ class TracingRng implements RNG { return result; } + public createRandUtil(): RandUtil { + const calls = this.calls; + const boolCalls = this.boolCalls; + return new (class extends RandUtil { + public override nextBool(probability: number = 0.5): boolean { + const rngSeq = calls.length; + const result = super.nextBool(probability); + boolCalls.push({ rngSeq, probability, result }); + return result; + } + })(this); + } + private record(operation: string, args: Record, result: unknown): void { this.calls.push({ seq: this.calls.length, operation, arguments: args, result }); } @@ -135,7 +235,213 @@ const findWorkspaceRoot = (start: string): string | null => { const readJson = (filePath: string): T => JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); + +const assertFixtureGeneralCityContract = (fixtureJson: string, label = 'battle fixture'): FixtureIdentity => { + const normalizedFixtureJson = fixtureJson.trim(); + const fixture = JSON.parse(normalizedFixtureJson) as unknown; + if (!isRecord(fixture)) { + throw new Error(`${label}: fixture root must be an object`); + } + + const assertSide = (side: 'attacker' | 'defender', general: unknown, city: unknown, index?: number): void => { + const suffix = index === undefined ? '' : `[${index}]`; + if (!isRecord(general) || !isRecord(city)) { + throw new Error(`${label}: ${side}${suffix} general/city must be objects`); + } + const generalCity = general['city']; + const currentCity = city['city']; + if (!Number.isSafeInteger(generalCity) || (generalCity as number) <= 0) { + throw new Error(`${label}: ${side}General${suffix}.city must be an explicit positive integer`); + } + if (!Number.isSafeInteger(currentCity) || (currentCity as number) <= 0) { + throw new Error(`${label}: ${side}City.city must be a positive integer`); + } + if (generalCity !== currentCity) { + throw new Error( + `${label}: ${side}General${suffix}.city=${String(generalCity)} must equal current city ${String(currentCity)}` + ); + } + }; + + assertSide('attacker', fixture['attackerGeneral'], fixture['attackerCity']); + const defenderCity = fixture['defenderCity']; + const rawDefenders = fixture['defenderGenerals']; + const defenders = Array.isArray(rawDefenders) + ? rawDefenders + : isRecord(rawDefenders) + ? Object.values(rawDefenders) + : null; + if (!defenders) { + throw new Error(`${label}: defenderGenerals must be an array or ID-keyed object`); + } + defenders.forEach((general, index) => assertSide('defender', general, defenderCity, index)); + + const seed = typeof fixture['seed'] === 'string' ? fixture['seed'] : 'battle-differential'; + return { + schemaVersion: 1, + seed, + sha256: sha256(normalizedFixtureJson), + }; +}; + +const assertReferenceFixtureIdentity = ( + reference: ReferenceTrace, + fixtureJson: string, + label = 'reference trace' +): void => { + const expected = assertFixtureGeneralCityContract(fixtureJson, label); + expect(reference.fixtureIdentity, `${label}: fixture identity`).toEqual(expected); + expect(reference.seed, `${label}: seed`).toBe(expected.seed); +}; + +const referenceRuntimeCopyFilter = (resolvedCompareRoot: string, source: string): boolean => { + const relative = path.relative(resolvedCompareRoot, source); + return !( + relative === '.git' || + relative.startsWith(`.git${path.sep}`) || + relative === 'vendor' || + relative.startsWith(`vendor${path.sep}`) || + relative === 'd_log' || + relative.startsWith(`d_log${path.sep}`) || + relative === path.join('hwe', 'd_setting') || + relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`) + ); +}; + +const assertSafeDockerNetworkName = (network: string): string => { + if (!/^[A-Za-z0-9_.-]+$/u.test(network)) { + throw new Error('Reference Docker network name contains unsupported characters.'); + } + return network; +}; + +const resolveReferenceDockerNetwork = (workspaceRoot: string): string => { + const explicitNetwork = process.env['REF_COMPARE_NETWORK']; + if (explicitNetwork) { + const network = assertSafeDockerNetworkName(explicitNetwork); + try { + const resolved = execFileSync('docker', ['network', 'inspect', '--format', '{{.Name}}', network], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + if (resolved !== network) { + throw new Error('network identity mismatch'); + } + return network; + } catch { + throw new Error('REF_COMPARE_NETWORK does not identify an available Docker network.'); + } + } + + const composeDirectory = path.join(workspaceRoot, 'docker_compose_files/reference'); + try { + const phpContainerId = execFileSync('docker', ['compose', 'ps', '-q', 'php'], { + cwd: composeDirectory, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + if (!phpContainerId || !/^[a-f0-9]+$/u.test(phpContainerId)) { + throw new Error('reference php container is unavailable'); + } + const networks = execFileSync( + 'docker', + [ + 'inspect', + '--format', + '{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}', + phpContainerId, + ], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + } + ) + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .filter(Boolean); + if (networks.length !== 1) { + throw new Error('reference php container must have exactly one discoverable network'); + } + return assertSafeDockerNetworkName(networks[0]!); + } catch { + throw new Error( + 'Unable to discover the official reference Compose network. Start that stack or set REF_COMPARE_NETWORK explicitly.' + ); + } +}; + +const runReferenceSourceScript = (options: { + workspaceRoot: string; + compareSourceRoot: string; + script: string; + args?: string[]; + input?: string; + maxBuffer?: number; +}): string => { + const resolvedCompareRoot = path.resolve(options.compareSourceRoot); + const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-compare-')); + fs.cpSync(resolvedCompareRoot, runtimeRoot, { + recursive: true, + filter: (source) => referenceRuntimeCopyFilter(resolvedCompareRoot, source), + }); + fs.mkdirSync(path.join(runtimeRoot, 'd_log')); + try { + const network = resolveReferenceDockerNetwork(options.workspaceRoot); + try { + return execFileSync( + 'docker', + [ + 'run', + '--rm', + '-i', + '--network', + network, + '-v', + `${runtimeRoot}:/var/www/html`, + '-v', + `${path.join(options.workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`, + '-v', + `${path.join(options.workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`, + 'sam-rebuild-ref-php:8.3', + 'php', + '-d', + 'display_errors=0', + '-d', + 'log_errors=0', + `/var/www/html/${options.script}`, + ...(options.args ?? []), + ], + { + input: options.input, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + ...(options.maxBuffer === undefined ? {} : { maxBuffer: options.maxBuffer }), + } + ); + } catch (error) { + const failure = error as { status?: number | null; stderr?: string | Buffer }; + const stderr = String(failure.stderr ?? '') + .replace(/\s+/gu, ' ') + .trim() + .slice(0, 500); + // Intentionally omit the raw child-process error as the cause: it + // retains prior JSONL stdout and can expose a huge fixture corpus. + // eslint-disable-next-line preserve-caught-error + throw new Error( + `reference comparison script failed (exit ${String(failure.status ?? 'unknown')})${stderr ? `: ${stderr}` : ''}` + ); + } + } finally { + fs.rmSync(runtimeRoot, { recursive: true, force: true }); + } +}; + const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): ReferenceTrace => { + assertFixtureGeneralCityContract(fixtureJson); const compareContainer = process.env.REF_COMPARE_CONTAINER; if (compareContainer) { const stdout = execFileSync( @@ -156,61 +462,22 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc stdio: ['pipe', 'pipe', 'pipe'], } ); - return JSON.parse(stdout) as ReferenceTrace; + const reference = JSON.parse(stdout) as ReferenceTrace; + assertReferenceFixtureIdentity(reference, fixtureJson); + return reference; } const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT; if (compareSourceRoot) { - const resolvedCompareRoot = path.resolve(compareSourceRoot); - const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-')); - fs.cpSync(resolvedCompareRoot, runtimeRoot, { - recursive: true, - filter: (source) => { - const relative = path.relative(resolvedCompareRoot, source); - return !( - relative === '.git' || - relative.startsWith(`.git${path.sep}`) || - relative === 'vendor' || - relative.startsWith(`vendor${path.sep}`) || - relative === 'd_log' || - relative.startsWith(`d_log${path.sep}`) || - relative === path.join('hwe', 'd_setting') || - relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`) - ); - }, + const stdout = runReferenceSourceScript({ + workspaceRoot, + compareSourceRoot, + script: 'hwe/compare/battle_trace.php', + args: ['-'], + input: fixtureJson, }); - fs.mkdirSync(path.join(runtimeRoot, 'd_log')); - try { - const stdout = execFileSync( - 'docker', - [ - 'run', - '--rm', - '-i', - '-v', - `${runtimeRoot}:/var/www/html`, - '-v', - `${path.join(workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`, - '-v', - `${path.join(workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`, - 'sam-rebuild-ref-php:8.3', - 'php', - '-d', - 'display_errors=0', - '-d', - 'log_errors=0', - '/var/www/html/hwe/compare/battle_trace.php', - '-', - ], - { - input: fixtureJson, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - } - ); - return JSON.parse(stdout) as ReferenceTrace; - } finally { - fs.rmSync(runtimeRoot, { recursive: true, force: true }); - } + const reference = JSON.parse(stdout) as ReferenceTrace; + assertReferenceFixtureIdentity(reference, fixtureJson); + return reference; } const stdout = execFileSync( 'docker', @@ -222,21 +489,63 @@ const runReferenceTrace = (workspaceRoot: string, fixtureJson: string): Referenc stdio: ['pipe', 'pipe', 'pipe'], } ); - return JSON.parse(stdout) as ReferenceTrace; + const reference = JSON.parse(stdout) as ReferenceTrace; + assertReferenceFixtureIdentity(reference, fixtureJson); + return reference; +}; + +interface BattleReferenceManifest { + schemaVersion: 1; + fixtureCount: number; + fixtureJsonlSha256: string; + traceCount: number; + traceJsonlSha256: string; +} + +const normalizeJsonlForManifest = (lines: string[]): string => `${lines.map((line) => line.trim()).join('\n')}\n`; + +const readBoundPrecomputedTraces = (tracePath: string, fixtureLines: string[]): ReferenceTrace[] => { + const resolvedTracePath = path.resolve(tracePath); + const rawTraceJsonl = fs.readFileSync(resolvedTracePath, 'utf8'); + const traceLines = rawTraceJsonl.split(/\r?\n/u).filter(Boolean); + const manifestPath = path.resolve( + process.env['BATTLE_REFERENCE_MANIFEST_PATH'] ?? `${resolvedTracePath}.manifest.json` + ); + if (!fs.existsSync(manifestPath)) { + throw new Error( + 'BATTLE_REFERENCE_TRACE_PATH requires BATTLE_REFERENCE_MANIFEST_PATH or a sibling .manifest.json file.' + ); + } + const manifest = readJson(manifestPath); + if (manifest.schemaVersion !== 1) { + throw new Error('Unsupported battle reference manifest schemaVersion.'); + } + if (traceLines.length !== fixtureLines.length) { + throw new Error(`precomputed ref corpus has ${traceLines.length} traces for ${fixtureLines.length} fixtures`); + } + const expectedFixtureJsonl = normalizeJsonlForManifest(fixtureLines); + const checks: Array<[string, unknown, unknown]> = [ + ['fixtureCount', manifest.fixtureCount, fixtureLines.length], + ['traceCount', manifest.traceCount, traceLines.length], + ['fixtureJsonlSha256', manifest.fixtureJsonlSha256, sha256(expectedFixtureJsonl)], + ['traceJsonlSha256', manifest.traceJsonlSha256, sha256(rawTraceJsonl)], + ]; + for (const [label, actual, expected] of checks) { + if (actual !== expected) { + throw new Error(`battle reference manifest ${label} mismatch`); + } + } + + const traces = traceLines.map((line) => JSON.parse(line) as ReferenceTrace); + traces.forEach((trace, index) => assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `trace[${index}]`)); + return traces; }; const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]): ReferenceTrace[] => { + fixtureLines.forEach((line, index) => assertFixtureGeneralCityContract(line, `fixture[${index}]`)); const precomputedTracePath = process.env.BATTLE_REFERENCE_TRACE_PATH; if (precomputedTracePath) { - const traces = fs - .readFileSync(path.resolve(precomputedTracePath), 'utf8') - .split(/\r?\n/u) - .filter(Boolean) - .map((line) => JSON.parse(line) as ReferenceTrace); - if (traces.length < fixtureLines.length) { - throw new Error(`precomputed ref corpus has ${traces.length} traces for ${fixtureLines.length} fixtures`); - } - return traces.slice(0, fixtureLines.length); + return readBoundPrecomputedTraces(precomputedTracePath, fixtureLines); } const compareContainer = process.env.REF_COMPARE_CONTAINER; if (compareContainer) { @@ -259,84 +568,54 @@ const runReferenceTraceBatch = (workspaceRoot: string, fixtureLines: string[]): maxBuffer: 512 * 1024 * 1024, } ); - return stdout + const traces = stdout .split(/\r?\n/u) .filter(Boolean) .map((line) => JSON.parse(line) as ReferenceTrace); + if (traces.length !== fixtureLines.length) { + throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`); + } + traces.forEach((trace, index) => + assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `container trace[${index}]`) + ); + return traces; } const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT; if (!compareSourceRoot) { throw new Error('BATTLE_CORPUS_PATH requires REF_COMPARE_SOURCE_ROOT with the JSONL-capable ref harness.'); } - const resolvedCompareRoot = path.resolve(compareSourceRoot); - const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-ref-battle-corpus-')); - fs.cpSync(resolvedCompareRoot, runtimeRoot, { - recursive: true, - filter: (source) => { - const relative = path.relative(resolvedCompareRoot, source); - return !( - relative === '.git' || - relative.startsWith(`.git${path.sep}`) || - relative === 'vendor' || - relative.startsWith(`vendor${path.sep}`) || - relative === 'd_log' || - relative.startsWith(`d_log${path.sep}`) || - relative === path.join('hwe', 'd_setting') || - relative.startsWith(`${path.join('hwe', 'd_setting')}${path.sep}`) - ); - }, + const stdout = runReferenceSourceScript({ + workspaceRoot, + compareSourceRoot, + script: 'hwe/compare/battle_trace.php', + args: ['--jsonl'], + input: normalizeJsonlForManifest(fixtureLines), + maxBuffer: 512 * 1024 * 1024, }); - fs.mkdirSync(path.join(runtimeRoot, 'd_log')); - try { - const traces: ReferenceTrace[] = []; - const chunkSize = 200; - for (let offset = 0; offset < fixtureLines.length; offset += chunkSize) { - const chunk = fixtureLines.slice(offset, offset + chunkSize); - const stdout = execFileSync( - 'docker', - [ - 'run', - '--rm', - '-i', - '-v', - `${runtimeRoot}:/var/www/html`, - '-v', - `${path.join(workspaceRoot, 'ref/sam/vendor')}:/var/www/html/vendor:ro`, - '-v', - `${path.join(workspaceRoot, 'ref/sam/hwe/d_setting')}:/var/www/html/hwe/d_setting:ro`, - 'sam-rebuild-ref-php:8.3', - 'php', - '-d', - 'display_errors=0', - '-d', - 'log_errors=0', - '/var/www/html/hwe/compare/battle_trace.php', - '--jsonl', - ], - { - input: `${chunk.join('\n')}\n`, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - maxBuffer: 512 * 1024 * 1024, - } - ); - traces.push( - ...stdout - .split(/\r?\n/u) - .filter(Boolean) - .map((line) => JSON.parse(line) as ReferenceTrace) - ); - } - if (traces.length !== fixtureLines.length) { - throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`); - } - return traces; - } finally { - fs.rmSync(runtimeRoot, { recursive: true, force: true }); + const traces = stdout + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line) as ReferenceTrace); + if (traces.length !== fixtureLines.length) { + throw new Error(`ref batch returned ${traces.length} traces for ${fixtureLines.length} fixtures`); } + traces.forEach((trace, index) => + assertReferenceFixtureIdentity(trace, fixtureLines[index]!, `source trace[${index}]`) + ); + return traces; }; const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Record => { + const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT; + if (compareSourceRoot) { + const stdout = runReferenceSourceScript({ + workspaceRoot, + compareSourceRoot, + script: 'hwe/compare/item_catalog.php', + input: JSON.stringify(itemKeys), + }); + return JSON.parse(stdout) as Record; + } const stdout = execFileSync( 'docker', ['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/item_catalog.php'], @@ -351,6 +630,15 @@ const runReferenceItemCatalog = (workspaceRoot: string, itemKeys: string[]): Rec }; const runReferenceTraitCatalog = (workspaceRoot: string): ReferenceTraitCatalog => { + const compareSourceRoot = process.env.REF_COMPARE_SOURCE_ROOT; + if (compareSourceRoot) { + const stdout = runReferenceSourceScript({ + workspaceRoot, + compareSourceRoot, + script: 'hwe/compare/trait_catalog.php', + }); + return JSON.parse(stdout) as ReferenceTraitCatalog; + } const stdout = execFileSync( 'docker', ['compose', 'exec', '-T', 'php', 'php', '/var/www/html/hwe/compare/trait_catalog.php'], @@ -366,45 +654,153 @@ const runReferenceTraitCatalog = (workspaceRoot: string): ReferenceTraitCatalog const expectNearlyEqual = (actual: unknown, expected: unknown, label: string): void => { expect(typeof actual, `${label}: actual type`).toBe('number'); expect(typeof expected, `${label}: reference type`).toBe('number'); - if (process.env['STRICT_BATTLE_PARITY'] === '1') { - expect(actual, `${label}: exact battle parity`).toBe(expected); + const actualNumber = actual as number; + const expectedNumber = expected as number; + expect(Number.isFinite(actualNumber), `${label}: actual must be finite`).toBe(true); + expect(Number.isFinite(expectedNumber), `${label}: reference must be finite`).toBe(true); + if (Number.isSafeInteger(actualNumber) && Number.isSafeInteger(expectedNumber)) { + expect(actualNumber, `${label}: integer battle parity`).toBe(expectedNumber); return; } - const reference = expected as number; - const configuredRelativeTolerance = Number.parseFloat( - process.env['BATTLE_TRACE_RELATIVE_TOLERANCE'] ?? '0.01' - ); - if (!Number.isFinite(configuredRelativeTolerance) || configuredRelativeTolerance < 0) { - throw new Error('BATTLE_TRACE_RELATIVE_TOLERANCE must be a non-negative finite number'); - } const tolerance = Math.max( - Number.EPSILON * Math.max(1, Math.abs(reference)) * 8, - Math.abs(reference) * configuredRelativeTolerance + Number.EPSILON * Math.max(1, Math.abs(expectedNumber)) * 16, + Math.abs(expectedNumber) * 1e-12, + 1e-12 ); expect( - Math.abs((actual as number) - reference), - `${label}: core=${String(actual)}, ref=${String(expected)}, tolerance=${tolerance}` + Math.abs(actualNumber - expectedNumber), + `${label}: core=${String(actualNumber)}, ref=${String(expectedNumber)}, tolerance=${tolerance}` ).toBeLessThanOrEqual(tolerance); }; +const isCanonicalEmptyMapPath = (label: string): boolean => + label.endsWith('.activatedSkills') || label.endsWith('.details'); + +const normalizeCanonicalEmptyMap = (value: unknown, label: string): unknown => { + if (isCanonicalEmptyMapPath(label) && Array.isArray(value) && value.length === 0) { + return {}; + } + return value; +}; + +const assertCanonicalValue = (rawActual: unknown, rawExpected: unknown, label: string): void => { + const actual = normalizeCanonicalEmptyMap(rawActual, label); + const expected = normalizeCanonicalEmptyMap(rawExpected, label); + if (typeof actual === 'number' || typeof expected === 'number') { + expectNearlyEqual(actual, expected, label); + return; + } + if (Array.isArray(actual) || Array.isArray(expected)) { + expect(Array.isArray(actual), `${label}: core array type`).toBe(true); + expect(Array.isArray(expected), `${label}: ref array type`).toBe(true); + const actualArray = actual as unknown[]; + const expectedArray = expected as unknown[]; + expect(actualArray.length, `${label}: array length`).toBe(expectedArray.length); + for (let index = 0; index < expectedArray.length; index += 1) { + assertCanonicalValue(actualArray[index], expectedArray[index], `${label}[${index}]`); + } + return; + } + if (isRecord(actual) || isRecord(expected)) { + expect(isRecord(actual), `${label}: core object type`).toBe(true); + expect(isRecord(expected), `${label}: ref object type`).toBe(true); + const actualObject = actual as Record; + const expectedObject = expected as Record; + const actualKeys = Object.keys(actualObject) + .filter((key) => actualObject[key] !== undefined) + .sort(); + const expectedKeys = Object.keys(expectedObject) + .filter((key) => expectedObject[key] !== undefined) + .sort(); + expect(actualKeys, `${label}: object keys`).toEqual(expectedKeys); + for (const key of expectedKeys) { + assertCanonicalValue(actualObject[key], expectedObject[key], `${label}.${key}`); + } + return; + } + expect(actual, label).toBe(expected); +}; + +const buildCapturedLogBuckets = ( + capture: CapturedCoreLogger | null | undefined, + year: number, + month: number +): ReferenceLogBuckets => { + const buckets: ReferenceLogBuckets = { + generalHistoryLog: [], + generalActionLog: [], + generalBattleResultLog: [], + generalBattleDetailLog: [], + nationalHistoryLog: [], + globalHistoryLog: [], + globalActionLog: [], + }; + for (const entry of capture?.entries ?? []) { + const text = formatLogText(entry.text, entry.format ?? LogFormat.RAWTEXT, year, month); + if (entry.scope === LogScope.GENERAL) { + switch (entry.category) { + case LogCategory.HISTORY: + buckets.generalHistoryLog.push(text); + break; + case LogCategory.ACTION: + buckets.generalActionLog.push(text); + break; + case LogCategory.BATTLE_BRIEF: + buckets.generalBattleResultLog.push(text); + break; + case LogCategory.BATTLE_DETAIL: + buckets.generalBattleDetailLog.push(text); + break; + default: + break; + } + } else if (entry.scope === LogScope.NATION && entry.category === LogCategory.HISTORY) { + buckets.nationalHistoryLog.push(text); + } else if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.HISTORY) { + buckets.globalHistoryLog.push(text); + } else if (entry.scope === LogScope.SYSTEM && entry.category === LogCategory.SUMMARY) { + buckets.globalActionLog.push(text); + } + } + return buckets; +}; + +const assertAllLogBucketsParity = ( + capture: CoreLogCapture, + reference: ReferenceTrace, + fixture: BattleSimRequestPayload, + label: string +): void => { + assertCanonicalValue( + buildCapturedLogBuckets(capture.byGeneralId.get(fixture.attackerGeneral.no), fixture.year, fixture.month), + reference.logs.attacker, + `${label}.logs.attacker` + ); + for (const defender of fixture.defenderGenerals) { + assertCanonicalValue( + buildCapturedLogBuckets(capture.byGeneralId.get(defender.no), fixture.year, fixture.month), + reference.logs.defenders[String(defender.no)] ?? { + generalHistoryLog: [], + generalActionLog: [], + generalBattleResultLog: [], + generalBattleDetailLog: [], + nationalHistoryLog: [], + globalHistoryLog: [], + globalActionLog: [], + }, + `${label}.logs.defenders.${defender.no}` + ); + } + assertCanonicalValue( + buildCapturedLogBuckets(capture.city, fixture.year, fixture.month), + reference.logs.city, + `${label}.logs.city` + ); +}; + const normalizeRandomArguments = (value: Record): Record => Array.isArray(value) && value.length === 0 ? {} : value; -const describeSequenceDifference = (label: string, actual: unknown[], expected: unknown[]): string | null => { - const commonLength = Math.min(actual.length, expected.length); - for (let index = 0; index < commonLength; index += 1) { - if (JSON.stringify(actual[index]) !== JSON.stringify(expected[index])) { - const start = Math.max(0, index - 2); - const end = index + 3; - return `${label}[${index}]: core=${JSON.stringify(actual.slice(start, end))} ref=${JSON.stringify(expected.slice(start, end))}`; - } - } - if (actual.length !== expected.length) { - return `${label} length: core=${actual.length} ref=${expected.length}`; - } - return null; -}; - const describeTextDifference = (actual: string | undefined, expected: string): string => { const actualText = actual ?? ''; let index = 0; @@ -448,19 +844,23 @@ const assertRngParity = (reference: ReferenceTrace, coreRng: TracingRng | null): arguments: normalizeRandomArguments(args), result, })); - const rngDifference = describeSequenceDifference('rng', normalizedCoreRng, normalizedReferenceRng); - if (rngDifference) { - throw new Error(rngDifference); - } + assertCanonicalValue(normalizedCoreRng, normalizedReferenceRng, 'rng'); + assertCanonicalValue(coreRng?.boolCalls ?? [], reference.boolRng, 'boolRng'); }; const assertTraceParity = ( coreEvents: WarBattleTraceEvent[], reference: ReferenceTrace, - coreRng: TracingRng | null + coreRng: TracingRng | null, + coreOutcome: WarBattleOutcome | null ): void => { const defenderOrderEvent = coreEvents[0]?.event === 'defender_order' ? coreEvents[0] : null; - const comparableCoreEvents = defenderOrderEvent ? coreEvents.slice(1) : coreEvents; + const comparableCoreEvents = (defenderOrderEvent ? coreEvents.slice(1) : coreEvents).map((event, seq) => ({ + ...event, + // Core emits one comparison-only defender_order event before the Ref + // processWar_NG sequence. Renumber only the canonical shared sequence. + seq, + })); if (reference.defenderOrder) { // Ref retains non-participating (order <= 0) defenders at the tail and // stops when it reaches them. Core discards them before sorting. The @@ -470,12 +870,14 @@ const assertTraceParity = ( after: reference.defenderOrder.after.filter(({ order }) => order > 0), }; const coreOrder = defenderOrderEvent?.details as typeof effectiveReferenceOrder | undefined; - expect(coreOrder?.before.map(({ id }) => id), 'defender order before IDs').toEqual( - effectiveReferenceOrder.before.map(({ id }) => id) - ); - expect(coreOrder?.after.map(({ id }) => id), 'defender order after IDs').toEqual( - effectiveReferenceOrder.after.map(({ id }) => id) - ); + expect( + coreOrder?.before.map(({ id }) => id), + 'defender order before IDs' + ).toEqual(effectiveReferenceOrder.before.map(({ id }) => id)); + expect( + coreOrder?.after.map(({ id }) => id), + 'defender order after IDs' + ).toEqual(effectiveReferenceOrder.after.map(({ id }) => id)); for (const side of ['before', 'after'] as const) { for (let index = 0; index < effectiveReferenceOrder[side].length; index += 1) { expectNearlyEqual( @@ -487,37 +889,145 @@ const assertTraceParity = ( } } assertRngParity(reference, coreRng); - const coreEventNames = comparableCoreEvents.map((event) => event.event); - const referenceEventNames = reference.events.map((event) => event.event); - expect( - coreEventNames, - `event sequence\ncore=${JSON.stringify(coreEventNames)}\nref=${JSON.stringify(referenceEventNames)}` - ).toEqual(referenceEventNames); + assertCanonicalValue(comparableCoreEvents, reference.events, 'events'); + assertFinalOutcomeParity(coreOutcome, coreEvents, reference); +}; - for (let index = 0; index < reference.events.length; index += 1) { - const core = comparableCoreEvents[index]!; - const ref = reference.events[index]!; - expectNearlyEqual(core.attacker.hp, ref.attacker.hp, `event ${index} attacker.hp`); - expectNearlyEqual(core.attacker.warPower, ref.attacker.warPower, `event ${index} attacker.warPower`); - expect(core.attacker.phase, `event ${index} attacker.phase`).toBe(ref.attacker.phase); - expect(core.attacker.realPhase, `event ${index} attacker.realPhase`).toBe(ref.attacker.realPhase); - expect(core.attacker.maxPhase, `event ${index} attacker.maxPhase`).toBe(ref.attacker.maxPhase); - if (core.defender && ref.defender) { - expect(core.defender.kind, `event ${index} defender.kind`).toBe(ref.defender.kind); - expectNearlyEqual(core.defender.hp, ref.defender.hp, `event ${index} defender.hp`); - expectNearlyEqual(core.defender.warPower, ref.defender.warPower, `event ${index} defender.warPower`); - expect(core.defender.phase, `event ${index} defender.phase`).toBe(ref.defender.phase); - expect(core.defender.realPhase, `event ${index} defender.realPhase`).toBe(ref.defender.realPhase); - expect(core.defender.maxPhase, `event ${index} defender.maxPhase`).toBe(ref.defender.maxPhase); - } else { - expect(core.defender, `event ${index} defender presence`).toBe(ref.defender); - } - if (core.event === 'phase_damage') { - for (const key of ['rawDeadAttacker', 'rawDeadDefender', 'deadAttacker', 'deadDefender']) { - expectNearlyEqual(core.details[key], ref.details[key], `event ${index} ${key}`); - } +const outcomeMetaNumber = (general: WarBattleOutcome['attacker'], key: string): number => { + const value = general.meta[key]; + return typeof value === 'number' ? value : 0; +}; + +const buildOutcomeGeneralSnapshot = ( + transient: WarBattleTraceUnitSnapshot, + general: WarBattleOutcome['attacker'], + report: WarBattleOutcome['reports'][number], + activatedSkills: Record +): WarBattleTraceUnitSnapshot => ({ + ...transient, + kind: 'general', + id: general.id, + name: general.name, + isAttacker: report.isAttacker, + crewTypeId: general.crewTypeId, + phase: report.phase ?? transient.phase, + hp: general.crew, + killed: report.killed, + dead: report.dead, + activatedSkills, + general: { + crew: general.crew, + rice: general.rice, + train: general.train, + atmos: general.atmos, + injury: general.injury, + experience: general.experience, + dedication: general.dedication, + dex1: outcomeMetaNumber(general, 'dex1'), + dex2: outcomeMetaNumber(general, 'dex2'), + dex3: outcomeMetaNumber(general, 'dex3'), + dex4: outcomeMetaNumber(general, 'dex4'), + dex5: outcomeMetaNumber(general, 'dex5'), + }, +}); + +const assertFinalOutcomeParity = ( + coreOutcome: WarBattleOutcome | null, + coreEvents: WarBattleTraceEvent[], + reference: ReferenceTrace +): void => { + expect(coreOutcome, 'comparison onBattleResolved callback').not.toBeNull(); + if (!coreOutcome) { + return; + } + const finalEvent = coreEvents.at(-1); + expect(finalEvent?.event, 'final battle trace event').toBe('battle_end'); + if (!finalEvent) { + return; + } + + const attackerReport = coreOutcome.reports.find( + (report) => report.type === 'general' && report.id === coreOutcome.attacker.id && report.isAttacker + ); + const cityReport = coreOutcome.reports.find( + (report) => report.type === 'city' && report.id === coreOutcome.defenderCity.id + ); + expect(attackerReport, 'final attacker report').toBeDefined(); + expect(cityReport, 'final city report').toBeDefined(); + if (!attackerReport || !cityReport) { + return; + } + + const latestDefenderSnapshots = new Map(); + for (const event of coreEvents) { + if (event.defender?.kind === 'general') { + latestDefenderSnapshots.set(event.defender.id, event.defender); } } + const metrics = coreOutcome.metrics; + const coreAttacker = buildOutcomeGeneralSnapshot( + finalEvent.attacker, + coreOutcome.attacker, + attackerReport, + metrics?.attackerActivatedSkills ?? {} + ); + const coreCity: WarBattleTraceUnitSnapshot = { + ...finalEvent.city, + kind: 'city', + id: coreOutcome.defenderCity.id, + name: coreOutcome.defenderCity.name, + isAttacker: cityReport.isAttacker, + phase: cityReport.phase ?? finalEvent.city.phase, + killed: cityReport.killed, + dead: cityReport.dead, + cityState: { + defence: coreOutcome.defenderCity.defence, + wall: coreOutcome.defenderCity.wall, + population: coreOutcome.defenderCity.population, + }, + }; + const coreFinishedDefenders = reference.finishedDefenders.map((expectedSnapshot) => { + if (expectedSnapshot.kind === 'city') { + return coreCity; + } + const defenderIndex = coreOutcome.defenders.findIndex((general) => general.id === expectedSnapshot.id); + expect(defenderIndex, `final defender ${expectedSnapshot.id} exists`).toBeGreaterThanOrEqual(0); + const general = coreOutcome.defenders[defenderIndex]; + const orderedDefenderReports = coreOutcome.reports.filter( + (candidate) => candidate.type === 'general' && !candidate.isAttacker + ); + const metricIndex = orderedDefenderReports.findIndex((candidate) => candidate.id === expectedSnapshot.id); + const report = metricIndex >= 0 ? orderedDefenderReports[metricIndex] : undefined; + const transient = latestDefenderSnapshots.get(expectedSnapshot.id); + expect(general, `final defender ${expectedSnapshot.id} state`).toBeDefined(); + expect(report, `final defender ${expectedSnapshot.id} report`).toBeDefined(); + expect(transient, `final defender ${expectedSnapshot.id} transient snapshot`).toBeDefined(); + if (!general || !report || !transient) { + return expectedSnapshot; + } + return buildOutcomeGeneralSnapshot( + transient, + general, + report, + metrics?.defenderActivatedSkills[metricIndex] ?? {} + ); + }); + + assertCanonicalValue( + { + conquered: coreOutcome.conquered, + attacker: coreAttacker, + city: coreCity, + finishedDefenders: coreFinishedDefenders, + }, + { + conquered: reference.conquered, + attacker: reference.attacker, + city: reference.city, + finishedDefenders: reference.finishedDefenders, + }, + 'finalOutcome' + ); }; const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; @@ -533,8 +1043,27 @@ const battleCorpusPath = process.env.BATTLE_CORPUS_PATH; const itWithBattleCorpus = battleCorpusPath ? it : it.skip; describeWithReference('ref ↔ core2026 battle differential', () => { + it('rejects battle fixtures whose general current-city contract is missing or inconsistent', () => { + const fixture = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + const missingCity = structuredClone(fixture) as BattleSimRequestPayload & { + attackerGeneral: BattleSimGeneralPayload & { city?: number }; + }; + delete missingCity.attackerGeneral.city; + expect(() => assertFixtureGeneralCityContract(JSON.stringify(missingCity), 'missing-city')).toThrow( + 'attackerGeneral.city must be an explicit positive integer' + ); + + const wrongDefenderCity = structuredClone(fixture); + wrongDefenderCity.defenderGenerals[0]!.city = fixture.attackerCity.city; + expect(() => assertFixtureGeneralCityContract(JSON.stringify(wrongDefenderCity), 'wrong-city')).toThrow( + 'defenderGeneral[0].city=1 must equal current city 2' + ); + }); + itWithBattleCorpus( - 'replays a captured battle corpus with matching trace, RNG, skills, outcome, and attacker logs', + 'replays a captured battle corpus with matching trace, RNG, full outcome, and all log buckets [conditional: BATTLE_CORPUS_PATH]', { timeout: 600_000 }, () => { const requestedLimit = Number.parseInt(process.env.BATTLE_CORPUS_LIMIT ?? '', 10); @@ -560,7 +1089,12 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }; const failures: string[] = []; const categoryCounts = new Map(); - const recordFailure = (category: string, index: number, fixture: BattleSimRequestPayload, detail: string) => { + const recordFailure = ( + category: string, + index: number, + fixture: BattleSimRequestPayload, + detail: string + ) => { categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1); if (failures.length < 40) { failures.push( @@ -593,7 +1127,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }; const reference = referenceTraces[index]!; const coreEvents: WarBattleTraceEvent[] = []; + const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; const coreResult = processBattleSimJob( { ...fixture, @@ -604,50 +1140,27 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }, { trace: (event) => coreEvents.push(event), + loggerFactory: coreLogs.loggerFactory, + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, } ); try { - assertTraceParity(coreEvents, reference, coreRng); + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); } catch (error) { recordFailure('trace', index, fixture, error instanceof Error ? error.message : String(error)); } - const finalReference = reference.events.at(-1); - if (finalReference) { - const outcome = { - phase: coreResult.phase, - killed: coreResult.killed, - dead: coreResult.dead, - }; - const expectedOutcome = { - phase: finalReference.attacker.phase, - killed: finalReference.attacker.killed, - dead: finalReference.attacker.dead, - }; - if (JSON.stringify(outcome) !== JSON.stringify(expectedOutcome)) { - recordFailure( - 'outcome', - index, - fixture, - `core=${JSON.stringify(outcome)} ref=${JSON.stringify(expectedOutcome)}` - ); - } - const coreSkills = coreResult.attackerSkills ?? {}; - const rawReferenceSkills = finalReference.attacker.activatedSkills; - const referenceSkills = Array.isArray(rawReferenceSkills) ? {} : (rawReferenceSkills ?? {}); - if (JSON.stringify(coreSkills) !== JSON.stringify(referenceSkills)) { - recordFailure( - 'skills', - index, - fixture, - `core=${JSON.stringify(coreSkills)} ref=${JSON.stringify(referenceSkills)}` - ); - } + try { + assertAllLogBucketsParity(coreLogs, reference, fixture, `fixture[${index}]`); + } catch (error) { + recordFailure('logs', index, fixture, error instanceof Error ? error.message : String(error)); } const expectedBrief = convertLog(reference.logs.attacker.generalBattleResultLog.join('
')); @@ -734,6 +1247,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => { const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; const coreResult = processBattleSimJob( { ...base, @@ -744,16 +1258,19 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }, { trace: (event) => coreEvents.push(event), + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, } ); try { const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); - assertTraceParity(coreEvents, reference, coreRng); + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); const opponentSwitches = coreEvents.filter((event) => event.event === 'opponent_switched'); if (entry.directCity) { expect( @@ -792,7 +1309,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => { '●아군의 전멸에 상대의 진격이 이어집니다!' ); expect(coreResult.lastWarLog?.generalBattleDetailLog).toContain( - '적군의 전멸에 진격이 이어집니다!' + '적군의 전멸에 진격이 이어집니다!' ); } } catch (error) { @@ -874,17 +1391,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => { base.attackerGeneral.strength = 85; base.attackerGeneral.intel = 80; base.attackerGeneral.special = - entry.kind === 'dualSlot' - ? entry.special - : entry.kind === 'eventDomestic' - ? entry.key - : 'None'; + entry.kind === 'dualSlot' ? entry.special : entry.kind === 'eventDomestic' ? entry.key : 'None'; base.attackerGeneral.special2 = - entry.kind === 'dualSlot' - ? entry.special2 - : entry.kind === 'war' - ? entry.key - : 'None'; + entry.kind === 'dualSlot' ? entry.special2 : entry.kind === 'war' ? entry.key : 'None'; base.attackerGeneral.personal = entry.kind === 'personality' ? entry.key : 'None'; if (entry.kind === 'nation') { base.attackerNation.type = entry.key; @@ -892,6 +1401,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => { const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; processBattleSimJob( { ...base, @@ -901,14 +1411,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }, { trace: (event) => coreEvents.push(event), + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, } ); try { - assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng); + assertTraceParity( + coreEvents, + runReferenceTrace(workspaceRoot!, JSON.stringify(base)), + coreRng, + coreOutcome + ); } catch (error) { throw new Error( `${entry.kind}/${entry.key}: ${error instanceof Error ? error.message : String(error)}`, @@ -989,7 +1507,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => { armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 }, }; const coreEvents: WarBattleTraceEvent[] = []; + const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; processBattleSimJob( { ...base, @@ -999,13 +1519,45 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }, { trace: (event) => coreEvents.push(event), + loggerFactory: coreLogs.loggerFactory, + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, } ); - assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng); + const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); + assertAllLogBucketsParity(coreLogs, reference, base, 'trait-item.non-stacking-musang'); + + const runFirstPhasePower = (fixture: BattleSimRequestPayload & { startYear: number }): number => { + const events: WarBattleTraceEvent[] = []; + processBattleSimJob( + { + ...fixture, + unitSet, + config, + time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear }, + }, + { trace: (event) => events.push(event) } + ); + const firstPhase = events.find((event) => event.event === 'phase_power'); + expect(firstPhase, '무쌍 first phase power').toBeDefined(); + return firstPhase!.attacker.rawWarPower; + }; + const combinedPower = coreEvents.find((event) => event.event === 'phase_power')!.attacker.rawWarPower; + const traitOnly = structuredClone(base); + traitOnly.attackerGeneral.item = 'None'; + const itemOnly = structuredClone(base); + itemOnly.attackerGeneral.special2 = 'None'; + const control = structuredClone(itemOnly); + control.attackerGeneral.item = 'None'; + expect(combinedPower, 'duplicate 무쌍 does not stack over trait').toBe(runFirstPhasePower(traitOnly)); + expect(combinedPower, 'duplicate 무쌍 does not stack over item').toBe(runFirstPhasePower(itemOnly)); + expect(combinedPower, '무쌍 has a real battle effect').not.toBe(runFirstPhasePower(control)); }); it('matches 척사 items against region-restricted troops', () => { @@ -1033,7 +1585,9 @@ describeWithReference('ref ↔ core2026 battle differential', () => { base.attackerGeneral.crew = 5000; base.defenderGenerals[0]!.crewtype = 1101; const coreEvents: WarBattleTraceEvent[] = []; + const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; processBattleSimJob( { ...base, @@ -1043,17 +1597,39 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }, { trace: (event) => coreEvents.push(event), + loggerFactory: coreLogs.loggerFactory, + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, } ); - assertTraceParity(coreEvents, runReferenceTrace(workspaceRoot!, JSON.stringify(base)), coreRng); + const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); + assertAllLogBucketsParity(coreLogs, reference, base, `item.${itemKey}.region-opponent`); + + const control = structuredClone(base); + control.attackerGeneral.item = 'None'; + const controlEvents: WarBattleTraceEvent[] = []; + processBattleSimJob( + { + ...control, + unitSet, + config, + time: { year: control.year, month: control.month, startYear: control.startYear }, + }, + { trace: (event) => controlEvents.push(event) } + ); + const itemPower = coreEvents.find((event) => event.event === 'phase_power')?.attacker.rawWarPower; + const controlPower = controlEvents.find((event) => event.event === 'phase_power')?.attacker.rawWarPower; + expect(itemPower, `${itemKey}: region troop effect is observed`).not.toBe(controlPower); } }); - it('keeps the detailed event sequence and phase values within 1%', () => { + it('matches the complete canonical event, RNG, state, and logger snapshots', () => { const fixturePath = path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json'); const fixtureJson = fs.readFileSync(fixturePath, 'utf8'); const request = JSON.parse(fixtureJson) as BattleSimRequestPayload & { startYear: number }; @@ -1086,18 +1662,271 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }; const coreEvents: WarBattleTraceEvent[] = []; + const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; const coreResult = processBattleSimJob(payload, { trace: (event) => coreEvents.push(event), + loggerFactory: coreLogs.loggerFactory, + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, }); const reference = runReferenceTrace(workspaceRoot!, fixtureJson); expect(coreResult.result).toBe(true); - assertTraceParity(coreEvents, reference, coreRng); + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); + assertAllLogBucketsParity(coreLogs, reference, request, 'basic-infantry'); + }); + + it('matches officer levels 1-4 in assigned and off-city battles on both sides', () => { + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + const config: WarEngineConfig = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId: 1000, + armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 }, + }; + const cases: Array<{ + role: 'attacker' | 'defender'; + level: number; + assigned: boolean; + fixture: BattleSimRequestPayload & { startYear: number }; + }> = []; + for (const role of ['attacker', 'defender'] as const) { + for (const level of [1, 2, 3, 4]) { + for (const assigned of [true, false]) { + const fixture = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + fixture.seed = `battle-differential-officer-${role}-${level}-${assigned ? 'assigned' : 'off-city'}`; + const general = role === 'attacker' ? fixture.attackerGeneral : fixture.defenderGenerals[0]!; + const counterpart = role === 'attacker' ? fixture.defenderGenerals[0]! : fixture.attackerGeneral; + const currentCity = role === 'attacker' ? fixture.attackerCity.city : fixture.defenderCity.city; + const counterpartCity = role === 'attacker' ? fixture.defenderCity.city : fixture.attackerCity.city; + general.officer_level = level; + general.officer_city = assigned ? currentCity : currentCity + 1000; + // Keep the opposite unit neutral so the subject officer's attack/defence + // multiplier is observable without the counterpart's level-3 5% modifier. + counterpart.officer_level = 1; + counterpart.officer_city = counterpartCity; + cases.push({ role, level, assigned, fixture }); + } + } + } + + const fixtureLines = cases.map(({ fixture }) => JSON.stringify(fixture)); + const references = runReferenceTraceBatch(workspaceRoot!, fixtureLines); + const officerSignatures = new Map(); + cases.forEach(({ role, level, assigned, fixture }, index) => { + const coreEvents: WarBattleTraceEvent[] = []; + const coreLogs = createCoreLogCapture(); + let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; + processBattleSimJob( + { + ...fixture, + unitSet, + config, + time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear }, + }, + { + trace: (event) => coreEvents.push(event), + loggerFactory: coreLogs.loggerFactory, + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return coreRng.createRandUtil(); + }, + } + ); + const reference = references[index]!; + const label = `officer.${role}.level${level}.${assigned ? 'assigned' : 'off-city'}`; + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); + assertAllLogBucketsParity(coreLogs, reference, fixture, label); + const phasePower = coreEvents.find((event) => event.event === 'phase_power'); + const snapshot = role === 'attacker' ? phasePower?.attacker : phasePower?.defender; + const counterpartSnapshot = role === 'attacker' ? phasePower?.defender : phasePower?.attacker; + expect(snapshot?.kind, `${label}: participating general`).toBe('general'); + expect(counterpartSnapshot?.kind, `${label}: counterpart general`).toBe('general'); + officerSignatures.set( + `${role}-${level}-${assigned}`, + JSON.stringify({ + subjectRawWarPower: snapshot!.rawWarPower, + counterpartWarPowerMultiplier: counterpartSnapshot!.warPowerMultiplier, + }) + ); + }); + + for (const role of ['attacker', 'defender'] as const) { + expect(officerSignatures.get(`${role}-1-true`), `${role}: level 1 ignores assignment`).toBe( + officerSignatures.get(`${role}-1-false`) + ); + for (const level of [2, 3, 4]) { + expect( + officerSignatures.get(`${role}-${level}-false`), + `${role}: off-city level ${level} falls back` + ).toBe(officerSignatures.get(`${role}-1-true`)); + expect( + officerSignatures.get(`${role}-${level}-true`), + `${role}: assigned level ${level} keeps the officer battle signature` + ).not.toBe(officerSignatures.get(`${role}-${level}-false`)); + } + } + }); + + it('matches every distinct CHE crew battle signature on attacker and defender paths', { timeout: 180_000 }, () => { + const unitSet = readJson( + path.resolve(process.cwd(), '../../resources/unitset/unitset_che.json') + ); + const crewTypes = unitSet.crewTypes ?? []; + const signatures = crewTypes.map((crewType) => + JSON.stringify({ + armType: crewType.armType, + attack: crewType.attack, + defence: crewType.defence, + speed: crewType.speed, + avoid: crewType.avoid, + magicCoef: crewType.magicCoef, + rice: crewType.rice, + attackCoef: crewType.attackCoef, + defenceCoef: crewType.defenceCoef, + iActionList: crewType.iActionList, + initSkillTrigger: crewType.initSkillTrigger, + phaseSkillTrigger: crewType.phaseSkillTrigger, + }) + ); + expect(new Set(signatures).size, 'unitset_che distinct battle signatures').toBe(crewTypes.length); + + const config: WarEngineConfig = { + armPerPhase: 500, + maxTrainByCommand: 100, + maxAtmosByCommand: 100, + maxTrainByWar: 110, + maxAtmosByWar: 150, + castleCrewTypeId: 1000, + armTypes: { footman: 1, archer: 2, cavalry: 3, wizard: 4, siege: 5, misc: 6, castle: 0 }, + }; + const cases: Array<{ + role: 'attacker' | 'defender'; + crewTypeId: number; + fixture: BattleSimRequestPayload & { startYear: number }; + }> = []; + const crewFilter = process.env.CREW_PARITY_FILTER; + const crewRoleFilter = process.env.CREW_PARITY_ROLE; + for (const crewType of crewTypes.filter((entry) => entry.id !== config.castleCrewTypeId)) { + if (crewFilter && String(crewType.id) !== crewFilter) { + continue; + } + for (const role of ['attacker', 'defender'] as const) { + if (crewRoleFilter && role !== crewRoleFilter) { + continue; + } + // In the official assertion-enabled Ref image, attacker-side + // 정란/벽력거 routes the castle first and then the castle's + // general-only phase trigger aborts. Their distinct phase skill + // remains covered on the defender path; the Ref runtime defect is + // documented as an explicit remaining boundary. + if (role === 'attacker' && (crewType.id === 1500 || crewType.id === 1502)) { + continue; + } + const fixture = readJson( + path.resolve(process.cwd(), 'fixtures/battle/basic-infantry.json') + ); + fixture.seed = `battle-differential-crew-${role}-${crewType.id}`; + // Keep every synthetic pairing in general-vs-general combat for the + // whole phase budget. City combat is covered separately and the Ref + // castle unit intentionally carries a general-only phase assertion. + fixture.attackerGeneral.crew = 50000; + fixture.attackerGeneral.rice = 1000000; + fixture.attackerGeneral.leadership = 90; + fixture.attackerGeneral.strength = 90; + fixture.attackerGeneral.intel = 90; + fixture.defenderGenerals[0]!.crew = 50000; + fixture.defenderGenerals[0]!.rice = 1000000; + fixture.defenderGenerals[0]!.leadership = 85; + fixture.defenderGenerals[0]!.strength = 85; + fixture.defenderGenerals[0]!.intel = 85; + fixture.defenderCity.def = 400; + fixture.defenderCity.wall = 400; + fixture.defenderCity.def_max = 400; + fixture.defenderCity.wall_max = 400; + const general = role === 'attacker' ? fixture.attackerGeneral : fixture.defenderGenerals[0]!; + general.crewtype = crewType.id; + general.dex1 = 12000; + general.dex2 = 12000; + general.dex3 = 12000; + general.dex4 = 12000; + general.dex5 = 12000; + cases.push({ role, crewTypeId: crewType.id, fixture }); + } + } + + const fixtureLines = cases.map(({ fixture }) => JSON.stringify(fixture)); + const references = runReferenceTraceBatch(workspaceRoot!, fixtureLines); + cases.forEach(({ role, crewTypeId, fixture }, index) => { + const coreEvents: WarBattleTraceEvent[] = []; + const coreLogs = createCoreLogCapture(); + let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; + processBattleSimJob( + { + ...fixture, + unitSet, + config, + time: { year: fixture.year, month: fixture.month, startYear: fixture.startYear }, + }, + { + trace: (event) => coreEvents.push(event), + loggerFactory: coreLogs.loggerFactory, + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, + rngFactory: (seed) => { + coreRng = new TracingRng(LiteHashDRBG.build(seed)); + return coreRng.createRandUtil(); + }, + } + ); + const reference = references[index]!; + const label = `crew.${role}.${crewTypeId}`; + try { + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); + assertAllLogBucketsParity(coreLogs, reference, fixture, label); + } catch (error) { + const debug = + process.env.CREW_PARITY_DEBUG === '1' + ? ` coreEvents=${JSON.stringify(coreEvents.map((event) => [event.seq, event.event, event.attacker.phase, event.defender?.phase, event.defender?.activatedSkills]))} refEvents=${JSON.stringify(reference.events.map((event) => [event.seq, event.event, event.attacker.phase, event.defender?.phase, event.defender?.activatedSkills]))}` + : ''; + throw new Error(`${label}: ${error instanceof Error ? error.message : String(error)}${debug}`, { + cause: error, + }); + } + + if (role === 'defender' && (crewTypeId === 1500 || crewTypeId === 1502)) { + expect( + coreEvents.some((event) => (event.defender?.activatedSkills['선제'] ?? 0) > 0), + `${label}: 정란/벽력거 선제사격 must activate` + ).toBe(true); + } + if (role === 'defender' && crewTypeId === 1503) { + expect( + coreEvents.some((event) => (event.defender?.activatedSkills['저지'] ?? 0) > 0), + `${label}: 목우 저지 must activate for the fixed seed` + ).toBe(true); + } + }); }); it('matches wizard strategy attempts, outcomes, and RNG consumption', () => { @@ -1142,11 +1971,15 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }; const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; const result = processBattleSimJob(payload, { trace: (event) => coreEvents.push(event), + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, }); const reference = runReferenceTrace(workspaceRoot!, fixtureJson); @@ -1157,12 +1990,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => { Object.keys(event.attacker.activatedSkills).some((skill) => ['계략', '계략실패'].includes(skill)) ) ).toBe(true); - assertRngParity(reference, coreRng); - const finalReference = reference.events.at(-1)!; - expect({ phase: result.phase, killed: result.killed }).toEqual({ - phase: finalReference.attacker.phase, - killed: finalReference.attacker.killed, - }); + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); // Ref keeps the injury-adjusted intelligence fraction here: // (((81 * 0.63) + round((58 * 0.63) / 4)) / 100) * 0.5 + 0.2 @@ -1185,7 +2013,7 @@ describeWithReference('ref ↔ core2026 battle differential', () => { { rngFactory: (seed) => { fractionalCoreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(fractionalCoreRng); + return fractionalCoreRng.createRandUtil(); }, } ); @@ -1262,18 +2090,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }; const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; const result = processBattleSimJob(payload, { trace: (event) => coreEvents.push(event), + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, }); const reference = runReferenceTrace(workspaceRoot!, fixtureJson); expect(result.result).toBe(true); expect(reference.events.filter((event) => event.event === 'opponent_switched')).toHaveLength(2); - assertTraceParity(coreEvents, reference, coreRng); + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); }); it('matches siege dexterity and castle damage handling', () => { @@ -1315,18 +2147,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }; const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; const result = processBattleSimJob(payload, { trace: (event) => coreEvents.push(event), + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, }); const reference = runReferenceTrace(workspaceRoot!, fixtureJson); expect(result.result).toBe(true); expect(reference.events.some((event) => event.defender?.kind === 'city')).toBe(true); - assertTraceParity(coreEvents, reference, coreRng); + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); }); it('matches the no-defender supply-retreat branch without consuming RNG', () => { @@ -1358,18 +2194,22 @@ describeWithReference('ref ↔ core2026 battle differential', () => { }; const coreEvents: WarBattleTraceEvent[] = []; let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; const result = processBattleSimJob(payload, { trace: (event) => coreEvents.push(event), + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, }); const reference = runReferenceTrace(workspaceRoot!, fixtureJson); expect(result.result).toBe(true); expect(reference.events.map((event) => event.event)).toEqual(['battle_start', 'supply_retreat', 'battle_end']); - assertTraceParity(coreEvents, reference, coreRng); + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); }); it('matches every scenario item in an attacker battle simulation', { timeout: 180_000 }, () => { @@ -1450,17 +2290,24 @@ describeWithReference('ref ↔ core2026 battle differential', () => { time: { year: base.year, month: base.month, startYear: base.startYear }, }; const coreEvents: WarBattleTraceEvent[] = []; + const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; processBattleSimJob(payload, { trace: (event) => coreEvents.push(event), + loggerFactory: coreLogs.loggerFactory, + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, }); const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); try { - assertTraceParity(coreEvents, reference, coreRng); + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); + assertAllLogBucketsParity(coreLogs, reference, base, `item.attacker.${itemKey}`); } catch (error) { const debug = process.env['ITEM_PARITY_DEBUG'] === '1' @@ -1558,17 +2405,24 @@ describeWithReference('ref ↔ core2026 battle differential', () => { time: { year: base.year, month: base.month, startYear: base.startYear }, }; const coreEvents: WarBattleTraceEvent[] = []; + const coreLogs = createCoreLogCapture(); let coreRng: TracingRng | null = null; + let coreOutcome: WarBattleOutcome | null = null; processBattleSimJob(payload, { trace: (event) => coreEvents.push(event), + loggerFactory: coreLogs.loggerFactory, + onBattleResolved: (outcome) => { + coreOutcome = outcome; + }, rngFactory: (seed) => { coreRng = new TracingRng(LiteHashDRBG.build(seed)); - return new RandUtil(coreRng); + return coreRng.createRandUtil(); }, }); const reference = runReferenceTrace(workspaceRoot!, JSON.stringify(base)); try { - assertTraceParity(coreEvents, reference, coreRng); + assertTraceParity(coreEvents, reference, coreRng, coreOutcome); + assertAllLogBucketsParity(coreLogs, reference, base, `item.defender.${itemKey}`); } catch (error) { const debug = process.env['ITEM_PARITY_DEBUG'] === '1' diff --git a/tools/integration-tests/test/liveSortiePersistence.integration.test.ts b/tools/integration-tests/test/liveSortiePersistence.integration.test.ts index a5a467ff..e22fa577 100644 --- a/tools/integration-tests/test/liveSortiePersistence.integration.test.ts +++ b/tools/integration-tests/test/liveSortiePersistence.integration.test.ts @@ -210,7 +210,9 @@ integration('live sortie PostgreSQL persistence retry', () => { commandProfile: createCoreTurnCommandProfile(request), }); world = new InMemoryTurnWorld(state, snapshot, { - schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + schedule: { + entries: [{ startMinute: 0, tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)) }], + }, generalTurnHandler: handler, }); const actor = world.getGeneralById(request.actorGeneralId);