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 minutes = String(Math.trunc(wholeSeconds / 60)).padStart(2, '0'); const seconds = String(wholeSeconds % 60).padStart(2, '0'); return `${minutes}:${seconds}`; }; 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 '알수없음'; }; const executeInheritanceActionMutation = 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 }; }; /** * Persists the WALL_TIME inheritance receipt in the same transaction as the * point debit, game mutation, and input-event completion. The input_event row * is the durable retry/failure record and owns the authoritative GAME clock * coordinate; an immediate effect does not invent a separate applied tick. */ export const executeInheritanceAction = async (options: { db: GamePrisma.TransactionClient; world: InMemoryTurnWorld; command: InheritanceActionCommand; gameNow: Date; }): Promise => { const result = await executeInheritanceActionMutation(options); if (!result.ok || !options.command.requestId) return result; const event = await options.db.inputEvent.findUnique({ where: { requestId: options.command.requestId }, select: { actorUserId: true, target: true, eventType: true, createdAt: true, processingClockRevision: true, processingDeadlineGeneration: true, }, }); if ( !event || event.actorUserId !== options.command.userId || event.target !== 'ENGINE' || event.eventType !== 'inheritanceAction' || event.processingClockRevision === null || event.processingDeadlineGeneration === null ) { throw new Error('Inheritance ledger requires the authoritative ENGINE input-event clock fence.'); } const previousPoint = await lockPreviousPoint(options.db, options.command.userId); const cost = previousPoint - result.remainPoint; if (!Number.isFinite(cost) || cost < 0) { throw new Error(`Inheritance ledger calculated an invalid cost: ${cost}.`); } await options.db.inheritanceLedger.create({ data: { requestId: options.command.requestId, userId: options.command.userId, action: result.action, cost, status: 'APPLIED', requestedAtWall: event.createdAt, appliedClockRevision: event.processingClockRevision, appliedDeadlineGeneration: event.processingDeadlineGeneration, }, }); return result; };