fix: 은퇴 명예 기록과 유산 수명주기 정합성을 복원한다

은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다.

유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
This commit is contained in:
2026-08-24 03:38:12 +00:00
parent 95cf68dffd
commit 4fc20de8d4
33 changed files with 4160 additions and 1166 deletions
@@ -22,6 +22,9 @@ const ACTION_NAME = '은퇴';
const ACTION_KEY = 'che_은퇴';
const REQ_AGE = 60;
const hasPendingRandomUnique = (value: unknown): boolean =>
value === true || value === 1 || (typeof value === 'string' && (value === '1' || value.toLowerCase() === 'true'));
const reqGeneralValue = (): Constraint => ({
name: 'reqGeneralValue',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
@@ -43,18 +46,6 @@ export class ActionResolver<
const general = context.general;
const effects: GeneralActionEffect<TriggerState>[] = [];
const nextMeta = { ...general.meta };
for (const key of ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const) {
const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0;
nextMeta[key] = Math.round(value * 0.5);
}
delete nextMeta.specAge;
delete nextMeta.specAge2;
nextMeta.specage = 0;
nextMeta.specage2 = 0;
for (const type of LEGACY_RANK_DATA_TYPES) {
nextMeta[rankDataMetaKey(type)] = 0;
}
const josaYi = JosaUtil.pick(general.name, '이');
context.addLog(`<Y>${general.name}</>${josaYi} <R>은퇴</>하고 그 자손이 유지를 이어받았습니다.`, {
@@ -75,7 +66,32 @@ export class ActionResolver<
format: LogFormat.MONTH,
});
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
const hadPendingRandomUnique = hasPendingRandomUnique(general.meta.inheritRandomUnique);
const acquiredUnique = tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
const refundedPendingRandomUnique =
hadPendingRandomUnique && !acquiredUnique && !hasPendingRandomUnique(general.meta.inheritRandomUnique);
const postLotterySpentDynamic = general.meta.inherit_spent_dyn;
// The lottery can consume a pending inheritance reservation and mutate meta.
// Build the reborn projection afterwards so the consumed flag is not restored
// by the action patch while still applying the retirement resets atomically.
const nextMeta = { ...general.meta };
for (const key of ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const) {
const value = typeof nextMeta[key] === 'number' ? nextMeta[key] : 0;
nextMeta[key] = Math.round(value * 0.5);
}
delete nextMeta.specAge;
delete nextMeta.specAge2;
nextMeta.specage = 0;
nextMeta.specage2 = 0;
nextMeta.inherit_lived_month = 0;
nextMeta.inherit_active_action = 0;
for (const type of LEGACY_RANK_DATA_TYPES) {
nextMeta[rankDataMetaKey(type)] = 0;
}
if (refundedPendingRandomUnique && typeof postLotterySpentDynamic === 'number') {
nextMeta.inherit_spent_dyn = postLotterySpentDynamic;
}
effects.push(
createGeneralPatchEffect(
@@ -1,3 +1,5 @@
import { readCentennialRecordableDexterity, type CentennialDexKey } from '../scenario/centennialAllStar.js';
export const LEGACY_DEX_INHERITANCE_LIMIT = 1_275_975;
export const ALL_MERGED_INHERITANCE_KEYS = [
@@ -38,9 +40,6 @@ export const REBIRTH_INHERITANCE_COEFFICIENTS: Readonly<Record<MergedInheritance
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];
@@ -59,17 +58,10 @@ const readStoredPoint = (
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}`);
let dexterity = readCentennialRecordableDexterity(general.meta, `dex${index}` as CentennialDexKey);
if (dexterity > LEGACY_DEX_INHERITANCE_LIMIT) {
totalDexterity += (dexterity - LEGACY_DEX_INHERITANCE_LIMIT) / 3;
dexterity = LEGACY_DEX_INHERITANCE_LIMIT;
+18 -7
View File
@@ -44,6 +44,12 @@ export type UniqueLotteryInput = {
inheritRandomUnique?: boolean;
};
export type UniqueLotteryOutcome =
| { status: 'NO_SLOT' }
| { status: 'ROLL_FAILED' }
| { status: 'NO_SUPPLY' }
| { status: 'ACQUIRED'; itemKey: string };
const DEFAULT_MAX_UNIQUE_ITEM_LIMIT: Array<[number, number]> = [
[-1, 1],
[3, 2],
@@ -183,7 +189,7 @@ export const buildGenericUniqueSeed = (
export const buildVoteUniqueSeed = (hiddenSeed: string | number, voteId: number, generalId: number): string =>
serializeSeed(hiddenSeed, 'voteUnique', voteId, generalId);
export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
export const rollUniqueLotteryDetailed = (input: UniqueLotteryInput): UniqueLotteryOutcome => {
const {
rng,
config,
@@ -203,13 +209,13 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
const resolvedAcquireType = acquireType ?? '아이템';
if (userCount <= 0) {
return null;
return { status: 'ROLL_FAILED' };
}
const itemTypes = Object.keys(config.allItems);
const itemTypeCnt = itemTypes.length;
if (itemTypeCnt <= 0) {
return null;
return { status: 'NO_SLOT' };
}
const relYear = currentYear - startYear;
@@ -246,7 +252,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
}
if (trialCnt <= 0 || maxCnt <= 0) {
return null;
return { status: 'NO_SLOT' };
}
const relMonthByInit = joinYearMonth(currentYear, currentMonth) - joinYearMonth(initYear, initMonth);
@@ -289,7 +295,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
}
if (!success) {
return null;
return { status: 'ROLL_FAILED' };
}
const availableUnique: Array<[string, number]> = [];
@@ -315,10 +321,15 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
}
if (availableUnique.length === 0) {
return null;
return { status: 'NO_SUPPLY' };
}
return rng.choiceUsingWeightPair(availableUnique);
return { status: 'ACQUIRED', itemKey: rng.choiceUsingWeightPair(availableUnique) };
};
export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => {
const outcome = rollUniqueLotteryDetailed(input);
return outcome.status === 'ACQUIRED' ? outcome.itemKey : null;
};
const applyUniqueItemGain = <TriggerState extends GeneralTriggerState = GeneralTriggerState>(
@@ -20,7 +20,7 @@ const STAT_KEYS = ['leadership', 'strength', 'intel'] as const;
const DEX_KEYS = ['dex1', 'dex2', 'dex3', 'dex4', 'dex5'] as const;
type CentennialStatKey = (typeof STAT_KEYS)[number];
type CentennialDexKey = (typeof DEX_KEYS)[number];
export type CentennialDexKey = (typeof DEX_KEYS)[number];
export interface CentennialAllStarTarget {
uniqueName: string;
@@ -670,3 +670,14 @@ export const reconcileCentennialDexConversion = (
export const centennialRecordableValue = (current: number, granted: number): number =>
Math.max(0, current - Math.max(0, granted));
/**
* Ref CentennialAllStarGrowthService::recordableRawValue. Event-provided
* mastery is useful during the season, but must not enter permanent ranking,
* Hall of Fame, or inheritance records.
*/
export const readCentennialRecordableDexterity = (meta: Record<string, unknown>, key: CentennialDexKey): number => {
const current = asNumber(meta[key], 0);
const granted = asNumber(asRecord(asRecord(meta[CENTENNIAL_ALL_STAR_AUX_KEY]).granted)[key], 0);
return centennialRecordableValue(current, granted);
};