fix: Ref 유산 포인트 적립 조건을 전면 정합화한다
능동 행동 31개 호출 지점과 소유자·NPC·통일 경계를 고정하고 사용자 저장값을 함께 갱신한다.\n\n최대 내정·임관, 천통 기여, 토너먼트, 숙련·베팅·랭크 계산과 환생 정산을 공통 계산기로 통합한다.
This commit is contained in:
@@ -65,6 +65,8 @@ export class ActionResolver<
|
||||
|
||||
// Penalty
|
||||
const betrayal = typeof general.meta.betray === 'number' ? general.meta.betray : 0;
|
||||
const belong = typeof general.meta.belong === 'number' ? general.meta.belong : 0;
|
||||
const maxBelong = typeof general.meta.max_belong === 'number' ? general.meta.max_belong : 0;
|
||||
const penaltyRatio = betrayal * 0.1;
|
||||
const nextExp = Math.round(general.experience * (1 - penaltyRatio));
|
||||
const nextDed = Math.round(general.dedication * (1 - penaltyRatio));
|
||||
@@ -99,6 +101,7 @@ export class ActionResolver<
|
||||
...general.meta,
|
||||
betray: Math.min(9, betrayal + 1),
|
||||
belong: 0,
|
||||
...(general.npcState < 2 ? { max_belong: Math.max(belong, maxBelong) } : {}),
|
||||
makelimit: 12,
|
||||
officer_city: 0,
|
||||
permission: 'normal',
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
|
||||
meta: { killturn: 24 },
|
||||
meta: { killturn: 24, belong: 18, max_belong: 12 },
|
||||
};
|
||||
|
||||
const snapshot: WorldSnapshot = {
|
||||
@@ -390,6 +390,7 @@ describe('General Commands New Scenario', () => {
|
||||
|
||||
const g1_after_resign = world.getGeneral(1)!;
|
||||
expect(g1_after_resign.nationId).toBe(0);
|
||||
expect(g1_after_resign.meta.max_belong).toBe(18);
|
||||
|
||||
// 6. Retire (Needs age >= 60)
|
||||
// Manually set age
|
||||
|
||||
Reference in New Issue
Block a user