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
+52
View File
@@ -79,6 +79,33 @@ export interface TurnDaemonSelectPoolReservation {
candidates: TurnDaemonSelectPoolCandidate[];
}
export type TurnDaemonInheritanceAction =
| {
action: 'buyHiddenBuff';
buffType:
| 'warAvoidRatio'
| 'warCriticalRatio'
| 'warMagicTrialProb'
| 'domesticSuccessProb'
| 'domesticFailProb'
| 'warAvoidRatioOppose'
| 'warCriticalRatioOppose'
| 'warMagicTrialProbOppose';
level: number;
}
| { action: 'setNextSpecialWar'; specialKey: string }
| { action: 'resetSpecialWar' }
| { action: 'resetTurnTime' }
| {
action: 'resetStat';
leadership: number;
strength: number;
intel: number;
inheritBonusStat?: [number, number, number];
}
| { action: 'buyRandomUnique' }
| { action: 'checkOwner'; targetGeneralId: number };
export type TurnDaemonCommand =
| {
type: 'run';
@@ -266,6 +293,12 @@ export type TurnDaemonCommand =
specialWar?: string | null;
};
}
| {
type: 'inheritanceAction';
requestId?: string;
userId: string;
input: TurnDaemonInheritanceAction;
}
| {
type: 'adjustGeneralIcon';
requestId?: string;
@@ -635,6 +668,25 @@ export type TurnDaemonCommandResult =
generalId: number;
reason: string;
}
| {
type: 'inheritanceAction';
ok: true;
action: TurnDaemonInheritanceAction['action'];
generalId: number;
remainPoint: number;
nextTurnTimeBase?: number;
nextTurnTimeLabel?: string;
stats?: { leadership: number; strength: number; intel: number };
ownerName?: string;
targetName?: string;
}
| {
type: 'inheritanceAction';
ok: false;
action: TurnDaemonInheritanceAction['action'];
code: 'BAD_REQUEST' | 'FORBIDDEN' | 'PRECONDITION_FAILED' | 'INTERNAL_SERVER_ERROR';
reason: string;
}
| {
type: 'adjustGeneralIcon';
ok: true;
@@ -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);
};
+46
View File
@@ -10,6 +10,7 @@ import {
countOccupiedUniqueItems,
resolveUniqueConfig,
rollUniqueLottery,
rollUniqueLotteryDetailed,
} from '../src/rewards/uniqueLottery.js';
const buildItem = (key: string, slot: ItemModule['slot'], buyable = false): ItemModule => ({
@@ -118,6 +119,51 @@ describe('unique lottery', () => {
expect(result).toBe('itemB');
});
it('distinguishes no slot, a failed roll, and exhausted supply', () => {
const itemRegistry = buildRegistry();
const base = {
itemRegistry,
scenarioId: 200,
userCount: 1,
currentYear: 200,
currentMonth: 1,
startYear: 180,
initYear: 180,
initMonth: 1,
};
expect(
rollUniqueLotteryDetailed({
...base,
rng: new RandUtil(LiteHashDRBG.build('no-slot')),
config: buildConfig(),
generalItems: { horse: null, weapon: 'itemB', book: null, item: null },
occupiedUniqueCounts: new Map([['itemB', 1]]),
})
).toEqual({ status: 'NO_SLOT' });
expect(
rollUniqueLotteryDetailed({
...base,
rng: new RandUtil(LiteHashDRBG.build('roll-failed')),
config: buildConfig({ uniqueTrialCoef: 0, maxUniqueTrialProb: 0 }),
generalItems: { horse: null, weapon: null, book: null, item: null },
occupiedUniqueCounts: new Map(),
})
).toEqual({ status: 'ROLL_FAILED' });
expect(
rollUniqueLotteryDetailed({
...base,
rng: new RandUtil(LiteHashDRBG.build('no-supply')),
config: buildConfig(),
generalItems: { horse: null, weapon: null, book: null, item: null },
occupiedUniqueCounts: new Map([['itemB', 1]]),
acquireType: '건국',
})
).toEqual({ status: 'NO_SUPPLY' });
});
it('counts only non-buyable equipped items', () => {
const itemRegistry = new Map<string, ItemModule>([
['uniqueItem', buildItem('uniqueItem', 'weapon', false)],