fix: Ref 유산 포인트 적립 조건을 전면 정합화한다

능동 행동 31개 호출 지점과 소유자·NPC·통일 경계를 고정하고 사용자 저장값을 함께 갱신한다.\n\n최대 내정·임관, 천통 기여, 토너먼트, 숙련·베팅·랭크 계산과 환생 정산을 공통 계산기로 통합한다.
This commit is contained in:
2026-08-23 12:06:59 +00:00
parent 2f793a9518
commit 18e0bed30a
19 changed files with 712 additions and 245 deletions
+17 -2
View File
@@ -271,12 +271,27 @@ export const inheritRouter = router({
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); 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 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({ const items = await computeInheritanceItems({
db: ctx.db, db: ctx.db,
userId, userId,
generalMeta: asRecord(general.meta), generalMeta: calculationMeta,
isUnited, isUnited,
}); });
const totalPoint = sumInheritanceItems(items); const totalPoint = sumInheritanceItems(items);
+22 -57
View File
@@ -1,5 +1,9 @@
import { asNumber, asRecord } from '@sammo-ts/common'; import { asNumber, asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra'; 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'; import type { DatabaseClient, WorldStateRow, InputJsonValue } from '../context.js';
export type InheritPointKey = export type InheritPointKey =
@@ -163,73 +167,34 @@ export const appendInheritanceLog = async (
}); });
}; };
const readUserMetaValue = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
}
return value;
};
const computeDexPoint = (meta: Record<string, unknown>): 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: { export const computeInheritanceItems = async (options: {
db: DatabaseClient; db: DatabaseClient;
userId: string; userId: string;
generalMeta: Record<string, unknown> | null; generalMeta: Record<string, unknown> | null;
isUnited: boolean; isUnited: boolean;
}): Promise<Record<InheritPointKey, number>> => { }): Promise<Record<InheritPointKey, number>> => {
const previous = await readInheritancePoint(options.db, options.userId, 'previous'); const pointRows = await options.db.inheritancePoint.findMany({
const unifier = await readInheritancePoint(options.db, options.userId, 'unifier'); 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) { if (options.isUnited) {
return { return Object.fromEntries([
previous, ['previous', previous],
lived_month: 0, ...ALL_MERGED_INHERITANCE_KEYS.map((key) => [key, inheritancePoints[key] ?? 0] as const),
max_domestic_critical: 0, ]) as Record<InheritPointKey, number>;
active_action: 0,
combat: 0,
sabotage: 0,
dex: 0,
unifier,
tournament: 0,
betting: 0,
max_belong: 0,
};
} }
const meta = options.generalMeta ?? {}; return Object.fromEntries([
const livedMonth = readUserMetaValue(meta, 'inherit_lived_month'); ['previous', previous],
const maxDomestic = readUserMetaValue(meta, 'max_domestic_critical'); ...ALL_MERGED_INHERITANCE_KEYS.map((key) => [key, computeActiveInheritancePoint(general, key)] as const),
const activeAction = readUserMetaValue(meta, 'inherit_active_action'); ]) as Record<InheritPointKey, number>;
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,
};
}; };
export const sumInheritanceItems = (items: Record<InheritPointKey, number>): number => { export const sumInheritanceItems = (items: Record<InheritPointKey, number>): number => {
+60
View File
@@ -106,6 +106,8 @@ const buildContext = (options: {
general?: GeneralRow | null; general?: GeneralRow | null;
target?: GeneralRow | null; target?: GeneralRow | null;
inheritancePoint?: number; 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 }>; inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
configConst?: Record<string, unknown>; configConst?: Record<string, unknown>;
}) => { }) => {
@@ -176,6 +178,12 @@ const buildContext = (options: {
}, },
inheritancePoint: { inheritancePoint: {
upsert: pointUpsert, upsert: pointUpsert,
findMany: vi.fn(
async () => options.inheritanceRows ?? [{ key: 'previous', value: options.inheritancePoint ?? 10_000 }]
),
},
rankData: {
findMany: vi.fn(async () => options.rankRows ?? []),
}, },
inheritanceLog: { inheritanceLog: {
create: logCreate, 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: '{}' }])( it.each([{}, { allItems: '{}' }])(
'restores selectable Ref default uniques for a legacy scenario config: %j', 'restores selectable Ref default uniques for a legacy scenario config: %j',
async (configConst) => { async (configConst) => {
+11 -2
View File
@@ -142,12 +142,12 @@ export const createTournamentRewardFinalizer = async (options: {
const nameMap = new Map<number, string>(); const nameMap = new Map<number, string>();
const generals = await db.general.findMany({ const generals = await db.general.findMany({
where: { id: { in: Array.from(rewardMap.keys()) } }, 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<number, string>(); const userMap = new Map<number, string>();
for (const general of generals) { for (const general of generals) {
nameMap.set(general.id, general.name); nameMap.set(general.id, general.name);
if (general.userId) { if (general.userId && general.npcState < 2) {
userMap.set(general.id, general.userId); userMap.set(general.id, general.userId);
} }
} }
@@ -261,6 +261,15 @@ export const createTournamentRewardFinalizer = async (options: {
update: { value: { increment: entry.value } }, update: { value: { increment: entry.value } },
create: { userId: entry.userId!, key: 'tournament', value: 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 { return {
+14 -15
View File
@@ -1241,21 +1241,6 @@ export const createDatabaseTurnHooks = async (
const meta = asRecord(state.meta); const meta = asRecord(state.meta);
const serverId = const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default'; 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) { if (inheritancePointAdjustments.length > 0) {
const grouped = new Map<string, { userId: string; key: string; amount: number }>(); const grouped = new Map<string, { userId: string; key: string; amount: number }>();
for (const entry of inheritancePointAdjustments) { 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) { if (deletedNationSnapshots.length > 0) {
const nationIds = deletedNationSnapshots.map((snapshot) => snapshot.nation.id); const nationIds = deletedNationSnapshots.map((snapshot) => snapshot.nation.id);
@@ -1,6 +1,7 @@
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope } from '@sammo-ts/logic'; import { LogCategory, LogScope } from '@sammo-ts/logic';
import { computeInheritanceSettlementBreakdown } from '@sammo-ts/logic/inheritance/pointCalculation.js';
import type { GeneralLifecycleEvent } from './inMemoryWorld.js'; import type { GeneralLifecycleEvent } from './inMemoryWorld.js';
@@ -23,14 +24,6 @@ const readWorldNumber = (record: Record<string, unknown>, key: string, fallback:
return value === 0 && record[key] === undefined ? fallback : Math.floor(value); return value === 0 && record[key] === undefined ? fallback : Math.floor(value);
}; };
const computeDexPoint = (meta: Record<string, unknown>): number => {
let total = 0;
for (let dex = 1; dex <= 5; dex += 1) {
total += readNumber(meta, `dex${dex}`);
}
return total * 0.001;
};
const settleInheritance = async ( const settleInheritance = async (
prisma: GamePrisma.TransactionClient, prisma: GamePrisma.TransactionClient,
event: GeneralLifecycleEvent, event: GeneralLifecycleEvent,
@@ -71,8 +64,6 @@ const settleInheritance = async (
}), }),
]); ]);
const points = new Map(rows.map((row) => [row.key, row.value])); 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 previous = points.get('previous') ?? 0;
const randomUniqueRefund = meta.inheritRandomUnique const randomUniqueRefund = meta.inheritRandomUnique
? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000)
@@ -81,30 +72,45 @@ const settleInheritance = async (
? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000) ? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000)
: 0; : 0;
const refund = randomUniqueRefund + specificSpecialRefund; const refund = randomUniqueRefund + specificSpecialRefund;
const lived = readNumber(meta, 'inherit_lived_month'); const calculationMeta = {
const maxBelong = readNumber(meta, 'inherit_max_belong') * 10; ...meta,
const maxDomestic = readNumber(meta, 'max_domestic_critical'); ...Object.fromEntries(rankRows.map((row) => [row.type, row.value])),
const active = readNumber(meta, 'inherit_active_action') * 3; ...Object.fromEntries(rankRows.map((row) => [`rank_${row.type}`, row.value])),
const combat = rank('warnum') * 5; };
const sabotage = (ranks.get('firenum') ?? readNumber(meta, 'firenum')) * 20; const settlement = computeInheritanceSettlementBreakdown(
const dex = computeDexPoint(meta); {
const unifier = points.get('unifier') ?? 0; meta: calculationMeta,
const earned = isRebirth inheritancePoints: Object.fromEntries(points),
? lived + active + combat + sabotage + dex * 0.5 },
: lived + maxBelong + maxDomestic + active + combat + sabotage + dex + unifier; isRebirth
const total = Math.trunc(previous + refund + earned); );
const total = Math.trunc(previous + refund + settlement.totalEarned);
await prisma.inheritancePoint.upsert({ await prisma.inheritancePoint.upsert({
where: { userId_key: { userId, key: 'previous' } }, where: { userId_key: { userId, key: 'previous' } },
update: { value: total }, update: { value: total },
create: { userId, key: 'previous', value: total }, create: { userId, key: 'previous', value: total },
}); });
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({ await prisma.inheritancePoint.deleteMany({
where: { where: {
userId, userId,
key: isRebirth ? { notIn: ['previous', 'unifier'] } : { not: 'previous' }, key: { notIn: ['previous', ...retainedEntries.map(([key]) => key)] },
}, },
}); });
} else {
await prisma.inheritancePoint.deleteMany({ where: { userId, key: { not: 'previous' } } });
}
const serverId = const serverId =
typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default';
await prisma.inheritanceResult.create({ await prisma.inheritanceResult.create({
@@ -117,15 +123,10 @@ const settleInheritance = async (
value: asJson({ value: asJson({
previous, previous,
refund, refund,
lived_month: lived, ...settlement.earned,
max_belong: maxBelong, ...(isRebirth ? { retained: settlement.retained } : {}),
max_domestic_critical: maxDomestic,
active_action: active,
combat,
sabotage,
dex: isRebirth ? dex * 0.5 : dex,
unifier: isRebirth ? 0 : unifier,
rebirth: isRebirth, rebirth: isRebirth,
total,
}), }),
}, },
}); });
@@ -1,97 +1,12 @@
const DEX_LIMIT = 1_275_975; export {
ALL_MERGED_INHERITANCE_KEYS,
interface InheritancePointGeneral { computeActiveInheritancePoint,
meta: Record<string, unknown>; computeBettingInheritancePoint,
inheritancePoints?: Record<string, number>; computeDexInheritancePoint,
} computeInheritanceSettlementBreakdown,
LEGACY_DEX_INHERITANCE_LIMIT,
const STORED_INHERITANCE_KEYS = [ REBIRTH_INHERITANCE_COEFFICIENTS,
'lived_month', type InheritancePointGeneral,
'max_domestic_critical', type InheritanceSettlementBreakdown,
'active_action', type MergedInheritanceKey,
'unifier', } from '@sammo-ts/logic/inheritance/pointCalculation.js';
'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<string, unknown>, 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);
}
};
@@ -375,8 +375,15 @@ export const createUpdateNationLevelHandler = (options: {
}); });
} }
const isUnited = readNumber(state.meta.isunited ?? state.meta.isUnited); const isUnited = readNumber(state.meta.isunited ?? state.meta.isUnited);
if (chief?.userId && isUnited === 0) { if (chief?.userId && chief.npcState < 2 && isUnited === 0) {
world.queueInheritancePointAdjustment(chief.userId, 'unifier', 250 * levelDiff); const amount = 250 * levelDiff;
world.queueInheritancePointAdjustment(chief.userId, 'unifier', amount);
world.updateGeneral(chief.id, {
inheritancePoints: {
...chief.inheritancePoints,
unifier: readNumber(chief.inheritancePoints?.unifier) + amount,
},
});
} }
} }
}; };
+113 -17
View File
@@ -268,6 +268,7 @@ const readConfigNumber = (config: ScenarioConfig, key: string, fallback: number)
const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({ const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({
...general, ...general,
...(general.inheritancePoints ? { inheritancePoints: { ...general.inheritancePoints } } : {}),
stats: { ...general.stats }, stats: { ...general.stats },
role: { role: {
...general.role, ...general.role,
@@ -372,6 +373,25 @@ const readMetaNumber = (meta: Record<string, unknown>, key: string, fallback: nu
return fallback; 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<TurnGeneral, 'userId' | 'npcState'>,
worldMeta: Record<string, unknown>
): general is Pick<TurnGeneral, 'userId' | 'npcState'> & { userId: string } =>
Boolean(general.userId) &&
general.npcState < 2 &&
readMetaNumber(worldMeta, 'isunited', readMetaNumber(worldMeta, 'isUnited', 0)) === 0;
const readMetaBool = (meta: Record<string, unknown>, key: string, fallback = false): boolean => { const readMetaBool = (meta: Record<string, unknown>, key: string, fallback = false): boolean => {
const value = meta[key]; const value = meta[key];
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
@@ -1214,6 +1234,34 @@ export const createReservedTurnHandler = async (options: {
currentGeneral = resolution.general as TurnGeneral; currentGeneral = resolution.general as TurnGeneral;
currentCity = resolution.city ?? currentCity; currentCity = resolution.city ?? currentCity;
currentNation = resolution.nation ?? currentNation; 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) { if (!resolution.alternative && !usedFallback && resolution.completed) {
currentGeneral = applyLegacyGeneralProgression( currentGeneral = applyLegacyGeneralProgression(
currentGeneral, currentGeneral,
@@ -1229,13 +1277,20 @@ export const createReservedTurnHandler = async (options: {
!usedFallback && !usedFallback &&
resolution.completed && resolution.completed &&
definition.countsAsInheritanceActiveAction && definition.countsAsInheritanceActiveAction &&
Boolean(currentGeneral.userId) && inheritanceUserId
currentGeneral.npcState < 2
) { ) {
const meta = { ...currentGeneral.meta }; const meta = { ...currentGeneral.meta };
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
meta.inherit_active_action = active + 1; 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 ( if (
!resolution.alternative && !resolution.alternative &&
@@ -1243,17 +1298,53 @@ export const createReservedTurnHandler = async (options: {
!usedFallback && !usedFallback &&
resolution.completed && resolution.completed &&
executionDefinition.getInheritanceActiveActionAmount && executionDefinition.getInheritanceActiveActionAmount &&
Boolean(currentGeneral.userId) && inheritanceEnabled
currentGeneral.npcState < 2
) { ) {
const amount = executionDefinition.getInheritanceActiveActionAmount(actionContext, actionArgs); const amount = executionDefinition.getInheritanceActiveActionAmount(actionContext, actionArgs);
if (Number.isFinite(amount) && amount !== 0) { if (Number.isFinite(amount) && amount !== 0) {
const meta = { ...currentGeneral.meta }; const meta = { ...currentGeneral.meta };
const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0; const active = typeof meta.inherit_active_action === 'number' ? meta.inherit_active_action : 0;
meta.inherit_active_action = active + amount; 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) { if (!currentNation && resolution.created?.nations) {
currentNation = currentNation =
@@ -1551,9 +1642,14 @@ export const createReservedTurnHandler = async (options: {
const lifecycleBefore = cloneTurnGeneral(currentGeneral); const lifecycleBefore = cloneTurnGeneral(currentGeneral);
currentGeneral = cloneTurnGeneral(currentGeneral); currentGeneral = cloneTurnGeneral(currentGeneral);
if (currentGeneral.npcState < 2) { if (canAccumulateInheritance(currentGeneral, asRecord(context.world.meta))) {
currentGeneral.meta.inherit_lived_month = currentGeneral.meta.inherit_lived_month =
readMetaNumber(currentGeneral.meta, 'inherit_lived_month', 0) + 1; 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( const preprocessRng = new RandUtil(
new LiteHashDRBG( new LiteHashDRBG(
@@ -1626,10 +1722,7 @@ export const createReservedTurnHandler = async (options: {
currentGeneral.crew = 0; currentGeneral.crew = 0;
currentGeneral.rice = 0; currentGeneral.rice = 0;
logs.push( logs.push(
createGeneralActionLog( createGeneralActionLog(currentGeneral.id, '군량이 모자라 병사들이 <R>소집해제</>되었습니다!')
currentGeneral.id,
'군량이 모자라 병사들이 <R>소집해제</>되었습니다!'
)
); );
preTurnContext.skill.activate('pre.소집해제'); preTurnContext.skill.activate('pre.소집해제');
} }
@@ -2125,10 +2218,7 @@ export const createReservedTurnHandler = async (options: {
currentGeneral = resetRetiredGeneral(currentGeneral); currentGeneral = resetRetiredGeneral(currentGeneral);
lifecycleOutcome = 'retired'; lifecycleOutcome = 'retired';
logs.push( logs.push(
createGeneralActionLog( createGeneralActionLog(currentGeneral.id, '나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.')
currentGeneral.id,
'나이가 들어 <R>은퇴</>하고 자손에게 자리를 물려줍니다.'
)
); );
} }
@@ -2384,11 +2474,17 @@ export const createImmediateGeneralActionExecutor = async (options: {
if ( if (
Number.isFinite(activeActionAmount) && Number.isFinite(activeActionAmount) &&
activeActionAmount !== 0 && activeActionAmount !== 0 &&
nextGeneral.userId && canAccumulateInheritance(nextGeneral, asRecord(state.meta))
nextGeneral.npcState < 2
) { ) {
const pointAmount = activeActionAmount * 3;
options.world.queueInheritancePointAdjustment(nextGeneral.userId, 'active_action', pointAmount);
nextGeneral = { nextGeneral = {
...nextGeneral, ...nextGeneral,
inheritancePoints: {
...nextGeneral.inheritancePoints,
active_action:
readInheritanceNumber(nextGeneral.inheritancePoints?.active_action) + pointAmount,
},
meta: { meta: {
...nextGeneral.meta, ...nextGeneral.meta,
inherit_active_action: inherit_active_action:
@@ -215,16 +215,16 @@ integration('game cancellation transaction', () => {
[userId]: { [userId]: {
openingPoint: 10_000, openingPoint: 10_000,
currentPoint: 7_000, currentPoint: 7_000,
earnedPoint: 1_750.005, earnedPoint: 1_790.005,
retainedEarnedPoint: 700, retainedEarnedPoint: 716,
finalPoint: 10_700, finalPoint: 10_716,
baselineSource: 'OPENING', baselineSource: 'OPENING',
}, },
}, },
}); });
await expect( await expect(
db.inheritancePoint.findMany({ where: { userId }, orderBy: { key: 'asc' } }) 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({ await expect(db.gameHistory.findUniqueOrThrow({ where: { serverId } })).resolves.toMatchObject({
status: 'ABANDONED', status: 'ABANDONED',
winnerNation: null, winnerNation: null,
@@ -391,7 +391,9 @@ describe('legacy general-turn execution contract', () => {
expect(updated.injury).toBe(10); expect(updated.injury).toBe(10);
expect(updated.experience).toBe(0); expect(updated.experience).toBe(0);
expect(updated.meta.killturn).toBe(4); 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(updated.meta.myset).toBe(3);
expect(harness.reservedTurnStore.getGeneralTurn(1, 0).action).toBe('휴식'); expect(harness.reservedTurnStore.getGeneralTurn(1, 0).action).toBe('휴식');
expect(harness.getCollectedLogs().some((log) => log.text.includes('악성유저'))).toBe(true); expect(harness.getCollectedLogs().some((log) => log.text.includes('악성유저'))).toBe(true);
@@ -96,19 +96,28 @@ integration('general turn lifecycle persistence', () => {
killturn: 0, killturn: 0,
inherit_lived_month: 10, inherit_lived_month: 10,
inherit_active_action: 2, inherit_active_action: 2,
max_belong: 9,
inheritRandomUnique: true, inheritRandomUnique: true,
dex1: 1_000, dex1: 1_000,
dex2: 1, dex2: 1,
dex3: 1, dex3: 1,
dex4: 1, dex4: 1,
dex5: 1, dex5: 1,
betwin: 2,
betgold: 2_000,
betwingold: 1_000,
}, },
}); });
await db.generalAccessLog.create({ await db.generalAccessLog.create({
data: { generalId: general.id, userId: general.userId, refreshScore: 99 }, data: { generalId: general.id, userId: general.userId, refreshScore: 99 },
}); });
await db.inheritancePoint.create({ await db.inheritancePoint.createMany({
data: { userId: general.userId!, key: 'previous', value: 100 }, 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({ await db.rankData.createMany({
data: [ data: [
@@ -202,7 +211,7 @@ integration('general turn lifecycle persistence', () => {
await db.inheritancePoint.findUnique({ await db.inheritancePoint.findUnique({
where: { userId_key: { userId: general.userId!, key: 'previous' } }, where: { userId_key: { userId: general.userId!, key: 'previous' } },
}) })
).toMatchObject({ value: 3_147 }); ).toMatchObject({ value: 3_622 });
expect( expect(
( (
await db.inheritanceLog.findMany({ await db.inheritanceLog.findMany({
@@ -211,16 +220,33 @@ integration('general turn lifecycle persistence', () => {
select: { text: true }, select: { text: true },
}) })
).map(({ text }) => text) ).map(({ text }) => text)
).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,147 포인트']); ).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,622 포인트']);
}); });
it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => { 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({ await db.generalAccessLog.create({
data: { generalId: general.id, userId: general.userId, refreshScore: 77 }, data: { generalId: general.id, userId: general.userId, refreshScore: 77 },
}); });
await db.inheritancePoint.create({ await db.inheritancePoint.createMany({
data: { userId: general.userId!, key: 'previous', value: 50 }, 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({ await db.rankData.create({
data: { generalId: general.id, nationId: 0, type: 'warnum', value: 10 }, data: { generalId: general.id, nationId: 0, type: 'warnum', value: 10 },
@@ -256,7 +282,19 @@ integration('general turn lifecycle persistence', () => {
await db.inheritancePoint.findUnique({ await db.inheritancePoint.findUnique({
where: { userId_key: { userId: general.userId!, key: 'previous' } }, 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 () => { it('does not settle a possessed NPC before the legacy minimum possession period', async () => {
@@ -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());
});
});
@@ -216,6 +216,7 @@ describe('UpdateNationLevel monthly action', () => {
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 500 }, { userId: 'user-1', key: 'unifier', amount: 500 },
]); ]);
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(500);
expect(world.peekDirtyState().logs).toEqual( expect(world.peekDirtyState().logs).toEqual(
expect.arrayContaining([ expect.arrayContaining([
{ {
@@ -292,6 +293,7 @@ describe('UpdateNationLevel monthly action', () => {
expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([ expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 250 }, { 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 () => { 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([ expect(world.peekDirtyState().inheritancePointAdjustments).toEqual([
{ userId: 'user-1', key: 'unifier', amount: 250 }, { userId: 'user-1', key: 'unifier', amount: 250 },
]); ]);
expect(world.getGeneralById(1)?.inheritancePoints?.unifier).toBe(250);
}); });
}); });
@@ -495,9 +495,7 @@ describe('Reserved Turn Execution Integration', () => {
category: 'ACTION', category: 'ACTION',
generalId: 1, generalId: 1,
}); });
const personalActionLogs = dirty.logs.filter( const personalActionLogs = dirty.logs.filter((log) => log.scope === 'GENERAL' && log.category === 'ACTION');
(log) => log.scope === 'GENERAL' && log.category === 'ACTION'
);
expect(personalActionLogs.length).toBeGreaterThan(0); expect(personalActionLogs.length).toBeGreaterThan(0);
expect(personalActionLogs.every((log) => log.generalId === 1)).toBe(true); expect(personalActionLogs.every((log) => log.generalId === 1)).toBe(true);
expect( expect(
@@ -679,6 +677,7 @@ describe('Reserved Turn Execution Integration', () => {
const generals: TurnGeneral[] = [ const generals: TurnGeneral[] = [
{ {
id: 1, id: 1,
userId: 'founder-user',
name: 'General_Leader', name: 'General_Leader',
nationId: 0, nationId: 0,
cityId: 1, cityId: 1,
@@ -708,6 +707,7 @@ describe('Reserved Turn Execution Integration', () => {
}, },
{ {
id: 2, id: 2,
userId: 'domestic-user',
name: 'General_Sub', name: 'General_Sub',
nationId: 0, nationId: 0,
cityId: 1, cityId: 1,
@@ -973,6 +973,14 @@ describe('Reserved Turn Execution Integration', () => {
expect(world.getCityById(1)!.agriculture).toBe(100); expect(world.getCityById(1)!.agriculture).toBe(100);
expect(world.getCityById(1)!.nationId).toBe(0); // City 1 is still unowned expect(world.getCityById(1)!.nationId).toBe(0); // City 1 is still unowned
expect(world.getCityById(2)!.agriculture).toBeGreaterThan(100); // City 2 agric increased 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 () => { it('should fail founding with specific constraints', async () => {
@@ -65,6 +65,8 @@ export class ActionResolver<
// Penalty // Penalty
const betrayal = typeof general.meta.betray === 'number' ? general.meta.betray : 0; 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 penaltyRatio = betrayal * 0.1;
const nextExp = Math.round(general.experience * (1 - penaltyRatio)); const nextExp = Math.round(general.experience * (1 - penaltyRatio));
const nextDed = Math.round(general.dedication * (1 - penaltyRatio)); const nextDed = Math.round(general.dedication * (1 - penaltyRatio));
@@ -99,6 +101,7 @@ export class ActionResolver<
...general.meta, ...general.meta,
betray: Math.min(9, betrayal + 1), betray: Math.min(9, betrayal + 1),
belong: 0, belong: 0,
...(general.npcState < 2 ? { max_belong: Math.max(belong, maxBelong) } : {}),
makelimit: 12, makelimit: 12,
officer_city: 0, officer_city: 0,
permission: 'normal', permission: 'normal',
@@ -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<string, unknown>;
inheritancePoints?: Record<string, number>;
}
/**
* 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<Record<MergedInheritanceKey, number | null>> = {
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<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const readNumber = (source: Record<string, unknown>, ...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<MergedInheritanceKey, number>;
retained: Partial<Record<MergedInheritanceKey, number>>;
totalEarned: number;
}
export const computeInheritanceSettlementBreakdown = (
general: InheritancePointGeneral,
isRebirth: boolean
): InheritanceSettlementBreakdown => {
const earned = {} as Record<MergedInheritanceKey, number>;
const retained: Partial<Record<MergedInheritanceKey, number>> = {};
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 };
};
@@ -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);
});
});
@@ -251,7 +251,7 @@ describe('General Commands New Scenario', () => {
items: { horse: null, weapon: null, book: null, item: null }, items: { horse: null, weapon: null, book: null, item: null },
}, },
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 }, meta: { killturn: 24, belong: 18, max_belong: 12 },
}; };
const snapshot: WorldSnapshot = { const snapshot: WorldSnapshot = {
@@ -390,6 +390,7 @@ describe('General Commands New Scenario', () => {
const g1_after_resign = world.getGeneral(1)!; const g1_after_resign = world.getGeneral(1)!;
expect(g1_after_resign.nationId).toBe(0); expect(g1_after_resign.nationId).toBe(0);
expect(g1_after_resign.meta.max_belong).toBe(18);
// 6. Retire (Needs age >= 60) // 6. Retire (Needs age >= 60)
// Manually set age // Manually set age