fix: 은퇴 명예 기록과 유산 수명주기 정합성을 복원한다
은퇴·사망·통일의 저장 순서와 명예의 전당 및 명장일람 판정을 Ref 흐름에 맞춘다. 유산 행동을 인증된 daemon transaction으로 통합하고 중복 지급·고유 아이템·로그·오류 경계를 회귀 테스트한다.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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)],
|
||||
|
||||
Reference in New Issue
Block a user