diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index 4346c992..3bec23ca 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -271,12 +271,27 @@ export const inheritRouter = router({ throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); } + const rankRows = await ctx.db.rankData.findMany({ + where: { + generalId: general.id, + type: { in: ['warnum', 'firenum', 'betwin', 'betgold', 'betwingold'] }, + }, + select: { type: true, value: true }, + }); + const calculationMeta = { + ...asRecord(general.meta), + ...Object.fromEntries(rankRows.map((row) => [row.type, row.value])), + ...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])), + }; + const meta = asRecord(worldState.meta); - const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0; + const isUnited = + (typeof meta.isUnited === 'number' && meta.isUnited !== 0) || + (typeof meta.isunited === 'number' && meta.isunited !== 0); const items = await computeInheritanceItems({ db: ctx.db, userId, - generalMeta: asRecord(general.meta), + generalMeta: calculationMeta, isUnited, }); const totalPoint = sumInheritanceItems(items); diff --git a/app/game-api/src/services/inheritance.ts b/app/game-api/src/services/inheritance.ts index e6b2bde9..fce6c558 100644 --- a/app/game-api/src/services/inheritance.ts +++ b/app/game-api/src/services/inheritance.ts @@ -1,5 +1,9 @@ import { asNumber, asRecord } from '@sammo-ts/common'; import { GamePrisma } from '@sammo-ts/infra'; +import { + ALL_MERGED_INHERITANCE_KEYS, + computeActiveInheritancePoint, +} from '@sammo-ts/logic/inheritance/pointCalculation.js'; import type { DatabaseClient, WorldStateRow, InputJsonValue } from '../context.js'; export type InheritPointKey = @@ -163,73 +167,34 @@ export const appendInheritanceLog = async ( }); }; -const readUserMetaValue = (meta: Record, key: string): number => { - const value = meta[key]; - if (typeof value !== 'number' || !Number.isFinite(value)) { - return 0; - } - return value; -}; - -const computeDexPoint = (meta: Record): number => { - let total = 0; - for (const [key, value] of Object.entries(meta)) { - if (!key.startsWith('dex')) { - continue; - } - if (typeof value === 'number' && Number.isFinite(value)) { - total += value; - } - } - return total * 0.001; -}; - export const computeInheritanceItems = async (options: { db: DatabaseClient; userId: string; generalMeta: Record | null; isUnited: boolean; }): Promise> => { - const previous = await readInheritancePoint(options.db, options.userId, 'previous'); - const unifier = await readInheritancePoint(options.db, options.userId, 'unifier'); + const pointRows = await options.db.inheritancePoint.findMany({ + where: { userId: options.userId }, + select: { key: true, value: true }, + }); + const inheritancePoints = Object.fromEntries(pointRows.map((row) => [row.key, row.value])); + const previous = inheritancePoints.previous ?? 0; + const general = { + meta: options.generalMeta ?? {}, + inheritancePoints, + }; if (options.isUnited) { - return { - previous, - lived_month: 0, - max_domestic_critical: 0, - active_action: 0, - combat: 0, - sabotage: 0, - dex: 0, - unifier, - tournament: 0, - betting: 0, - max_belong: 0, - }; + return Object.fromEntries([ + ['previous', previous], + ...ALL_MERGED_INHERITANCE_KEYS.map((key) => [key, inheritancePoints[key] ?? 0] as const), + ]) as Record; } - const meta = options.generalMeta ?? {}; - const livedMonth = readUserMetaValue(meta, 'inherit_lived_month'); - const maxDomestic = readUserMetaValue(meta, 'max_domestic_critical'); - const activeAction = readUserMetaValue(meta, 'inherit_active_action'); - const combat = readUserMetaValue(meta, 'rank_warnum') * 5; - const sabotage = readUserMetaValue(meta, 'firenum') * 20; - const dex = computeDexPoint(meta); - - return { - previous, - lived_month: livedMonth, - max_domestic_critical: maxDomestic, - active_action: activeAction, - combat, - sabotage, - dex, - unifier, - tournament: 0, - betting: 0, - max_belong: 0, - }; + return Object.fromEntries([ + ['previous', previous], + ...ALL_MERGED_INHERITANCE_KEYS.map((key) => [key, computeActiveInheritancePoint(general, key)] as const), + ]) as Record; }; export const sumInheritanceItems = (items: Record): number => { diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index f3d473f7..9535e424 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -106,6 +106,8 @@ const buildContext = (options: { general?: GeneralRow | null; target?: GeneralRow | null; inheritancePoint?: number; + inheritanceRows?: Array<{ key: string; value: number }>; + rankRows?: Array<{ type: string; value: number }>; inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>; configConst?: Record; }) => { @@ -176,6 +178,12 @@ const buildContext = (options: { }, inheritancePoint: { upsert: pointUpsert, + findMany: vi.fn( + async () => options.inheritanceRows ?? [{ key: 'previous', value: options.inheritancePoint ?? 10_000 }] + ), + }, + rankData: { + findMany: vi.fn(async () => options.rankRows ?? []), }, inheritanceLog: { create: logCreate, @@ -254,6 +262,58 @@ describe('inherit router actor and permission boundaries', () => { }); }); + it('projects every Ref inheritance source with its own coefficient and stored/calculated boundary', async () => { + const fixture = buildContext({ + general: buildGeneral({ + meta: { + inherit_lived_month: 12, + max_domestic_critical: 20, + inherit_active_action: 0.5, + belong: 7, + max_belong: 9, + rank_warnum: 300, + firenum: 200, + dex1: 1_275_978, + dex2: 100, + event100_allstar: { granted: { dex2: 40 } }, + betwin: 200, + betgold: 200_000, + betwingold: 100_000, + }, + }), + rankRows: [ + { type: 'warnum', value: 3 }, + { type: 'firenum', value: 2 }, + { type: 'betwin', value: 2 }, + { type: 'betgold', value: 2_000 }, + { type: 'betwingold', value: 1_000 }, + ], + inheritanceRows: [ + { key: 'previous', value: 100 }, + { key: 'max_domestic_critical', value: 80 }, + { key: 'unifier', value: 250 }, + { key: 'tournament', value: 50 }, + ], + }); + + const status = await appRouter.createCaller(fixture.context).inherit.getStatus(); + + expect(status.items).toEqual({ + previous: 100, + lived_month: 12, + max_domestic_critical: 80, + active_action: 1.5, + unifier: 250, + tournament: 50, + max_belong: 90, + combat: 15, + sabotage: 40, + dex: 1_276.036, + betting: 5, + }); + expect(status.totalPoint).toBeCloseTo(1_919.536, 8); + }); + it.each([{}, { allItems: '{}' }])( 'restores selectable Ref default uniques for a legacy scenario config: %j', async (configConst) => { diff --git a/app/game-engine/src/tournament/finalizer.ts b/app/game-engine/src/tournament/finalizer.ts index cbf00c8b..ba4d07d8 100644 --- a/app/game-engine/src/tournament/finalizer.ts +++ b/app/game-engine/src/tournament/finalizer.ts @@ -142,12 +142,12 @@ export const createTournamentRewardFinalizer = async (options: { const nameMap = new Map(); const generals = await db.general.findMany({ where: { id: { in: Array.from(rewardMap.keys()) } }, - select: { id: true, userId: true, name: true }, + select: { id: true, userId: true, name: true, npcState: true }, }); const userMap = new Map(); for (const general of generals) { nameMap.set(general.id, general.name); - if (general.userId) { + if (general.userId && general.npcState < 2) { userMap.set(general.id, general.userId); } } @@ -261,6 +261,15 @@ export const createTournamentRewardFinalizer = async (options: { update: { value: { increment: entry.value } }, create: { userId: entry.userId!, key: 'tournament', value: entry.value }, }); + const general = world.getGeneralById(entry.generalId); + if (general) { + world.updateGeneral(entry.generalId, { + inheritancePoints: { + ...general.inheritancePoints, + tournament: Number(general.inheritancePoints?.tournament ?? 0) + entry.value, + }, + }); + } } return { diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index bee09456..b17e4910 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1241,21 +1241,6 @@ export const createDatabaseTurnHooks = async ( const meta = asRecord(state.meta); const serverId = typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default'; - await persistGeneralLifecycleEvents( - prisma, - lifecycleEvents, - meta, - asRecord(world.getScenarioConfig().const), - world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0) - ); - - if (accessScoreResetGeneralIds.length > 0) { - await prisma.generalAccessLog.updateMany({ - where: { generalId: { in: accessScoreResetGeneralIds } }, - data: { refreshScore: 0 }, - }); - } - if (inheritancePointAdjustments.length > 0) { const grouped = new Map(); for (const entry of inheritancePointAdjustments) { @@ -1275,6 +1260,20 @@ export const createDatabaseTurnHooks = async ( }); } } + await persistGeneralLifecycleEvents( + prisma, + lifecycleEvents, + meta, + asRecord(world.getScenarioConfig().const), + world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0) + ); + + if (accessScoreResetGeneralIds.length > 0) { + await prisma.generalAccessLog.updateMany({ + where: { generalId: { in: accessScoreResetGeneralIds } }, + data: { refreshScore: 0 }, + }); + } if (deletedNationSnapshots.length > 0) { const nationIds = deletedNationSnapshots.map((snapshot) => snapshot.nation.id); diff --git a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts index 2a134e20..6347bec5 100644 --- a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts +++ b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts @@ -1,6 +1,7 @@ import { asRecord, HALL_OF_FAME_TYPES, 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 type { GeneralLifecycleEvent } from './inMemoryWorld.js'; @@ -23,14 +24,6 @@ const readWorldNumber = (record: Record, key: string, fallback: return value === 0 && record[key] === undefined ? fallback : Math.floor(value); }; -const computeDexPoint = (meta: Record): number => { - let total = 0; - for (let dex = 1; dex <= 5; dex += 1) { - total += readNumber(meta, `dex${dex}`); - } - return total * 0.001; -}; - const settleInheritance = async ( prisma: GamePrisma.TransactionClient, event: GeneralLifecycleEvent, @@ -71,8 +64,6 @@ const settleInheritance = async ( }), ]); const points = new Map(rows.map((row) => [row.key, row.value])); - const ranks = new Map(rankRows.map((row) => [row.type, row.value])); - const rank = (key: string): number => ranks.get(key) ?? readNumber(meta, `rank_${key}`); const previous = points.get('previous') ?? 0; const randomUniqueRefund = meta.inheritRandomUnique ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) @@ -81,30 +72,45 @@ const settleInheritance = async ( ? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000) : 0; const refund = randomUniqueRefund + specificSpecialRefund; - const lived = readNumber(meta, 'inherit_lived_month'); - const maxBelong = readNumber(meta, 'inherit_max_belong') * 10; - const maxDomestic = readNumber(meta, 'max_domestic_critical'); - const active = readNumber(meta, 'inherit_active_action') * 3; - const combat = rank('warnum') * 5; - const sabotage = (ranks.get('firenum') ?? readNumber(meta, 'firenum')) * 20; - const dex = computeDexPoint(meta); - const unifier = points.get('unifier') ?? 0; - const earned = isRebirth - ? lived + active + combat + sabotage + dex * 0.5 - : lived + maxBelong + maxDomestic + active + combat + sabotage + dex + unifier; - const total = Math.trunc(previous + refund + earned); + const calculationMeta = { + ...meta, + ...Object.fromEntries(rankRows.map((row) => [row.type, row.value])), + ...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])), + }; + const settlement = computeInheritanceSettlementBreakdown( + { + meta: calculationMeta, + inheritancePoints: Object.fromEntries(points), + }, + isRebirth + ); + const total = Math.trunc(previous + refund + settlement.totalEarned); await prisma.inheritancePoint.upsert({ where: { userId_key: { userId, key: 'previous' } }, update: { value: total }, create: { userId, key: 'previous', value: total }, }); - await prisma.inheritancePoint.deleteMany({ - where: { - userId, - key: isRebirth ? { notIn: ['previous', 'unifier'] } : { not: 'previous' }, - }, - }); + if (isRebirth) { + const retainedEntries = Object.entries(settlement.retained).filter( + ([key, value]) => key === 'max_belong' || points.has(key) || value !== 0 + ); + for (const [key, value] of retainedEntries) { + await prisma.inheritancePoint.upsert({ + where: { userId_key: { userId, key } }, + update: { value }, + create: { userId, key, value }, + }); + } + await prisma.inheritancePoint.deleteMany({ + where: { + userId, + key: { notIn: ['previous', ...retainedEntries.map(([key]) => key)] }, + }, + }); + } else { + await prisma.inheritancePoint.deleteMany({ where: { userId, key: { not: 'previous' } } }); + } const serverId = typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; await prisma.inheritanceResult.create({ @@ -117,15 +123,10 @@ const settleInheritance = async ( value: asJson({ previous, refund, - lived_month: lived, - max_belong: maxBelong, - max_domestic_critical: maxDomestic, - active_action: active, - combat, - sabotage, - dex: isRebirth ? dex * 0.5 : dex, - unifier: isRebirth ? 0 : unifier, + ...settlement.earned, + ...(isRebirth ? { retained: settlement.retained } : {}), rebirth: isRebirth, + total, }), }, }); diff --git a/app/game-engine/src/turn/inheritancePointCalculation.ts b/app/game-engine/src/turn/inheritancePointCalculation.ts index ed18c3b6..62a2bd8b 100644 --- a/app/game-engine/src/turn/inheritancePointCalculation.ts +++ b/app/game-engine/src/turn/inheritancePointCalculation.ts @@ -1,97 +1,12 @@ -const DEX_LIMIT = 1_275_975; - -interface InheritancePointGeneral { - meta: Record; - inheritancePoints?: Record; -} - -const STORED_INHERITANCE_KEYS = [ - 'lived_month', - 'max_domestic_critical', - 'active_action', - 'unifier', - 'tournament', -] as const; - -export const ALL_MERGED_INHERITANCE_KEYS = [ - ...STORED_INHERITANCE_KEYS, - 'max_belong', - 'combat', - 'sabotage', - 'dex', - 'betting', -] as const; - -export type MergedInheritanceKey = (typeof ALL_MERGED_INHERITANCE_KEYS)[number]; - -const readNumber = (source: Record, key: string): number => { - const value = source[key]; - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return 0; -}; - -const computeDexPoint = (general: InheritancePointGeneral): number => { - let totalDexterity = 0; - for (let index = 1; index <= 5; index += 1) { - let dexterity = readNumber(general.meta, `dex${index}`); - if (dexterity > DEX_LIMIT) { - totalDexterity += (dexterity - DEX_LIMIT) / 3; - dexterity = DEX_LIMIT; - } - totalDexterity += dexterity; - } - return totalDexterity * 0.001; -}; - -const computeBettingPoint = (general: InheritancePointGeneral): number => { - const wins = readNumber(general.meta, 'betwin'); - const gold = readNumber(general.meta, 'betgold'); - const wonGold = readNumber(general.meta, 'betwingold'); - const winRate = wonGold / Math.max(1000, gold); - return wins * 10 * winRate ** 2; -}; - -export const computeActiveInheritancePoint = ( - general: InheritancePointGeneral, - key: MergedInheritanceKey, - storedOverride?: number -): number => { - const stored = storedOverride ?? general.inheritancePoints?.[key] ?? 0; - switch (key) { - case 'lived_month': { - const value = readNumber(general.meta, 'inherit_lived_month'); - return value !== 0 ? value : stored; - } - case 'max_domestic_critical': { - const value = readNumber(general.meta, 'max_domestic_critical'); - return value !== 0 ? value : stored; - } - case 'active_action': { - const value = readNumber(general.meta, 'inherit_active_action'); - return value !== 0 ? value * 3 : stored; - } - case 'unifier': - case 'tournament': - return stored; - case 'max_belong': - return ( - Math.max( - readNumber(general.meta, 'belong'), - readNumber(general.meta, 'max_belong'), - readNumber(general.meta, 'inherit_max_belong') - ) * 10 - ); - case 'combat': - return readNumber(general.meta, 'rank_warnum') * 5; - case 'sabotage': - return readNumber(general.meta, 'firenum') * 20; - case 'dex': - return computeDexPoint(general); - case 'betting': - return computeBettingPoint(general); - } -}; +export { + ALL_MERGED_INHERITANCE_KEYS, + computeActiveInheritancePoint, + computeBettingInheritancePoint, + computeDexInheritancePoint, + computeInheritanceSettlementBreakdown, + LEGACY_DEX_INHERITANCE_LIMIT, + REBIRTH_INHERITANCE_COEFFICIENTS, + type InheritancePointGeneral, + type InheritanceSettlementBreakdown, + type MergedInheritanceKey, +} from '@sammo-ts/logic/inheritance/pointCalculation.js'; diff --git a/app/game-engine/src/turn/monthlyNationLevelAction.ts b/app/game-engine/src/turn/monthlyNationLevelAction.ts index 38806610..a1a9df76 100644 --- a/app/game-engine/src/turn/monthlyNationLevelAction.ts +++ b/app/game-engine/src/turn/monthlyNationLevelAction.ts @@ -375,8 +375,15 @@ export const createUpdateNationLevelHandler = (options: { }); } const isUnited = readNumber(state.meta.isunited ?? state.meta.isUnited); - if (chief?.userId && isUnited === 0) { - world.queueInheritancePointAdjustment(chief.userId, 'unifier', 250 * levelDiff); + if (chief?.userId && chief.npcState < 2 && isUnited === 0) { + const amount = 250 * levelDiff; + world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount); + world.updateGeneral(chief.id, { + inheritancePoints: { + ...chief.inheritancePoints, + unifier: readNumber(chief.inheritancePoints?.unifier) + amount, + }, + }); } } }; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 1dac6ae2..bfac0e72 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -268,6 +268,7 @@ const readConfigNumber = (config: ScenarioConfig, key: string, fallback: number) const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({ ...general, + ...(general.inheritancePoints ? { inheritancePoints: { ...general.inheritancePoints } } : {}), stats: { ...general.stats }, role: { ...general.role, @@ -372,6 +373,25 @@ const readMetaNumber = (meta: Record, key: string, fallback: nu return fallback; }; +const readInheritanceNumber = (value: unknown): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +}; + +const canAccumulateInheritance = ( + general: Pick, + worldMeta: Record +): general is Pick & { userId: string } => + Boolean(general.userId) && + general.npcState < 2 && + readMetaNumber(worldMeta, 'isunited', readMetaNumber(worldMeta, 'isUnited', 0)) === 0; + const readMetaBool = (meta: Record, key: string, fallback = false): boolean => { const value = meta[key]; if (typeof value === 'boolean') { @@ -1214,6 +1234,34 @@ export const createReservedTurnHandler = async (options: { currentGeneral = resolution.general as TurnGeneral; currentCity = resolution.city ?? currentCity; currentNation = resolution.nation ?? currentNation; + const inheritanceEnabled = canAccumulateInheritance(currentGeneral, asRecord(context.world.meta)); + const inheritanceUserId = inheritanceEnabled ? currentGeneral.userId : null; + if (actionKey === 'che_인재탐색') { + const previousActive = readMetaNumber( + asRecord(generalBeforeExecution.meta), + 'inherit_active_action', + 0 + ); + const nextActive = readMetaNumber(asRecord(currentGeneral.meta), 'inherit_active_action', 0); + if (!inheritanceUserId) { + currentGeneral = { + ...currentGeneral, + meta: { ...currentGeneral.meta, inherit_active_action: previousActive }, + }; + } else if (nextActive > previousActive) { + const pointAmount = (nextActive - previousActive) * 3; + worldRef?.queueInheritancePointAdjustment(inheritanceUserId, 'active_action', pointAmount); + currentGeneral = { + ...currentGeneral, + inheritancePoints: { + ...currentGeneral.inheritancePoints, + active_action: + readInheritanceNumber(currentGeneral.inheritancePoints?.active_action) + + pointAmount, + }, + }; + } + } if (!resolution.alternative && !usedFallback && resolution.completed) { currentGeneral = applyLegacyGeneralProgression( currentGeneral, @@ -1229,13 +1277,20 @@ export const createReservedTurnHandler = async (options: { !usedFallback && resolution.completed && definition.countsAsInheritanceActiveAction && - Boolean(currentGeneral.userId) && - currentGeneral.npcState < 2 + inheritanceUserId ) { const meta = { ...currentGeneral.meta }; const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; meta.inherit_active_action = active + 1; - currentGeneral = { ...currentGeneral, meta }; + worldRef?.queueInheritancePointAdjustment(inheritanceUserId, 'active_action', 3); + currentGeneral = { + ...currentGeneral, + meta, + inheritancePoints: { + ...currentGeneral.inheritancePoints, + active_action: readInheritanceNumber(currentGeneral.inheritancePoints?.active_action) + 3, + }, + }; } if ( !resolution.alternative && @@ -1243,17 +1298,53 @@ export const createReservedTurnHandler = async (options: { !usedFallback && resolution.completed && executionDefinition.getInheritanceActiveActionAmount && - Boolean(currentGeneral.userId) && - currentGeneral.npcState < 2 + inheritanceEnabled ) { const amount = executionDefinition.getInheritanceActiveActionAmount(actionContext, actionArgs); if (Number.isFinite(amount) && amount !== 0) { const meta = { ...currentGeneral.meta }; const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; meta.inherit_active_action = active + amount; - currentGeneral = { ...currentGeneral, meta }; + const pointAmount = amount * 3; + worldRef?.queueInheritancePointAdjustment(inheritanceUserId!, 'active_action', pointAmount); + currentGeneral = { + ...currentGeneral, + meta, + inheritancePoints: { + ...currentGeneral.inheritancePoints, + active_action: + readInheritanceNumber(currentGeneral.inheritancePoints?.active_action) + + pointAmount, + }, + }; } } + if ( + !resolution.alternative && + kind === 'general' && + !usedFallback && + resolution.completed && + inheritanceUserId + ) { + const inheritancePoints = { ...currentGeneral.inheritancePoints }; + const storedDomesticMaximum = readInheritanceNumber(inheritancePoints.max_domestic_critical); + const currentDomesticStreak = readInheritanceNumber( + asRecord(currentGeneral.meta).max_domestic_critical + ); + if (currentDomesticStreak > storedDomesticMaximum) { + worldRef?.queueInheritancePointAdjustment( + inheritanceUserId, + 'max_domestic_critical', + currentDomesticStreak - storedDomesticMaximum + ); + inheritancePoints.max_domestic_critical = currentDomesticStreak; + } + if (actionKey === 'che_건국') { + worldRef?.queueInheritancePointAdjustment(inheritanceUserId, 'unifier', 250); + inheritancePoints.unifier = readInheritanceNumber(inheritancePoints.unifier) + 250; + } + currentGeneral = { ...currentGeneral, inheritancePoints }; + } if (!currentNation && resolution.created?.nations) { currentNation = @@ -1551,9 +1642,14 @@ export const createReservedTurnHandler = async (options: { const lifecycleBefore = cloneTurnGeneral(currentGeneral); currentGeneral = cloneTurnGeneral(currentGeneral); - if (currentGeneral.npcState < 2) { + if (canAccumulateInheritance(currentGeneral, asRecord(context.world.meta))) { currentGeneral.meta.inherit_lived_month = readMetaNumber(currentGeneral.meta, 'inherit_lived_month', 0) + 1; + worldRef?.queueInheritancePointAdjustment(currentGeneral.userId, 'lived_month', 1); + currentGeneral.inheritancePoints = { + ...currentGeneral.inheritancePoints, + lived_month: readInheritanceNumber(currentGeneral.inheritancePoints?.lived_month) + 1, + }; } const preprocessRng = new RandUtil( new LiteHashDRBG( @@ -1626,10 +1722,7 @@ export const createReservedTurnHandler = async (options: { currentGeneral.crew = 0; currentGeneral.rice = 0; logs.push( - createGeneralActionLog( - currentGeneral.id, - '군량이 모자라 병사들이 소집해제되었습니다!' - ) + createGeneralActionLog(currentGeneral.id, '군량이 모자라 병사들이 소집해제되었습니다!') ); preTurnContext.skill.activate('pre.소집해제'); } @@ -2125,10 +2218,7 @@ export const createReservedTurnHandler = async (options: { currentGeneral = resetRetiredGeneral(currentGeneral); lifecycleOutcome = 'retired'; logs.push( - createGeneralActionLog( - currentGeneral.id, - '나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.' - ) + createGeneralActionLog(currentGeneral.id, '나이가 들어 은퇴하고 자손에게 자리를 물려줍니다.') ); } @@ -2384,11 +2474,17 @@ export const createImmediateGeneralActionExecutor = async (options: { if ( Number.isFinite(activeActionAmount) && activeActionAmount !== 0 && - nextGeneral.userId && - nextGeneral.npcState < 2 + canAccumulateInheritance(nextGeneral, asRecord(state.meta)) ) { + const pointAmount = activeActionAmount * 3; + options.world.queueInheritancePointAdjustment(nextGeneral.userId, 'active_action', pointAmount); nextGeneral = { ...nextGeneral, + inheritancePoints: { + ...nextGeneral.inheritancePoints, + active_action: + readInheritanceNumber(nextGeneral.inheritancePoints?.active_action) + pointAmount, + }, meta: { ...nextGeneral.meta, inherit_active_action: diff --git a/app/game-engine/test/gameCancellation.integration.test.ts b/app/game-engine/test/gameCancellation.integration.test.ts index 574dc37f..fd770e18 100644 --- a/app/game-engine/test/gameCancellation.integration.test.ts +++ b/app/game-engine/test/gameCancellation.integration.test.ts @@ -215,16 +215,16 @@ integration('game cancellation transaction', () => { [userId]: { openingPoint: 10_000, currentPoint: 7_000, - earnedPoint: 1_750.005, - retainedEarnedPoint: 700, - finalPoint: 10_700, + earnedPoint: 1_790.005, + retainedEarnedPoint: 716, + finalPoint: 10_716, baselineSource: 'OPENING', }, }, }); await expect( db.inheritancePoint.findMany({ where: { userId }, orderBy: { key: 'asc' } }) - ).resolves.toMatchObject([{ key: 'previous', value: 10_700 }]); + ).resolves.toMatchObject([{ key: 'previous', value: 10_716 }]); await expect(db.gameHistory.findUniqueOrThrow({ where: { serverId } })).resolves.toMatchObject({ status: 'ABANDONED', winnerNation: null, diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts index bf40595a..02ad0bbe 100644 --- a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -391,7 +391,9 @@ describe('legacy general-turn execution contract', () => { expect(updated.injury).toBe(10); expect(updated.experience).toBe(0); expect(updated.meta.killturn).toBe(4); - expect(updated.meta.inherit_lived_month).toBe(1); + // Ref InheritancePointManager ignores every source when the general + // has no owner, including the per-turn lived_month source. + expect(updated.meta.inherit_lived_month).toBeUndefined(); expect(updated.meta.myset).toBe(3); expect(harness.reservedTurnStore.getGeneralTurn(1, 0).action).toBe('휴식'); expect(harness.getCollectedLogs().some((log) => log.text.includes('악성유저'))).toBe(true); diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts index f6123637..e9cf54e3 100644 --- a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts +++ b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts @@ -96,19 +96,28 @@ integration('general turn lifecycle persistence', () => { killturn: 0, inherit_lived_month: 10, inherit_active_action: 2, + max_belong: 9, inheritRandomUnique: true, dex1: 1_000, dex2: 1, dex3: 1, dex4: 1, dex5: 1, + betwin: 2, + betgold: 2_000, + betwingold: 1_000, }, }); await db.generalAccessLog.create({ data: { generalId: general.id, userId: general.userId, refreshScore: 99 }, }); - await db.inheritancePoint.create({ - data: { userId: general.userId!, key: 'previous', value: 100 }, + await db.inheritancePoint.createMany({ + data: [ + { userId: general.userId!, key: 'previous', value: 100 }, + { userId: general.userId!, key: 'max_domestic_critical', value: 80 }, + { userId: general.userId!, key: 'unifier', value: 250 }, + { userId: general.userId!, key: 'tournament', value: 50 }, + ], }); await db.rankData.createMany({ data: [ @@ -202,7 +211,7 @@ integration('general turn lifecycle persistence', () => { await db.inheritancePoint.findUnique({ where: { userId_key: { userId: general.userId!, key: 'previous' } }, }) - ).toMatchObject({ value: 3_147 }); + ).toMatchObject({ value: 3_622 }); expect( ( await db.inheritanceLog.findMany({ @@ -211,16 +220,33 @@ integration('general turn lifecycle persistence', () => { select: { text: true }, }) ).map(({ text }) => text) - ).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,147 포인트']); + ).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,622 포인트']); }); it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => { - const general = makeGeneral(generalIds[1]!, userIds[1]!); + const general = makeGeneral(generalIds[1]!, userIds[1]!, { + meta: { + killturn: 0, + inherit_lived_month: 10, + inherit_active_action: 2, + max_domestic_critical: 20, + max_belong: 7, + dex1: 1_000, + betwin: 2, + betgold: 2_000, + betwingold: 1_000, + }, + }); await db.generalAccessLog.create({ data: { generalId: general.id, userId: general.userId, refreshScore: 77 }, }); - await db.inheritancePoint.create({ - data: { userId: general.userId!, key: 'previous', value: 50 }, + await db.inheritancePoint.createMany({ + data: [ + { userId: general.userId!, key: 'previous', value: 50 }, + { userId: general.userId!, key: 'max_domestic_critical', value: 80 }, + { userId: general.userId!, key: 'unifier', value: 250 }, + { userId: general.userId!, key: 'tournament', value: 50 }, + ], }); await db.rankData.create({ data: { generalId: general.id, nationId: 0, type: 'warnum', value: 10 }, @@ -256,7 +282,19 @@ integration('general turn lifecycle persistence', () => { await db.inheritancePoint.findUnique({ where: { userId_key: { userId: general.userId!, key: 'previous' } }, }) - ).toMatchObject({ value: 116 }); + ).toMatchObject({ value: 171 }); + expect( + await db.inheritancePoint.findMany({ + where: { userId: general.userId! }, + orderBy: { key: 'asc' }, + select: { key: true, value: true }, + }) + ).toEqual([ + { key: 'max_belong', value: 70 }, + { key: 'max_domestic_critical', value: 80 }, + { key: 'previous', value: 171 }, + { key: 'unifier', value: 250 }, + ]); }); it('does not settle a possessed NPC before the legacy minimum possession period', async () => { diff --git a/app/game-engine/test/inheritanceActiveActionInventory.test.ts b/app/game-engine/test/inheritanceActiveActionInventory.test.ts new file mode 100644 index 00000000..102aa023 --- /dev/null +++ b/app/game-engine/test/inheritanceActiveActionInventory.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_TURN_COMMAND_PROFILE, type ScenarioConfig } from '@sammo-ts/logic'; + +import { buildCommandEnv, buildReservedTurnDefinitions } from '../src/turn/reservedTurnCommands.js'; + +const scenarioConfig: ScenarioConfig = { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMin: 10, npcMax: 70, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'inheritance-active-action', unitSet: 'default' }, +}; + +const generalCommands = [ + 'che_거병', + 'che_건국', + 'che_등용수락', + 'che_랜덤임관', + 'che_모반시도', + 'che_무작위건국', + 'che_방랑', + 'che_선양', + 'che_인재탐색', + 'che_임관', + 'che_장수대상임관', + 'che_첩보', + 'che_출병', + 'che_하야', + 'cr_건국', +] as const; + +const nationCommands = [ + 'che_감축', + 'che_국기변경', + 'che_국호변경', + 'che_무작위수도이전', + 'che_증축', + 'che_천도', + 'che_초토화', + 'event_극병연구', + 'event_대검병연구', + 'event_무희연구', + 'event_산저병연구', + 'event_상병연구', + 'event_원융노병연구', + 'event_음귀병연구', + 'event_화륜차연구', + 'event_화시병연구', +] as const; + +describe('Ref active-action inheritance inventory', () => { + it('marks every Ref command call site, including trend-producing strategic and research actions', async () => { + const { general, nation } = await buildReservedTurnDefinitions({ + env: buildCommandEnv(scenarioConfig), + commandProfile: DEFAULT_TURN_COMMAND_PROFILE, + defaultActionKey: '휴식', + }); + + const generalWithFixedOrContextAmount = [...general.entries()] + .filter(([, definition]) => typeof definition.getInheritanceActiveActionAmount === 'function') + .map(([key]) => key) + .sort(); + expect(generalWithFixedOrContextAmount).toEqual(generalCommands.filter((key) => key !== 'che_인재탐색').sort()); + // 인재탐색은 발견확률을 실제 resolve 안에서 계산해 sqrt(1/p)를 + // 기록한다. 별도 차등 fixture가 이 가중 경로를 검증한다. + expect(general.get('che_인재탐색')).toBeDefined(); + + const nationWithPoint = [...nation.entries()] + .filter(([, definition]) => definition.countsAsInheritanceActiveAction) + .map(([key]) => key) + .sort(); + expect(nationWithPoint).toEqual([...nationCommands].sort()); + }); +}); diff --git a/app/game-engine/test/monthlyNationLevelAction.test.ts b/app/game-engine/test/monthlyNationLevelAction.test.ts index 8c5b7360..923bc10c 100644 --- a/app/game-engine/test/monthlyNationLevelAction.test.ts +++ b/app/game-engine/test/monthlyNationLevelAction.test.ts @@ -216,6 +216,7 @@ describe('UpdateNationLevel monthly action', () => { expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ { userId: 'user-1', key: 'unifier', amount: 500 }, ]); + expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(500); expect(world.peekDirtyState().logs).toEqual( expect.arrayContaining([ { @@ -292,6 +293,7 @@ describe('UpdateNationLevel monthly action', () => { expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ { userId: 'user-1', key: 'unifier', amount: 250 }, ]); + expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250); }); it('does not duplicate a unique item reserved by an unfinished auction', async () => { @@ -306,6 +308,7 @@ describe('UpdateNationLevel monthly action', () => { expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ { userId: 'user-1', key: 'unifier', amount: 250 }, ]); + expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250); }); }); diff --git a/app/game-engine/test/reservedTurnExecution.test.ts b/app/game-engine/test/reservedTurnExecution.test.ts index b20be1fc..b2ad33d5 100644 --- a/app/game-engine/test/reservedTurnExecution.test.ts +++ b/app/game-engine/test/reservedTurnExecution.test.ts @@ -495,9 +495,7 @@ describe('Reserved Turn Execution Integration', () => { category: 'ACTION', generalId: 1, }); - const personalActionLogs = dirty.logs.filter( - (log) => log.scope === 'GENERAL' && log.category === 'ACTION' - ); + const personalActionLogs = dirty.logs.filter((log) => log.scope === 'GENERAL' && log.category === 'ACTION'); expect(personalActionLogs.length).toBeGreaterThan(0); expect(personalActionLogs.every((log) => log.generalId === 1)).toBe(true); expect( @@ -679,6 +677,7 @@ describe('Reserved Turn Execution Integration', () => { const generals: TurnGeneral[] = [ { id: 1, + userId: 'founder-user', name: 'General_Leader', nationId: 0, cityId: 1, @@ -708,6 +707,7 @@ describe('Reserved Turn Execution Integration', () => { }, { id: 2, + userId: 'domestic-user', name: 'General_Sub', nationId: 0, cityId: 1, @@ -973,6 +973,14 @@ describe('Reserved Turn Execution Integration', () => { expect(world.getCityById(1)!.agriculture).toBe(100); expect(world.getCityById(1)!.nationId).toBe(0); // City 1 is still unowned expect(world.getCityById(2)!.agriculture).toBeGreaterThan(100); // City 2 agric increased + expect(gen1ReallyFinal.inheritancePoints?.unifier).toBe(250); + expect(world.peekDirtyState().inheritancePointAdjustments).toEqual( + expect.arrayContaining([ + { userId: 'founder-user', key: 'active_action', amount: 3 }, + { userId: 'founder-user', key: 'lived_month', amount: 1 }, + { userId: 'founder-user', key: 'unifier', amount: 250 }, + ]) + ); }); it('should fail founding with specific constraints', async () => { diff --git a/packages/logic/src/actions/turn/general/che_하야.ts b/packages/logic/src/actions/turn/general/che_하야.ts index a5e245fd..300d52a9 100644 --- a/packages/logic/src/actions/turn/general/che_하야.ts +++ b/packages/logic/src/actions/turn/general/che_하야.ts @@ -65,6 +65,8 @@ export class ActionResolver< // Penalty const betrayal = typeof general.meta.betray === 'number' ? general.meta.betray : 0; + const belong = typeof general.meta.belong === 'number' ? general.meta.belong : 0; + const maxBelong = typeof general.meta.max_belong === 'number' ? general.meta.max_belong : 0; const penaltyRatio = betrayal * 0.1; const nextExp = Math.round(general.experience * (1 - penaltyRatio)); const nextDed = Math.round(general.dedication * (1 - penaltyRatio)); @@ -99,6 +101,7 @@ export class ActionResolver< ...general.meta, betray: Math.min(9, betrayal + 1), belong: 0, + ...(general.npcState < 2 ? { max_belong: Math.max(belong, maxBelong) } : {}), makelimit: 12, officer_city: 0, permission: 'normal', diff --git a/packages/logic/src/inheritance/pointCalculation.ts b/packages/logic/src/inheritance/pointCalculation.ts new file mode 100644 index 00000000..a9b8b09c --- /dev/null +++ b/packages/logic/src/inheritance/pointCalculation.ts @@ -0,0 +1,164 @@ +export const LEGACY_DEX_INHERITANCE_LIMIT = 1_275_975; + +export const ALL_MERGED_INHERITANCE_KEYS = [ + 'lived_month', + 'max_domestic_critical', + 'active_action', + 'unifier', + 'tournament', + 'max_belong', + 'combat', + 'sabotage', + 'dex', + 'betting', +] as const; + +export type MergedInheritanceKey = (typeof ALL_MERGED_INHERITANCE_KEYS)[number]; + +export interface InheritancePointGeneral { + meta: Record; + inheritancePoints?: Record; +} + +/** + * Ref InheritancePointType::rebirthStoreCoeff. A null coefficient means that + * the point is not paid on rebirth and remains reserved for the final death or + * unification settlement. + */ +export const REBIRTH_INHERITANCE_COEFFICIENTS: Readonly> = { + lived_month: 1, + max_domestic_critical: null, + active_action: 1, + unifier: null, + tournament: 1, + max_belong: null, + combat: 1, + sabotage: 1, + dex: 0.5, + betting: 1, +}; + +const asRecord = (value: unknown): Record => + 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]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return 0; +}; + +const readStoredPoint = ( + general: InheritancePointGeneral, + key: MergedInheritanceKey, + 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}`); + if (dexterity > LEGACY_DEX_INHERITANCE_LIMIT) { + totalDexterity += (dexterity - LEGACY_DEX_INHERITANCE_LIMIT) / 3; + dexterity = LEGACY_DEX_INHERITANCE_LIMIT; + } + totalDexterity += dexterity; + } + return totalDexterity * 0.001; +}; + +export const computeBettingInheritancePoint = (general: InheritancePointGeneral): number => { + const wins = readNumber(general.meta, 'betwin', 'rank_betwin'); + const gold = readNumber(general.meta, 'betgold', 'rank_betgold'); + const wonGold = readNumber(general.meta, 'betwingold', 'rank_betwingold'); + const winRate = wonGold / Math.max(1000, gold); + return wins * 10 * winRate ** 2; +}; + +export const computeActiveInheritancePoint = ( + general: InheritancePointGeneral, + key: MergedInheritanceKey, + storedOverride?: number +): number => { + const stored = readStoredPoint(general, key, storedOverride); + switch (key) { + case 'lived_month': { + const value = readNumber(general.meta, 'inherit_lived_month'); + return value !== 0 ? value : stored; + } + case 'max_domestic_critical': + // Ref keeps the current streak in general.aux and the lifetime max + // in inheritance storage. Math.max also upgrades pre-fix live + // snapshots whose current streak has not yet been copied there. + return Math.max( + stored, + readNumber(general.meta, 'max_domestic_critical'), + readNumber(general.meta, 'inherit_max_domestic_critical') + ); + case 'active_action': { + const value = readNumber(general.meta, 'inherit_active_action'); + return value !== 0 ? value * 3 : stored; + } + case 'unifier': + case 'tournament': + return stored; + case 'max_belong': + return ( + Math.max( + readNumber(general.meta, 'belong'), + readNumber(general.meta, 'max_belong'), + readNumber(general.meta, 'inherit_max_belong') + ) * 10 + ); + case 'combat': + return readNumber(general.meta, 'rank_warnum', 'warnum') * 5; + case 'sabotage': + return readNumber(general.meta, 'firenum', 'rank_firenum') * 20; + case 'dex': + return computeDexInheritancePoint(general); + case 'betting': + return computeBettingInheritancePoint(general); + } +}; + +export interface InheritanceSettlementBreakdown { + earned: Record; + retained: Partial>; + totalEarned: number; +} + +export const computeInheritanceSettlementBreakdown = ( + general: InheritancePointGeneral, + isRebirth: boolean +): InheritanceSettlementBreakdown => { + const earned = {} as Record; + const retained: Partial> = {}; + let totalEarned = 0; + + for (const key of ALL_MERGED_INHERITANCE_KEYS) { + const value = computeActiveInheritancePoint(general, key); + const rebirthCoefficient = REBIRTH_INHERITANCE_COEFFICIENTS[key]; + if (isRebirth && rebirthCoefficient === null) { + earned[key] = 0; + retained[key] = value; + continue; + } + const settledValue = value * (isRebirth ? (rebirthCoefficient ?? 0) : 1); + earned[key] = settledValue; + totalEarned += settledValue; + } + + return { earned, retained, totalEarned }; +}; diff --git a/packages/logic/test/inheritancePointCalculation.test.ts b/packages/logic/test/inheritancePointCalculation.test.ts new file mode 100644 index 00000000..59da3989 --- /dev/null +++ b/packages/logic/test/inheritancePointCalculation.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; + +import { + ALL_MERGED_INHERITANCE_KEYS, + computeActiveInheritancePoint, + computeInheritanceSettlementBreakdown, +} from '../src/inheritance/pointCalculation.js'; + +describe('Ref inheritance point calculation', () => { + const general = { + meta: { + inherit_lived_month: 12, + max_domestic_critical: 20, + inherit_active_action: 0.5, + belong: 7, + max_belong: 9, + rank_warnum: 3, + firenum: 2, + dex1: 1_275_978, + dex2: 100, + event100_allstar: { granted: { dex2: 40 } }, + betwin: 2, + betgold: 2_000, + betwingold: 1_000, + }, + inheritancePoints: { + max_domestic_critical: 80, + unifier: 250, + tournament: 50, + }, + }; + + it('keeps all ten Ref sources distinct', () => { + expect(ALL_MERGED_INHERITANCE_KEYS).toEqual([ + 'lived_month', + 'max_domestic_critical', + 'active_action', + 'unifier', + 'tournament', + 'max_belong', + 'combat', + 'sabotage', + 'dex', + 'betting', + ]); + expect( + Object.fromEntries( + ALL_MERGED_INHERITANCE_KEYS.map((key) => [key, computeActiveInheritancePoint(general, key)]) + ) + ).toEqual({ + lived_month: 12, + max_domestic_critical: 80, + active_action: 1.5, + unifier: 250, + tournament: 50, + max_belong: 90, + combat: 15, + sabotage: 40, + dex: 1_276.036, + betting: 5, + }); + }); + + it('pays only Ref rebirth-enabled sources and retains the three delayed sources', () => { + const settlement = computeInheritanceSettlementBreakdown(general, true); + + expect(settlement.earned).toEqual({ + lived_month: 12, + max_domestic_critical: 0, + active_action: 1.5, + unifier: 0, + tournament: 50, + max_belong: 0, + combat: 15, + sabotage: 40, + dex: 638.018, + betting: 5, + }); + expect(settlement.retained).toEqual({ + max_domestic_critical: 80, + unifier: 250, + max_belong: 90, + }); + expect(settlement.totalEarned).toBeCloseTo(761.518, 8); + }); + + it('uses the current domestic streak only as a live upgrade candidate for the stored maximum', () => { + expect( + computeActiveInheritancePoint( + { + meta: { max_domestic_critical: 120 }, + inheritancePoints: { max_domestic_critical: 80 }, + }, + 'max_domestic_critical' + ) + ).toBe(120); + expect( + computeActiveInheritancePoint( + { + meta: { max_domestic_critical: 0 }, + inheritancePoints: { max_domestic_critical: 80 }, + }, + 'max_domestic_critical' + ) + ).toBe(80); + }); +}); diff --git a/packages/logic/test/scenarios/general_commands_new.test.ts b/packages/logic/test/scenarios/general_commands_new.test.ts index 13b67b0e..545983e0 100644 --- a/packages/logic/test/scenarios/general_commands_new.test.ts +++ b/packages/logic/test/scenarios/general_commands_new.test.ts @@ -251,7 +251,7 @@ describe('General Commands New Scenario', () => { items: { horse: null, weapon: null, book: null, item: null }, }, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, - meta: { killturn: 24 }, + meta: { killturn: 24, belong: 18, max_belong: 12 }, }; const snapshot: WorldSnapshot = { @@ -390,6 +390,7 @@ describe('General Commands New Scenario', () => { const g1_after_resign = world.getGeneral(1)!; expect(g1_after_resign.nationId).toBe(0); + expect(g1_after_resign.meta.max_belong).toBe(18); // 6. Retire (Needs age >= 60) // Manually set age