fribbels / hsr-optimizer

A Honkai Star Rail optimizer, relic scorer, damage calculator, and various other tools for building and gearing characters
https://fribbels.github.io/hsr-optimizer/
MIT License
279 stars 47 forks source link

[Feature] Result orientated optimization target #173

Open Kamii0909 opened 9 months ago

Kamii0909 commented 9 months ago

Motivation

Currently, the optimizer use an rudimentary method to sort builds, which is more or less: get all builds permutations -> sort them This is limiting for various reasons:

The result of such process is no better than manual eyeball.

Furthermore, the damage system is inherently flawed and ad-hoc, which currently is (and will continue to) be problematic to work with:

Personally, code for the current optimizer lacks proper documentation and is rather confusing. Without significant time investment, I can't even verify things like "does BS EHR -> DMG% work?".

I raised this issue to discuss about the aforementioned concerns. Before I produce a full-fledged pull requests, I want to use this issue to first gather some feedback, before I did something that is not on the vision of the site.

Proposal

Stat API

As part of the current damage calculation stack, the stat collection system need a redesign. A stat modification (buff, debuff, whatever) may come from many source, with their own conditional on top of that. We need a mechanism to effectively model them with an intuitive game-like API, this will greatly simplify game version update. I reckon the upperbound of the scope should be "everything can be changed after you pick an action and enemy". So things like "IL gains 24% CD when attacking enemies with Imaginary Weakness" should be out of scope.

For example, Ting Yun provides an unconditional 50% DMG Boost with her Ultimate. Topaz Skill with 50% FuA DMG Boost.

// we can reuse all of these
const tyUlt: StatModification = { dmgBoost: 0.5 }
const riverFlowsInSpring = {
    basic: {
        percent: {
            speed: 0.12
        }
    },
    dmgBoost: 0.24
}
// ConditionalStat.trait(trait: Trait, stat: StatModification)
const topazSkillBuff = ConditionalStat.trait(Trait.FOLLOW_UP, { dmgBoost: 0.5 });
// ConditionalStat.stat((currentStat: Readonly<CurrentStat>) => StatModification | null)
// Why not group both into a single API? 
// The above type can be applied very early (before relic stat calculation), the below cannot.
// Note that we actually risk circular dependency here, but whatever, it's Hoyoverse's fault. 
// When the time comes, we will deal with it.  
// For example, Kazuha buff cannot scale with other percentage EM buff, a surprisingly logical yet arbitrary limitation.
const blackSwanEhrToDmg = ConditionalStat.stat(curr => { dmgBoost: Math.max(curr.effectHitRate * 0.6, 0.72) };
const hertaSpaceStation = ConditionalStat.stat(curr => curr.speed >= 120 ? { basic: { percent: { atk: 12 }}}: null);
const rutilantArena = ConditionalStat.stat(curr => 
    curr.traits in [Traits.NORMAL_ATTACK, Traits.SKILL] && curr.critRate >= 0.7 
    ? { dmgBoost: 0.2 } : null);
/*
calculateStats(
    unconditional: StatModification[],
    element: ConditionalStat<Element>[],
    traits: ConditionalStat<Trait[]>[],
    stats: ConditionalStat<CurrentStat>
): FinalStats
*/

This should also close #153 since FinalStats can trace all its element, traits, and stats dependencies. I'm honestly debating whether Element ConditionalStat is even relevant at all, because then the stat system should also track which element the attack is, which is an extremely useless feature, because such a thing cannot be changed after "the player chose an action". But then, it will simplify a fair bit for downstream, so well?

Optimization request

The aforementioned stat system also provide the base for complex/custom result-orientated optimization. For example, we can provide the formula for the optimization of IL 3SP and then Ult:

// Some are not valid JS/TS, but is used to provide type information. 
// It's unlikely that the actual API will be in builder/factory format,.
// I used it here because it's rather easy to read
const target: OptimizationRequest = OptimizationRequest.builder()
    // These stats cannot be changed between each step
    .with(stat: StatBuilder => stat
        .unconditional(mods: StatModification[])
        .element(eleConds: ElementConditional[])
        .trait(trConds: TraitConditional[])
        .stat(statConds: StatConditional[])
        .build(): ModifyingStats)
    .element(ele: Element) // Element.IMAGINARY
    // IL Hit split is 14.2 x6, 14.8
    // 2nd -> 7th hit will get 10% DMG Bonus (stack)
    // 4th -> 7th hit will get 12% Crit DMG (stack)
    .addStep(step: StepBuilder => step
        .damage() // calculate for damage
        .traits(traits: Trait[]) // [Trait.NORMAL_ATTACK]
        // multiplier = 5 * 0.142, flat (additional damage) = 0
        // formula = (base) => base.atk * multiplier + flat
        .baseMultiplier(formula: (base: BaseStat) => number))
        // This attack can Crit, assume average crit
        // .dot(): this attack can't crit
        .averageCrit(): Step) 
    .addStep(step => step.damage().traits([Trait.NORMAL_ATTACK])
        // should we even allow element/trait/stat conditionals here? 
        // IMPORTANT: will it simplify downstream code?
        .with(stat: StepStatBuilder => stat
            // each step can know stat modifications by previous steps
            // prev won't contain ones applied at the start
            // done through a key so that they don't interfere with each other needlessly
            .unconditional(
                key: string, 
                prev: StatModification | null => StatModification | null)
            .unconditional("Talent", _ => { dmgBoost: 0.1 }) 
            .build(): ModifyingStats)
        .baseMultiplier(GenericAtkFormula.with(5 * 0.142, 0))
        .averageCrit())
    // A generic one
    .addStep(step => step...
        .with(stat => stat
            .unconditional(
                "Talent", 
                prev => { 
                    dmgBoost: Math.max(prev.dmgBoost + 0.1, 0.6)
                })
            .unconditional(
                "Talent",
                prev => {
                    crit: {
                        critDmg: Math.max(prev.crit.critDmg + 0.12, 0.48)
                    }
                })
            .build())
        .averageCrit())
    // similarly, Skill buff
    .build();

const result: OptimizationResult = target.optimize(relics: RelicInformation);
result.getCurrentProgress();

There are still multiple problems with this approach, for example, parallelization, performance, flexibility, but you get the idea. This is just an API draft anyway. We probably want some composition for OptimizationTarget, but that should take a fair bit of work.

Personal note

Personally, I'm not familliar with React technology, so it's unlikely that I can contribute to UI, so this is as much sugar that I can personally provide. I'm from a strongly typed language background, so I'm comfortable (in fact, I can't work with vanilla JS at all lol) working everything with Typescript.

fribbels commented 9 months ago

Hi @Kamii0909 I appreciate the feedback! I agree the optimization system needs an overhaul - the project is still young and we could use a hand in designing the api. Its late here but please drop by the discord server's #dev channel for more discussion tmrw/next week, I will be able to respond better on discord than in a ticket.

fribbels commented 9 months ago

There's a lot of backlog tasks related to this one that we could use help with:

https://github.com/fribbels/hsr-optimizer/issues/153

https://github.com/fribbels/hsr-optimizer/issues/115

https://github.com/fribbels/hsr-optimizer/issues/41

https://github.com/fribbels/hsr-optimizer/issues/8