merge: 유산 포인트 적립 조건 정합화를 main에 반영한다

This commit is contained in:
2026-08-23 12:10:49 +00:00
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 },
}); });
await prisma.inheritancePoint.deleteMany({ if (isRebirth) {
where: { const retainedEntries = Object.entries(settlement.retained).filter(
userId, ([key, value]) => key === 'max_belong' || points.has(key) || value !== 0
key: isRebirth ? { notIn: ['previous', 'unifier'] } : { not: 'previous' }, );
}, 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 = 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