diff --git a/libs/common/util/src/lib/object.ts b/libs/common/util/src/lib/object.ts index 5d22934e42..fbeba29cf7 100644 --- a/libs/common/util/src/lib/object.ts +++ b/libs/common/util/src/lib/object.ts @@ -62,6 +62,29 @@ export function objFilterKeys( ) as Record } +/** + * Filter an object's entries based on a predicate function + * @param obj The object to filter + * @param f Predicate function that takes (value, key, index) + * @returns A new object containing only the entries that pass the predicate + */ +export function objFilter( + obj: Record, + f: (v: V, k: K, i: number) => boolean +): Record +export function objFilter( + obj: Partial>, + f: (v: V, k: K, i: number) => boolean +): Partial> +export function objFilter( + obj: Record, + f: (v: V, k: K, i: number) => boolean +): Record { + return Object.fromEntries( + Object.entries(obj).filter(([k, v], i) => f(v as V, k as K, i)) + ) as Record +} + export function objMap( obj: Record, f: (v: V, k: K, i: number) => V2 @@ -79,15 +102,6 @@ export function objMap( ) as Record } -export function objFilter( - obj: Record, - f: (v: V, k: K, i: number) => boolean -): Record { - return Object.fromEntries( - Object.entries(obj).filter(([k, v], i) => f(v as V, k as K, i)) - ) as Record -} - /** * Generate an object from an array of keys, and a function that maps the key to a value * @param keys diff --git a/libs/zzz/consts/src/common.ts b/libs/zzz/consts/src/common.ts index 4d5f732d10..de78bcdcd5 100644 --- a/libs/zzz/consts/src/common.ts +++ b/libs/zzz/consts/src/common.ts @@ -1,5 +1,10 @@ import { objKeyMap } from '@genshin-optimizer/common/util' -import { allDiscMainStatKeys, allDiscSubStatKeys } from './disc' +import type { DiscCondKey } from './disc' +import { + allDiscCondKeys, + allDiscMainStatKeys, + allDiscSubStatKeys, +} from './disc' export const otherStatKeys = [ // Used by calc, likely will be bundled into pando @@ -124,3 +129,6 @@ const elementalData: Record = { Object.entries(elementalData).forEach(([e, name]) => { statKeyTextMap[`${e}_dmg_`] = `${name} DMG Bonus` }) + +export type CondKey = DiscCondKey +export const allCondKeys = Object.keys(allDiscCondKeys) as CondKey[] diff --git a/libs/zzz/consts/src/disc.ts b/libs/zzz/consts/src/disc.ts index c69f4befca..3ece845796 100644 --- a/libs/zzz/consts/src/disc.ts +++ b/libs/zzz/consts/src/disc.ts @@ -1,3 +1,4 @@ +import { objMultiplication } from '@genshin-optimizer/common/util' import type { PandoStatKey } from './common' export const allDiscSlotKeys = ['1', '2', '3', '4', '5', '6'] as const @@ -197,3 +198,212 @@ export const discSetNames: Record = { BranchBladeSong: 'Branch & Blade Song', AstralVoice: 'Astral Voice', } + +export const allDiscCondKeys = { + AstralVoice: { + key: 'AstralVoice', + text: (val: number) => `${val} Stacks of Astral`, + min: 1, + max: 3, + }, + BranchBladeSong: { + key: 'BranchBladeSong', + text: `When any squad member applies Freeze or triggers the Shatter effect on an enemy`, + min: 1, + max: 1, + }, + ChaosJazz: { + key: 'ChaosJazz', + text: 'When off-field', + min: 1, + max: 1, + }, + ChaoticMetal: { + key: 'ChaoticMetal', + text: 'Whenever a squad member inflicts Corruption on an enemy', + min: 1, + max: 1, + }, + FangedMetal: { + key: 'FangedMetal', + text: 'Whenever a squad member inflicts Assault on an enemy', + min: 1, + max: 1, + }, + HormonePunk: { + key: 'HormonePunk', + text: 'Upon entering or switching into combat', + min: 1, + max: 1, + }, + InfernoMetal: { + key: 'InfernoMetal', + text: 'Upon hitting a Burning enemy', + min: 1, + max: 1, + }, + PolarMetal: { + key: 'PolarMetal', + text: 'Whenever a squad member Freezes or Shatters an enemy', + min: 1, + max: 1, + }, + ProtoPunk: { + key: 'ProtoPunk', + text: 'When any squad member triggers a Defensive Assist or Evasive Assist', + min: 1, + max: 1, + }, + PufferElectro: { + key: 'PufferElectro', + text: 'Launching an Ultimate', + min: 1, + max: 1, + }, + SwingJazz: { + key: 'SwingJazz', + text: 'Launching a Chain Attack or Ultimate', + min: 1, + max: 1, + }, + ThunderMetal: { + key: 'ThunderMetal', + text: 'As long as an enemy in combat is Shocked', + min: 1, + max: 1, + }, + WoodpeckerElectro: { + key: 'WoodpeckerElectro', + text: (val: number) => + `${val}x Triggering a critical hit with a Basic Attack, Dodge Counter, or EX Special Attack`, + min: 1, + max: 3, + }, +} as const + +export type DiscCondKey = keyof typeof allDiscCondKeys +export const disc4PeffectSheets: Partial< + Record< + DiscSetKey, + { + condMeta: (typeof allDiscCondKeys)[DiscCondKey] + getStats: ( + conds: Partial>, + stats: Record + ) => Record | undefined + } + > +> = { + AstralVoice: { + condMeta: allDiscCondKeys.AstralVoice, + getStats: (conds) => { + return conds['AstralVoice'] + ? (objMultiplication({ dmg_: 0.08 }, conds['AstralVoice']) as Record< + string, + number + >) + : undefined + }, + }, + BranchBladeSong: { + condMeta: allDiscCondKeys.BranchBladeSong, + getStats: (conds, stats) => { + const ret: Record = {} + if (stats['anomMas'] >= 115) ret['crit_dmg_'] = 0.3 + if (conds['BranchBladeSong']) ret['crit_'] = 0.12 + return ret + }, + }, + ChaosJazz: { + condMeta: allDiscCondKeys.ChaosJazz, + getStats: (conds) => { + const ret: Record = { + fire_dmg_: 0.15, + electric_dmg_: 0.15, + } + if (conds['ChaosJazz']) ret['dmg_'] = 0.2 // TODO: Should be EX Special Attacks and Assist Attacks + return ret + }, + }, + ChaoticMetal: { + condMeta: allDiscCondKeys.ChaoticMetal, + getStats: (conds) => { + if (conds['ChaoticMetal']) return { dmg_: 0.18 } //enemy takes 18% more DMG + return undefined + }, + }, + FangedMetal: { + condMeta: allDiscCondKeys.FangedMetal, + getStats: (conds) => { + if (conds['FangedMetal']) return { dmg_: 0.35 } // equipper deals 35% additional DMG + return undefined + }, + }, + // FreedomBlues: reduce the target's Anomaly Buildup RES to the equipper's Attribute by 35% + HormonePunk: { + condMeta: allDiscCondKeys.HormonePunk, + getStats: (conds) => { + if (conds['HormonePunk']) return { cond_atk_: 0.25 } // ATK increased by 25% + return undefined + }, + }, + InfernoMetal: { + condMeta: allDiscCondKeys.InfernoMetal, + getStats: (conds) => { + if (conds['InfernoMetal']) return { crit_: 0.28 } //equipper's CRIT Rate is increased by 28% + return undefined + }, + }, + PolarMetal: { + condMeta: allDiscCondKeys.PolarMetal, + getStats: (conds) => { + const ret = { dmg_: 0.28 } // TODO: Basic Attack and Dash Attack DMG increases by 28% + if (conds['PolarMetal']) { + objMultiplication(ret, 2) + } + return ret + }, + }, + ProtoPunk: { + condMeta: allDiscCondKeys.ProtoPunk, + getStats: (conds) => { + if (conds['ProtoPunk']) return { dmg_: 0.15 } // all squad members deal 15% increased DMG + return undefined + }, + }, + PufferElectro: { + condMeta: allDiscCondKeys.PufferElectro, + getStats: (conds) => { + const ret: Record = { dmg_: 0.2 } //Ultimate DMG increases by 20% + if (conds['PufferElectro']) ret['cond_atk_'] = 0.15 // Launching an Ultimate increases the equipper's ATK by 15% + return ret + }, + }, + // ShockstarDisco: 15% more Daze + // SoulRock: the equipper takes 40% less DMG + SwingJazz: { + condMeta: allDiscCondKeys.SwingJazz, + getStats: (conds) => { + if (conds['SwingJazz']) return { dmg_: 0.15 } //increases all squad members' DMG by 15% + return undefined + }, + }, + ThunderMetal: { + condMeta: allDiscCondKeys.ThunderMetal, + getStats: (conds) => { + if (conds['ThunderMetal']) return { cond_atk_: 0.27 } // equipper's ATK is increased by 27% + return undefined + }, + }, + WoodpeckerElectro: { + condMeta: allDiscCondKeys.WoodpeckerElectro, + getStats: (conds) => { + if (conds['WoodpeckerElectro']) + return objMultiplication( + { cond_atk_: 0.15 }, + conds['WoodpeckerElectro'] + ) as Record + return undefined + }, + }, +} diff --git a/libs/zzz/db/src/Database/DataManagers/CharacterDataManager.ts b/libs/zzz/db/src/Database/DataManagers/CharacterDataManager.ts index 6a8a23d48d..0b75c8ce42 100644 --- a/libs/zzz/db/src/Database/DataManagers/CharacterDataManager.ts +++ b/libs/zzz/db/src/Database/DataManagers/CharacterDataManager.ts @@ -7,6 +7,7 @@ import { } from '@genshin-optimizer/common/util' import type { CharacterKey, + CondKey, DiscMainStatKey, DiscSetKey, FormulaKey, @@ -42,6 +43,7 @@ export type CharacterData = { levelHigh: number setFilter2: DiscSetKey[] setFilter4: DiscSetKey[] + conditionals: Partial> } function initialCharacterData(key: CharacterKey): CharacterData { @@ -65,6 +67,7 @@ function initialCharacterData(key: CharacterKey): CharacterData { levelHigh: 15, setFilter2: [], setFilter4: [], + conditionals: {}, } } export class CharacterDataManager extends DataManager< @@ -95,6 +98,7 @@ export class CharacterDataManager extends DataManager< levelHigh, setFilter2, setFilter4, + conditionals, } = obj as CharacterData if (!allCharacterKeys.includes(characterKey)) return undefined // non-recoverable @@ -154,6 +158,9 @@ export class CharacterDataManager extends DataManager< setFilter2 = validateArr(setFilter2, allDiscSetKeys, []) setFilter4 = validateArr(setFilter4, allDiscSetKeys, []) + if (typeof conditionals !== 'object') conditionals = {} + conditionals = objFilter(conditionals, (value) => typeof value === 'number') + const char: CharacterData = { key: characterKey, level, @@ -171,6 +178,7 @@ export class CharacterDataManager extends DataManager< levelHigh, setFilter2, setFilter4, + conditionals, } return char } diff --git a/libs/zzz/page-optimize/src/BuildsDisplay.tsx b/libs/zzz/page-optimize/src/BuildsDisplay.tsx index 79d40c7f34..128ea96fd4 100644 --- a/libs/zzz/page-optimize/src/BuildsDisplay.tsx +++ b/libs/zzz/page-optimize/src/BuildsDisplay.tsx @@ -15,6 +15,7 @@ import { Typography, } from '@mui/material' import { useCallback, useMemo } from 'react' +import { useCharacterContext } from './CharacterContext' import { StatsDisplay } from './StatsDisplay' export function BuildsDisplay({ @@ -53,16 +54,18 @@ function Build({ baseStats: Stats }) { const { database } = useDatabaseContext() + const character = useCharacterContext() const sum = useMemo( () => applyCalc( baseStats, + character?.conditionals ?? {}, Object.values(build.discIds) .map((d) => database.discs.get(d)) .filter(notEmpty) .map(convertDiscToStats) ), - [baseStats, build.discIds, database.discs] + [baseStats, build.discIds, character, database.discs] ) const onEquip = useCallback(() => { Object.values(build.discIds).forEach((dId) => { diff --git a/libs/zzz/page-optimize/src/DiscConditionalCard.tsx b/libs/zzz/page-optimize/src/DiscConditionalCard.tsx new file mode 100644 index 0000000000..f49d89fb2a --- /dev/null +++ b/libs/zzz/page-optimize/src/DiscConditionalCard.tsx @@ -0,0 +1,160 @@ +import { CardThemed, DropdownButton } from '@genshin-optimizer/common/ui' +import { range } from '@genshin-optimizer/common/util' +import type { + allDiscCondKeys, + DiscCondKey, + DiscSetKey, +} from '@genshin-optimizer/zzz/consts' +import { disc4PeffectSheets } from '@genshin-optimizer/zzz/consts' +import type { Stats } from '@genshin-optimizer/zzz/db' +import { useDatabaseContext } from '@genshin-optimizer/zzz/db-ui' +import { DiscSetName } from '@genshin-optimizer/zzz/ui' +import { + Box, + Button, + CardContent, + CardHeader, + Divider, + Grid, + MenuItem, + Typography, +} from '@mui/material' +import { useMemo } from 'react' +import { useCharacterContext } from './CharacterContext' +import { StatsDisplay } from './StatsDisplay' + +export function DiscConditionalsCard({ baseStats }: { baseStats: Stats }) { + return ( + + + + + + Conditionals only take effect when characters are equipped with the + 4p. + + + + {Object.keys(disc4PeffectSheets).map((setKey) => ( + + + + ))} + + + + + ) +} +export function DiscConditionalCard({ + setKey, + baseStats, +}: { + setKey: DiscSetKey + baseStats: Stats +}) { + const sheet = disc4PeffectSheets[setKey] + const character = useCharacterContext() + const stats = useMemo( + () => + character && + sheet?.getStats && + sheet.getStats(character?.conditionals, baseStats), + [baseStats, character, sheet] + ) + if (!sheet || !character) return null + const { condMeta } = sheet + + return ( + + } /> + + {!!stats && !!Object.keys(stats).length && ( + + + + )} + + ) +} +export function ConditionalToggle({ + condMeta, +}: { + condMeta: (typeof allDiscCondKeys)[DiscCondKey] +}) { + const { database } = useDatabaseContext() + const character = useCharacterContext()! + const value = character.conditionals[condMeta.key] ?? 0 + if (condMeta.max > 1) + return ( + + { + database.chars.set(character.key, (chars) => ({ + conditionals: { + ...chars.conditionals, + [condMeta.key]: undefined, + }, + })) + }} + > + Clear + + {range(condMeta.min, condMeta.max).map((i) => ( + { + database.chars.set(character.key, (chars) => ({ + conditionals: { + ...chars.conditionals, + [condMeta.key]: i, + }, + })) + }} + > + {typeof condMeta.text === 'function' + ? condMeta.text(i) + : condMeta.text} + + ))} + + ) + if (condMeta.max === 1) { + return ( + + ) + } + return null +} diff --git a/libs/zzz/page-optimize/src/Optimize.tsx b/libs/zzz/page-optimize/src/Optimize.tsx index 1df5652418..6271bb4c98 100644 --- a/libs/zzz/page-optimize/src/Optimize.tsx +++ b/libs/zzz/page-optimize/src/Optimize.tsx @@ -134,6 +134,7 @@ export default function OptimizeWrapper({ const optimizer = new Solver( formulaKey, baseStats, + character.conditionals, objMap(character.constraints, (c, k) => ({ ...c, value: toDecimal(c.value, k), diff --git a/libs/zzz/page-optimize/src/StatsDisplay.tsx b/libs/zzz/page-optimize/src/StatsDisplay.tsx index 7d4ff738d1..3a18a36037 100644 --- a/libs/zzz/page-optimize/src/StatsDisplay.tsx +++ b/libs/zzz/page-optimize/src/StatsDisplay.tsx @@ -11,7 +11,7 @@ export function StatsDisplay({ showBase?: boolean }) { return ( - + {Object.entries(stats) .filter(([k]) => showBase || !k.endsWith('_base')) .map(([k, v]) => ( diff --git a/libs/zzz/page-optimize/src/index.tsx b/libs/zzz/page-optimize/src/index.tsx index 924323c2b1..871ba28887 100644 --- a/libs/zzz/page-optimize/src/index.tsx +++ b/libs/zzz/page-optimize/src/index.tsx @@ -33,6 +33,7 @@ import { useCallback, useMemo, useState } from 'react' import BaseStatCard from './BaseStatCard' import { BuildsDisplay } from './BuildsDisplay' import { CharacterContext } from './CharacterContext' +import { DiscConditionalsCard } from './DiscConditionalCard' import OptimizeWrapper from './Optimize' import { OptimizeTargetSelector } from './OptimizeTargetSelector' import { StatsDisplay } from './StatsDisplay' @@ -188,6 +189,7 @@ export default function PageOptimize() { baseStats={character?.stats ?? {}} setBaseStats={setStats} /> + >, + discs: DiscStats[] +) { const sum = { ...baseStats } const s = (key: string) => sum[key] || 0 @@ -63,13 +69,19 @@ export function applyCalc(baseStats: Stats, discs: DiscStats[]) { // Apply disc stats for (const { stats } of discs) objSumInPlace(sum, stats) - // Apply 2p effects for (const key in setCount) { + // Apply 2p effects const k = key as DiscSetKey - if (setCount[k]! >= 2) { + const count = setCount[k] ?? 0 + if (count >= 2) { const p2 = disc2pEffect[k] if (p2) objSumInPlace(sum, p2) } + // Apply 4p effects + if (count >= 4) { + const p4 = disc4PeffectSheets[k]?.getStats(conditionals, sum) + if (p4) objSumInPlace(sum, p4) + } } // Rudimentary Calculations diff --git a/libs/zzz/solver/src/childWorker.ts b/libs/zzz/solver/src/childWorker.ts index 866f9c579e..b481571098 100644 --- a/libs/zzz/solver/src/childWorker.ts +++ b/libs/zzz/solver/src/childWorker.ts @@ -1,4 +1,5 @@ import type { + CondKey, DiscSetKey, DiscSlotKey, FormulaKey, @@ -12,6 +13,7 @@ const MAX_BUILDS_TO_SEND = 200_000 let discStatsBySlot: Record let constraints: Constraints let baseStats: Stats +let conditionals: Partial> let formulaKey: FormulaKey let setFilter2p: DiscSetKey[] let setFilter4p: DiscSetKey[] @@ -19,6 +21,7 @@ let setFilter4p: DiscSetKey[] export interface ChildCommandInit { command: 'init' baseStats: Stats + conditionals: Partial> discStatsBySlot: Record constraints: Constraints formulaKey: FormulaKey @@ -81,6 +84,7 @@ async function handleEvent(e: MessageEvent): Promise { // Create compiledCalcFunction async function init({ baseStats: bs, + conditionals: conds, discStatsBySlot: discs, constraints: initCons, formulaKey: fk, @@ -88,6 +92,7 @@ async function init({ setFilter4, }: ChildCommandInit) { baseStats = bs + conditionals = conds discStatsBySlot = discs constraints = initCons formulaKey = fk @@ -164,7 +169,7 @@ async function start() { continue } // 2. Calculate base stats. - const sum = applyCalc(baseStats, discs) + const sum = applyCalc(baseStats, conditionals, discs) // 3Filter using constraints if ( constraintArr.every(([k, { value, isMax }]) => diff --git a/libs/zzz/solver/src/parentWorker.ts b/libs/zzz/solver/src/parentWorker.ts index 1af45f8dbc..ef7bf49b5e 100644 --- a/libs/zzz/solver/src/parentWorker.ts +++ b/libs/zzz/solver/src/parentWorker.ts @@ -1,5 +1,9 @@ import { objKeyMap, range } from '@genshin-optimizer/common/util' -import type { DiscSetKey, FormulaKey } from '@genshin-optimizer/zzz/consts' +import type { + CondKey, + DiscSetKey, + FormulaKey, +} from '@genshin-optimizer/zzz/consts' import { allDiscSlotKeys, type DiscSlotKey, @@ -15,6 +19,7 @@ let workers: Worker[] export interface ParentCommandStart { command: 'start' baseStats: Stats + conditionals: Partial> constraints: Constraints setFilter2: DiscSetKey[] // [] means rainbow setFilter4: DiscSetKey[] // [] means rainbow @@ -81,6 +86,7 @@ async function handleEvent(e: MessageEvent): Promise { async function start({ baseStats, + conditionals, discsBySlot, constraints, setFilter2, @@ -181,6 +187,7 @@ async function start({ const message: ChildCommandInit = { command: 'init', baseStats, + conditionals, discStatsBySlot: chunkedDiscStatsBySlot[index], constraints, formulaKey, diff --git a/libs/zzz/solver/src/solver.ts b/libs/zzz/solver/src/solver.ts index 5d02e6a9de..ce8551e1e4 100644 --- a/libs/zzz/solver/src/solver.ts +++ b/libs/zzz/solver/src/solver.ts @@ -1,4 +1,5 @@ import type { + CondKey, DiscSetKey, DiscSlotKey, FormulaKey, @@ -18,6 +19,7 @@ export interface ProgressResult { export class Solver { private baseStats: Stats + private conditionals: Partial> private constraints: Constraints private discsBySlot: Record private formulaKey: FormulaKey @@ -30,6 +32,7 @@ export class Solver { constructor( formulaKey: FormulaKey, baseStats: Stats, + conditionals: Partial>, constraints: Constraints, setFilter2: DiscSetKey[], // [] means rainbow setFilter4: DiscSetKey[], // [] means rainbow @@ -39,6 +42,7 @@ export class Solver { ) { this.formulaKey = formulaKey this.baseStats = baseStats + this.conditionals = conditionals this.constraints = constraints this.discsBySlot = discsBySlot this.numWorkers = numWorkers @@ -73,6 +77,7 @@ export class Solver { // Start worker const message: ParentCommandStart = { baseStats: this.baseStats, + conditionals: this.conditionals, command: 'start', // lightCones: this.lightCones, discsBySlot: this.discsBySlot,