From e9f8659398ccab6e0b1fd15d694f213a8d9bbcac Mon Sep 17 00:00:00 2001
From: Van Nguyen <36019388+nguyentvan7@users.noreply.github.com>
Date: Mon, 25 Dec 2023 21:57:06 -0700
Subject: [PATCH 01/17] Fix misc issues (#1383)
* Add Hu Tao atk optimization target
* Make Song of Days Past a teambuff
* Update scanner versions
* Remove Akasha scanner
* Add Song of Days past as optimization target
* Modify SyncRepo to take a parameter for branch name
* Remove Song of Days Past opt target
---
.../Data/Artifacts/SongOfDaysPast/index.tsx | 15 ++++++++++-----
.../src/app/Data/Characters/HuTao/index.tsx | 1 +
apps/frontend/src/app/PageScanner/index.tsx | 18 ++++++++----------
libs/dm/project.json | 3 ++-
.../src/executors/sync-repo/executor.spec.ts | 3 +++
.../plugin/src/executors/sync-repo/executor.ts | 9 +++++++--
.../plugin/src/executors/sync-repo/schema.d.ts | 1 +
.../plugin/src/executors/sync-repo/schema.json | 4 ++++
libs/sr-dm/project.json | 3 ++-
9 files changed, 38 insertions(+), 19 deletions(-)
diff --git a/apps/frontend/src/app/Data/Artifacts/SongOfDaysPast/index.tsx b/apps/frontend/src/app/Data/Artifacts/SongOfDaysPast/index.tsx
index fb30793fe6..5344ce98cf 100644
--- a/apps/frontend/src/app/Data/Artifacts/SongOfDaysPast/index.tsx
+++ b/apps/frontend/src/app/Data/Artifacts/SongOfDaysPast/index.tsx
@@ -34,11 +34,15 @@ const burst_dmgInc = { ...healing_dmgInc }
export const data: Data = dataObjForArtifactSheet(key, {
premod: {
heal_: set2,
- normal_dmgInc,
- charged_dmgInc,
- plunging_dmgInc,
- skill_dmgInc,
- burst_dmgInc,
+ },
+ teamBuff: {
+ premod: {
+ normal_dmgInc,
+ charged_dmgInc,
+ plunging_dmgInc,
+ skill_dmgInc,
+ burst_dmgInc,
+ },
},
})
@@ -54,6 +58,7 @@ const sheet: IArtifactSheet = {
value: condHealing,
path: condHealingPath,
name: trm('condName'),
+ teamBuff: true,
states: objKeyMap(healingArr, (heal) => ({
name: `${heal}`,
fields: [
diff --git a/apps/frontend/src/app/Data/Characters/HuTao/index.tsx b/apps/frontend/src/app/Data/Characters/HuTao/index.tsx
index 84ba91c0fa..432de5470c 100644
--- a/apps/frontend/src/app/Data/Characters/HuTao/index.tsx
+++ b/apps/frontend/src/app/Data/Characters/HuTao/index.tsx
@@ -175,6 +175,7 @@ const dmgFormulas = {
),
skill: {
dmg: dmgNode('atk', dm.skill.dmg, 'skill'),
+ atk,
},
burst: {
dmg: dmgNode('atk', dm.burst.dmg, 'burst'),
diff --git a/apps/frontend/src/app/PageScanner/index.tsx b/apps/frontend/src/app/PageScanner/index.tsx
index a7673af13a..e505832dd7 100644
--- a/apps/frontend/src/app/PageScanner/index.tsx
+++ b/apps/frontend/src/app/PageScanner/index.tsx
@@ -1,9 +1,9 @@
import { AnvilIcon, DiscordIcon } from '@genshin-optimizer/svgicons'
+import { CardThemed, SqBadge } from '@genshin-optimizer/ui-common'
import {
Backpack,
Computer,
Download,
- EmojiEvents,
Gamepad,
InsertLink,
PersonSearch,
@@ -27,10 +27,8 @@ import ReactGA from 'react-ga4'
import { Trans, useTranslation } from 'react-i18next'
import { Link as RouterLink } from 'react-router-dom'
import AdScanner from './AdeptiScanner.png'
-import AkashaScanner from './AkashaScanner.png'
-import Artiscan from './artiscan.png'
import GIScanner from './GIScanner.png'
-import { CardThemed, SqBadge } from '@genshin-optimizer/ui-common'
+import Artiscan from './artiscan.png'
export default function PageScanner() {
const { t } = useTranslation('page_scanner')
ReactGA.send({ hitType: 'pageview', page: '/scanner' })
@@ -98,7 +96,7 @@ export default function PageScanner() {
sx={{ display: 'flex', alignItems: 'center' }}
>
- 4.2
+ 4.3
@@ -156,7 +154,7 @@ export default function PageScanner() {
sx={{ display: 'flex', alignItems: 'center' }}
>
- 4.2
+ 4.3
@@ -243,7 +241,7 @@ export default function PageScanner() {
sx={{ display: 'flex', alignItems: 'center' }}
>
- 4.2
+ 4.3
@@ -295,7 +293,7 @@ export default function PageScanner() {
-
+ {/*
@@ -383,7 +381,7 @@ export default function PageScanner() {
-
+ */}
)
diff --git a/libs/dm/project.json b/libs/dm/project.json
index 585fad7ffe..e8f5dc5f44 100644
--- a/libs/dm/project.json
+++ b/libs/dm/project.json
@@ -7,7 +7,8 @@
"load-dm": {
"options": {
"repoUrl": "https://gitlab.com/Dimbreath/AnimeGameData.git",
- "outputPath": "libs/dm/GenshinData"
+ "outputPath": "libs/dm/GenshinData",
+ "branch": "origin/main"
},
"inputs": [
{
diff --git a/libs/plugin/src/executors/sync-repo/executor.spec.ts b/libs/plugin/src/executors/sync-repo/executor.spec.ts
index ab0c6d4263..7dd27c402c 100644
--- a/libs/plugin/src/executors/sync-repo/executor.spec.ts
+++ b/libs/plugin/src/executors/sync-repo/executor.spec.ts
@@ -7,6 +7,7 @@ describe('SyncRepo Executor', () => {
const repoUrl = 'https://github.com/chrislgarry/Apollo-11/'
const outputPath = path.join(__dirname, 'TestDB')
const hashPath = path.join(__dirname, 'TestDB.hash')
+ const branch = 'origin/master'
if (fs.existsSync(outputPath))
fs.rmSync(outputPath, { recursive: true, force: true })
@@ -15,6 +16,7 @@ describe('SyncRepo Executor', () => {
const output1 = await executor({
repoUrl,
outputPath,
+ branch,
prefixPath: false,
})
@@ -28,6 +30,7 @@ describe('SyncRepo Executor', () => {
const output2 = await executor({
repoUrl,
outputPath,
+ branch,
prefixPath: false,
})
expect(output2.success).toBe(true)
diff --git a/libs/plugin/src/executors/sync-repo/executor.ts b/libs/plugin/src/executors/sync-repo/executor.ts
index be84571f90..05e0d41336 100644
--- a/libs/plugin/src/executors/sync-repo/executor.ts
+++ b/libs/plugin/src/executors/sync-repo/executor.ts
@@ -7,7 +7,12 @@ import * as path from 'path'
export default async function runExecutor(
options: SyncRepoExecutorSchema
): Promise<{ success: boolean }> {
- const { outputPath, repoUrl: url, prefixPath: prefix = true } = options
+ const {
+ outputPath,
+ repoUrl: url,
+ prefixPath: prefix = true,
+ branch,
+ } = options
const cwd = prefix ? path.join(workspaceRoot, outputPath) : outputPath
const remoteHash = getRemoteRepoHash(url)
const name = path.basename(cwd)
@@ -23,7 +28,7 @@ Caution: if this is part of nx cache replay,
const localHash = getLocalRepoHash(cwd)
if (remoteHash !== localHash) {
execSync(`git fetch --depth 1`, { cwd })
- execSync(`git reset --hard origin/master`, { cwd })
+ execSync(`git reset --hard ${branch}`, { cwd })
} else console.log('Repo already existed with the latest commit')
} else {
// Clone
diff --git a/libs/plugin/src/executors/sync-repo/schema.d.ts b/libs/plugin/src/executors/sync-repo/schema.d.ts
index 0ebda2b084..c0f57dd67f 100644
--- a/libs/plugin/src/executors/sync-repo/schema.d.ts
+++ b/libs/plugin/src/executors/sync-repo/schema.d.ts
@@ -1,5 +1,6 @@
export interface SyncRepoExecutorSchema {
repoUrl: string
outputPath: string
+ branch: string
prefixPath?: boolean
}
diff --git a/libs/plugin/src/executors/sync-repo/schema.json b/libs/plugin/src/executors/sync-repo/schema.json
index ea63294e62..0d9577ada2 100644
--- a/libs/plugin/src/executors/sync-repo/schema.json
+++ b/libs/plugin/src/executors/sync-repo/schema.json
@@ -13,6 +13,10 @@
"type": "string",
"description": "Local repo path (including the repo name)"
},
+ "branch": {
+ "type": "string",
+ "description": "Branch to sync (e.g. origin/master)"
+ },
"prefixPath": {
"type": "boolean",
"description": "Whether to prepend `localPath` with `{workspaceRoot}`. Default: true"
diff --git a/libs/sr-dm/project.json b/libs/sr-dm/project.json
index 3d15ad97e6..8abffb625a 100644
--- a/libs/sr-dm/project.json
+++ b/libs/sr-dm/project.json
@@ -7,7 +7,8 @@
"load-dm": {
"options": {
"repoUrl": "https://github.com/Dimbreath/StarRailData.git",
- "outputPath": "libs/sr-dm/StarRailData"
+ "outputPath": "libs/sr-dm/StarRailData",
+ "branch": "origin/master"
},
"inputs": [
{
From 56ea4e68f2759e85844f7db79c33397c013f6854 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 26 Dec 2023 05:05:41 +0000
Subject: [PATCH 02/17] 9.19.3
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 4aebf13621..139347be61 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "genshin-optimizer",
- "version": "9.19.2",
+ "version": "9.19.3",
"license": "MIT",
"private": true,
"scripts": {
From eb8378ec33470ca121a6ddabd3ef6031eb447790 Mon Sep 17 00:00:00 2001
From: lantua <16190491+lantua@users.noreply.github.com>
Date: Wed, 27 Dec 2023 17:27:04 -0500
Subject: [PATCH 03/17] Fix stat calculation stage and listing (#1389)
- Restrict `base` stage to `atk/def/hp`
- Move irrelevant `base` stats to `premod`
- Update char/weapon bonus stat calculation`
- Move `formula.listing` listing to `listing.formulas`
- Add `listing.specialized` listing
---
libs/gi-formula/src/data/char/util.ts | 46 +++++++++++++++----
libs/gi-formula/src/data/common/index.ts | 5 +-
libs/gi-formula/src/data/common/prep.ts | 4 +-
libs/gi-formula/src/data/common/reaction.ts | 2 +-
libs/gi-formula/src/data/util/sheet.ts | 37 +++++++--------
libs/gi-formula/src/data/util/tag.ts | 12 +++--
libs/gi-formula/src/data/weapon/util.ts | 43 ++++++++++++++---
libs/gi-formula/src/debug.ts | 2 +-
libs/gi-formula/src/example.test.ts | 14 +++---
.../src/executors/gen-desc/executor.ts | 4 +-
libs/gi-formula/src/util.ts | 4 +-
.../src/executors/gen-stats/src/weaponData.ts | 10 ++--
12 files changed, 122 insertions(+), 61 deletions(-)
diff --git a/libs/gi-formula/src/data/char/util.ts b/libs/gi-formula/src/data/char/util.ts
index fe9b69c230..401cf5d209 100644
--- a/libs/gi-formula/src/data/char/util.ts
+++ b/libs/gi-formula/src/data/char/util.ts
@@ -12,12 +12,12 @@ import type { NumNode } from '@genshin-optimizer/pando'
import { prod, subscript, sum } from '@genshin-optimizer/pando'
import type { TagMapNodeEntries, FormulaArg, Stat } from '../util'
import {
- addStatCurve,
- registerStatListing,
allStatics,
customDmg,
customShield,
+ listingItem,
percent,
+ readStat,
self,
selfBuff,
} from '../util'
@@ -126,32 +126,58 @@ export function fixedShield(
)
}
+const baseStats = new Set(['atk', 'def', 'hp'])
+
export function entriesForChar(
- { ele, weaponType, region }: CharInfo,
+ { key, ele, weaponType, region }: CharInfo,
{ lvlCurves, ascensionBonus }: CharacterDataGen
): TagMapNodeEntries {
- const specials = new Set(Object.keys(ascensionBonus))
- specials.delete('atk')
- specials.delete('def')
- specials.delete('hp')
+ const specialized = new Set(
+ Object.keys(ascensionBonus) as (keyof typeof ascensionBonus)[]
+ )
+ specialized.delete('atk')
+ specialized.delete('def')
+ specialized.delete('hp')
const { ascension } = self.char
return [
// Stats
...lvlCurves.map(({ key, base, curve }) =>
- addStatCurve(key, prod(base, allStatics('static')[curve]))
+ selfBuff.base[key].add(prod(base, allStatics('static')[curve]))
),
...Object.entries(ascensionBonus).map(([key, values]) =>
- addStatCurve(key, subscript(ascension, values))
+ (baseStats.has(key)
+ ? selfBuff.base[key as 'atk' | 'def' | 'hp']
+ : readStat(selfBuff.premod, key as keyof typeof ascensionBonus)
+ ).add(subscript(ascension, values))
),
// Constants
- ...[...specials].map((s) => registerStatListing(s)),
selfBuff.common.weaponType.add(weaponType),
selfBuff.char.ele.add(ele),
// Counters
selfBuff.common.count[ele].add(1),
...(region !== '' ? [selfBuff.common.count[region].add(1)] : []),
+
+ // Listing (formulas)
+ selfBuff.listing.formulas.add(listingItem(self.final.hp)),
+ selfBuff.listing.formulas.add(listingItem(self.final.atk)),
+ selfBuff.listing.formulas.add(listingItem(self.final.def)),
+ selfBuff.listing.formulas.add(listingItem(self.final.eleMas)),
+ selfBuff.listing.formulas.add(listingItem(self.final.enerRech_)),
+ selfBuff.listing.formulas.add(listingItem(self.common.cappedCritRate_)),
+ selfBuff.listing.formulas.add(listingItem(self.final.critDMG_)),
+ selfBuff.listing.formulas.add(listingItem(self.final.heal_)),
+ selfBuff.listing.formulas.add(listingItem(self.final.dmg_[ele])),
+ selfBuff.listing.formulas.add(listingItem(self.final.dmg_.physical)),
+
+ // Listing (specialized)
+ ...[...specialized].map((stat) =>
+ selfBuff.listing.specialized.add(
+ // Sheet-specific data (i.e., `src:`)
+ listingItem(readStat(self.premod, stat).src(key))
+ )
+ ),
]
}
diff --git a/libs/gi-formula/src/data/common/index.ts b/libs/gi-formula/src/data/common/index.ts
index e4bcd88031..7265571a9b 100644
--- a/libs/gi-formula/src/data/common/index.ts
+++ b/libs/gi-formula/src/data/common/index.ts
@@ -15,13 +15,16 @@ const data: TagMapNodeEntries = [
reader.withTag({ src: 'iso', et: 'self' }).reread(reader.src('custom')),
reader.withTag({ src: 'agg', et: 'self' }).reread(reader.src('custom')),
- // Final <= Premod <= Base
+ // Final <= Premod <= Base + WeaponRefinement
reader
.withTag({ src: 'agg', et: 'self', qt: 'final' })
.add(reader.with('qt', 'premod').sum),
reader
.withTag({ src: 'agg', et: 'self', qt: 'premod' })
.add(reader.with('qt', 'base').sum),
+ reader
+ .withTag({ src: 'agg', et: 'self', qt: 'premod' })
+ .add(reader.with('qt', 'weaponRefinement').sum),
// premod X += base X * premod X%
...(['atk', 'def', 'hp'] as const).map((s) =>
diff --git a/libs/gi-formula/src/data/common/prep.ts b/libs/gi-formula/src/data/common/prep.ts
index 8c9aaddb6f..6910a5a757 100644
--- a/libs/gi-formula/src/data/common/prep.ts
+++ b/libs/gi-formula/src/data/common/prep.ts
@@ -15,10 +15,10 @@ const data: TagMapNodeEntries = [
})
),
selfBuff.formula.shield.add(
- prod(self.formula.base, sum(percent(1), self.base.shield_))
+ prod(self.formula.base, sum(percent(1), self.premod.shield_))
),
selfBuff.formula.heal.add(
- prod(self.formula.base, sum(percent(1), self.base.heal_))
+ prod(self.formula.base, sum(percent(1), self.premod.heal_))
),
// Transformative reactions
diff --git a/libs/gi-formula/src/data/common/reaction.ts b/libs/gi-formula/src/data/common/reaction.ts
index fdbe50dec4..a1d1cad8e4 100644
--- a/libs/gi-formula/src/data/common/reaction.ts
+++ b/libs/gi-formula/src/data/common/reaction.ts
@@ -364,7 +364,7 @@ const data: TagMapNodeEntries = [
return variants.flatMap((ele) => {
const name = trans === 'swirl' ? `swirl_${ele}` : trans
return [
- selfBuff.formula.listing.add(
+ selfBuff.listing.formulas.add(
tag(cond, { trans, q, ele, src: 'static', name })
),
selfBuff.prep.ele.name(name).add(ele),
diff --git a/libs/gi-formula/src/data/util/sheet.ts b/libs/gi-formula/src/data/util/sheet.ts
index f6a1e98508..6898c1357d 100644
--- a/libs/gi-formula/src/data/util/sheet.ts
+++ b/libs/gi-formula/src/data/util/sheet.ts
@@ -7,9 +7,11 @@ import type {
import type { NumNode, StrNode } from '@genshin-optimizer/pando'
import { prod } from '@genshin-optimizer/pando'
import type { Source, Stat } from './listing'
+import type { Read } from './read'
import { reader, tag } from './read'
import { self, selfBuff, teamBuff } from './tag'
import type { TagMapNodeEntries, TagMapNodeEntry } from './tagMapType'
+import type { StatKey } from '@genshin-optimizer/dm'
// Use `registerArt` for artifacts
export function register(
@@ -25,24 +27,6 @@ export function register(
)
}
-export function addStatCurve(key: string, value: NumNode): TagMapNodeEntry {
- return (
- key.endsWith('_dmg_')
- ? selfBuff.premod['dmg_'][key.slice(0, -5) as ElementWithPhyKey]
- : selfBuff.base[key as Stat]
- ).add(value)
-}
-export function registerStatListing(key: string): TagMapNodeEntry {
- const tags = key.endsWith('_dmg_')
- ? {
- qt: 'premod',
- q: 'dmg_',
- ele: key.slice(0, -5) as ElementWithPhyKey,
- }
- : { qt: 'base', q: key }
- return selfBuff.formula.listing.add(tag('sum', tags))
-}
-
export type FormulaArg = {
team?: boolean // true if applies to every member, and false (default) if applies only to self
cond?: string | StrNode
@@ -121,9 +105,22 @@ function registerFormula(
...extra: TagMapNodeEntries
): TagMapNodeEntries {
reader.name(name) // register name:
- const buff = team ? teamBuff : selfBuff
+ const listing = (team ? teamBuff : selfBuff).listing.formulas
return [
- buff.formula.listing.add(tag(cond, { name, q })),
+ listing.add(listingItem(reader.withTag({ name, qt: 'formula', q }), cond)),
...extra.map(({ tag, value }) => ({ tag: { ...tag, name }, value })),
]
}
+
+export function listingItem(t: Read, cond?: string | StrNode) {
+ return tag(cond ?? t.ex ?? 'unique', t.tag)
+}
+
+export function readStat(
+ list: Record,
+ key: StatKey
+): Read {
+ return key.endsWith('_dmg_')
+ ? list['dmg_'][key.slice(0, -5) as ElementWithPhyKey]
+ : list[key as Stat]
+}
diff --git a/libs/gi-formula/src/data/util/tag.ts b/libs/gi-formula/src/data/util/tag.ts
index 0830c6e534..d887cd3711 100644
--- a/libs/gi-formula/src/data/util/tag.ts
+++ b/libs/gi-formula/src/data/util/tag.ts
@@ -87,8 +87,9 @@ const stats: Record = {
heal_: agg,
} as const
export const selfTag = {
- base: { ...stats, shield_: agg },
- premod: stats,
+ base: { atk: agg, def: agg, hp: agg },
+ weaponRefinement: { ...stats, shield_: agg },
+ premod: { ...stats, shield_: agg },
final: stats,
char: {
lvl: iso,
@@ -132,7 +133,6 @@ export const selfTag = {
prep: { ele: prep, move: prep, amp: prep, cata: prep, trans: prep },
formula: {
base: agg,
- listing: aggStr,
dmg: prep,
shield: prep,
heal: prep,
@@ -140,6 +140,10 @@ export const selfTag = {
transCrit: prep,
swirl: prep,
},
+ listing: {
+ formulas: aggStr,
+ specialized: aggStr,
+ },
} as const
export const enemyTag = {
common: {
@@ -159,7 +163,7 @@ export function convert>>(
): { [j in keyof V]: { [k in keyof V[j]]: Read } } {
return reader.withTag(tag).withAll('qt', Object.keys(v), (r, qt) =>
r.withAll('q', Object.keys(v[qt]), (r, q) => {
- if (!v[qt][q]) console.log(v, qt, q)
+ if (!v[qt][q]) console.error(`Invalid { qt:${qt} q:${q} }`)
const { src, accu } = v[qt][q]
// `tag.src` overrides `Desc`
if (src && !tag.src) r = r.src(src)
diff --git a/libs/gi-formula/src/data/weapon/util.ts b/libs/gi-formula/src/data/weapon/util.ts
index 737154d63b..e5e61a107f 100644
--- a/libs/gi-formula/src/data/weapon/util.ts
+++ b/libs/gi-formula/src/data/weapon/util.ts
@@ -2,25 +2,54 @@ import { type WeaponKey } from '@genshin-optimizer/consts'
import { allStats } from '@genshin-optimizer/gi-stats'
import { prod, subscript } from '@genshin-optimizer/pando'
import type { TagMapNodeEntries } from '../util'
-import { addStatCurve, allStatics, registerStatListing, self } from '../util'
+import { allStatics, listingItem, readStat, self, selfBuff } from '../util'
export function entriesForWeapon(key: WeaponKey): TagMapNodeEntries {
const gen = allStats.weapon.data[key]
const { refinement, ascension } = self.weapon
- const specials = new Set(Object.keys(gen.ascensionBonus))
+ const primaryStat = 'atk'
+ const nonPrimaryStat = new Set(gen.lvlCurves.map(({ key }) => key))
+ nonPrimaryStat.delete(primaryStat)
return [
// Stats
...gen.lvlCurves.map(({ key, base, curve }) =>
- addStatCurve(key, prod(base, allStatics('static')[curve]))
+ (key == 'atk' ? selfBuff.base[key] : readStat(selfBuff.premod, key)).add(
+ prod(base, allStatics('static')[curve])
+ )
),
...Object.entries(gen.ascensionBonus).map(([key, values]) =>
- addStatCurve(key, subscript(ascension, values))
+ (key == 'atk'
+ ? selfBuff.base[key]
+ : readStat(selfBuff.premod, key as keyof typeof gen.ascensionBonus)
+ ).add(subscript(ascension, values))
),
...Object.entries(gen.refinementBonus).map(([key, values]) =>
- addStatCurve(key, subscript(refinement, values))
+ readStat(
+ selfBuff.weaponRefinement,
+ key as keyof typeof gen.refinementBonus
+ ).add(subscript(refinement, values))
+ ),
+
+ // Listing (specialized)
+ // All items here are sheet-specific data (i.e., `src:`)
+ self.listing.specialized.add(listingItem(self.base[primaryStat].src(key))),
+ ...[...nonPrimaryStat].map((stat) =>
+ self.listing.specialized.add(
+ listingItem(readStat(self.premod, stat).src(key))
+ )
+ ),
+ ...[...Object.keys(gen.refinementBonus)].map((stat) =>
+ self.listing.specialized
+ .src(key)
+ .add(
+ listingItem(
+ readStat(
+ self.weaponRefinement,
+ stat as keyof typeof gen.refinementBonus
+ )
+ )
+ )
),
- // Listing
- ...[...specials].map((key) => registerStatListing(key)),
]
}
diff --git a/libs/gi-formula/src/debug.ts b/libs/gi-formula/src/debug.ts
index 8dac3b9c57..76c4fe1e77 100644
--- a/libs/gi-formula/src/debug.ts
+++ b/libs/gi-formula/src/debug.ts
@@ -161,7 +161,7 @@ export class DebugCalculator extends BaseCalculator {
text: `expand ${tagStr(nTag!, ex)} (${tagStr(tag!)})`,
deps: args.map(({ meta, entryTag }) => ({
...meta,
- text: `${entryTag!.map((tag) => tagStr(tag)).join(' <- ')} <= ${
+ text: `${entryTag?.map((tag) => tagStr(tag)).join(' <- ')} <= ${
meta.text
}`,
})),
diff --git a/libs/gi-formula/src/example.test.ts b/libs/gi-formula/src/example.test.ts
index 57fc834374..98a42aba16 100644
--- a/libs/gi-formula/src/example.test.ts
+++ b/libs/gi-formula/src/example.test.ts
@@ -109,7 +109,7 @@ describe('example', () => {
calc.compute(member1.final.eleMas).val
)
})
- describe('list final formulas', () => {
+ describe('retrieve formulas in formula listing', () => {
/**
* Each entry in listing is a `Tag` in the shape of
* ```
@@ -123,7 +123,9 @@ describe('example', () => {
* }
* ```
*/
- const listing = calc.listFormulas(member0.formula.listing).map((x) => x.tag)
+ const listing = calc
+ .listFormulas(member0.listing.formulas)
+ .map((x) => x.tag)
// Simple check that all tags are in the correct format
const names: string[] = []
@@ -158,9 +160,9 @@ describe('example', () => {
])
expect(listing.filter((x) => x.src === 'static').length).toEqual(5)
})
- test('calculate final formulas', () => {
+ test('calculate formulas in a listing', () => {
const read = calc
- .listFormulas(member0.formula.listing)
+ .listFormulas(member0.listing.formulas)
.find((x) => x.tag.name === 'normal_0')!
const tag = read.tag
@@ -203,7 +205,7 @@ describe('example', () => {
// Step 1: Pick formula(s); anything that `calc.compute` can handle will work
const nodes = [
calc
- .listFormulas(member0.formula.listing)
+ .listFormulas(member0.listing.formulas)
.find((x) => x.tag.name === 'normal_0')!,
member0.char.auto,
member0.final.atk,
@@ -247,7 +249,7 @@ describe('example', () => {
test.skip('debug formula', () => {
// Pick formula
const normal0 = calc
- .listFormulas(member1.formula.listing)
+ .listFormulas(member1.listing.formulas)
.find((x) => x.tag.name === 'normal_0')!
// Use `DebugCalculator` instead of `Calculator`, same constructor
diff --git a/libs/gi-formula/src/executors/gen-desc/executor.ts b/libs/gi-formula/src/executors/gen-desc/executor.ts
index 7b74327d42..f1b6a92fe8 100644
--- a/libs/gi-formula/src/executors/gen-desc/executor.ts
+++ b/libs/gi-formula/src/executors/gen-desc/executor.ts
@@ -34,8 +34,8 @@ export default async function runExecutor(
// sheet-specific
tag['src'] != 'agg' &&
// formula listing
- tag['qt'] == 'formula' &&
- tag['q'] == 'listing' &&
+ tag['qt'] == 'listing' &&
+ tag['q'] == 'formulas' &&
// pattern from `registerFormula`
value['op'] == 'tag' &&
'name' in value.tag &&
diff --git a/libs/gi-formula/src/util.ts b/libs/gi-formula/src/util.ts
index 501edca18b..fc570c6c2c 100644
--- a/libs/gi-formula/src/util.ts
+++ b/libs/gi-formula/src/util.ts
@@ -44,8 +44,8 @@ export function charData(data: ICharacter): TagMapNodeEntries {
constellation.add(data.constellation),
// Default char
- selfBuff.base.critRate_.add(0.05),
- selfBuff.base.critDMG_.add(0.5),
+ selfBuff.premod.critRate_.add(0.05),
+ selfBuff.premod.critDMG_.add(0.5),
]
}
diff --git a/libs/gi-stats/src/executors/gen-stats/src/weaponData.ts b/libs/gi-stats/src/executors/gen-stats/src/weaponData.ts
index ac521a98d7..ea052de5e2 100644
--- a/libs/gi-stats/src/executors/gen-stats/src/weaponData.ts
+++ b/libs/gi-stats/src/executors/gen-stats/src/weaponData.ts
@@ -25,9 +25,9 @@ export type WeaponDataGen = {
rarity: 1 | 2 | 3 | 4 | 5
mainStat: WeaponProp
subStat?: WeaponProp | undefined
- lvlCurves: { key: string; base: number; curve: WeaponGrowCurveKey }[]
- refinementBonus: { [key in string]: number[] }
- ascensionBonus: { [key in string]: number[] }
+ lvlCurves: { key: StatKey; base: number; curve: WeaponGrowCurveKey }[]
+ refinementBonus: { [key in StatKey]?: number[] }
+ ascensionBonus: { [key in StatKey]?: number[] }
}
export default function weaponData() {
@@ -52,7 +52,7 @@ export default function weaponData() {
if (!(key in refinementBonus))
refinementBonus[key] = [...emptyRefinement]
// Refinement uses 1-based index, hence the +1
- refinementBonus[key][i + 1] += extrapolateFloat(value)
+ refinementBonus[key]![i + 1] += extrapolateFloat(value)
}
})
const ascensionBonus: WeaponDataGen['ascensionBonus'] = {}
@@ -63,7 +63,7 @@ export default function weaponData() {
const key = propTypeMap[propType]
if (!(key in ascensionBonus))
ascensionBonus[key] = [...emptyAscension]
- ascensionBonus[key][i] += extrapolateFloat(value)
+ ascensionBonus[key]![i] += extrapolateFloat(value)
}
})
From 66026ce2254303f3adcc4562865a072940c5b92b Mon Sep 17 00:00:00 2001
From: lantua <16190491+lantua@users.noreply.github.com>
Date: Wed, 27 Dec 2023 18:44:51 -0500
Subject: [PATCH 04/17] Minor gi-formula bug fix (#1391)
* Minor bug fix
---
libs/gi-formula/src/data/common/reaction.ts | 2 +-
libs/gi-formula/src/data/weapon/util.ts | 16 +++++++---------
2 files changed, 8 insertions(+), 10 deletions(-)
diff --git a/libs/gi-formula/src/data/common/reaction.ts b/libs/gi-formula/src/data/common/reaction.ts
index a1d1cad8e4..610f9ec35e 100644
--- a/libs/gi-formula/src/data/common/reaction.ts
+++ b/libs/gi-formula/src/data/common/reaction.ts
@@ -365,7 +365,7 @@ const data: TagMapNodeEntries = [
const name = trans === 'swirl' ? `swirl_${ele}` : trans
return [
selfBuff.listing.formulas.add(
- tag(cond, { trans, q, ele, src: 'static', name })
+ tag(cond, { trans, qt: 'formula', q, ele, src: 'static', name })
),
selfBuff.prep.ele.name(name).add(ele),
]
diff --git a/libs/gi-formula/src/data/weapon/util.ts b/libs/gi-formula/src/data/weapon/util.ts
index e5e61a107f..b735b6f377 100644
--- a/libs/gi-formula/src/data/weapon/util.ts
+++ b/libs/gi-formula/src/data/weapon/util.ts
@@ -40,16 +40,14 @@ export function entriesForWeapon(key: WeaponKey): TagMapNodeEntries {
)
),
...[...Object.keys(gen.refinementBonus)].map((stat) =>
- self.listing.specialized
- .src(key)
- .add(
- listingItem(
- readStat(
- self.weaponRefinement,
- stat as keyof typeof gen.refinementBonus
- )
- )
+ self.listing.specialized.add(
+ listingItem(
+ readStat(
+ self.weaponRefinement,
+ stat as keyof typeof gen.refinementBonus
+ ).src(key)
)
+ )
),
]
}
From 7e432fef9ec74d0103f4f581938db5b685e801de Mon Sep 17 00:00:00 2001
From: lantua <16190491+lantua@users.noreply.github.com>
Date: Wed, 27 Dec 2023 22:20:37 -0500
Subject: [PATCH 05/17] Add weapon-only example (#1392)
---
libs/gi-formula/src/example.test.ts | 24 +++++++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/libs/gi-formula/src/example.test.ts b/libs/gi-formula/src/example.test.ts
index 98a42aba16..58027f1401 100644
--- a/libs/gi-formula/src/example.test.ts
+++ b/libs/gi-formula/src/example.test.ts
@@ -109,7 +109,7 @@ describe('example', () => {
calc.compute(member1.final.eleMas).val
)
})
- describe('retrieve formulas in formula listing', () => {
+ describe('retrieve formulas in a listing', () => {
/**
* Each entry in listing is a `Tag` in the shape of
* ```
@@ -263,3 +263,25 @@ describe('example', () => {
console.log(debugCalc.debug(normal0))
})
})
+describe('weapon-only example', () => {
+ const data: TagMapNodeEntries = [
+ ...weaponData(rawData[1].weapon as IWeapon),
+ ...conditionalData(rawData[1].conditionals),
+ ],
+ calc = new Calculator(keys, values, compileTagMapValues(keys, data))
+
+ const self = convert(selfTag, { et: 'self' })
+
+ test('retrieve formulas in a listing', () => {
+ const listing = calc.listFormulas(self.listing.specialized)
+ expect(listing.length).toEqual(3)
+ })
+ test('calculate formulas in a listing', () => {
+ // Some listings require character data (e.g., `formulas` listing) and will crash if used
+ const listing = calc.listFormulas(self.listing.specialized)
+
+ expect(calc.compute(listing[0]).val).toBeCloseTo(337.96) // atk
+ expect(calc.compute(listing[1]).val).toBeCloseTo(0.458) // hp_
+ expect(calc.compute(listing[2]).val).toBeCloseTo(0.3) // refinement hp_
+ })
+})
From 467184a96adbde15a3654f868169080db78b62e6 Mon Sep 17 00:00:00 2001
From: lantua <16190491+lantua@users.noreply.github.com>
Date: Thu, 28 Dec 2023 13:10:01 -0500
Subject: [PATCH 06/17] Update `gi-formula` example (#1393)
* Minor bug fix
* Microscopic bug fix
* Add weapon-only example
* Add some comment
* Replace `Calculator` constucture with more a appropriate function call
* - Default `isActive` to 0
- Set `isActive` if `teamData` is used
* Add test
---
libs/gi-formula/src/data/util/tag.ts | 2 +-
libs/gi-formula/src/example.test.ts | 8 +++++---
libs/gi-formula/src/util.ts | 3 +++
3 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/libs/gi-formula/src/data/util/tag.ts b/libs/gi-formula/src/data/util/tag.ts
index d887cd3711..4686437cab 100644
--- a/libs/gi-formula/src/data/util/tag.ts
+++ b/libs/gi-formula/src/data/util/tag.ts
@@ -103,7 +103,7 @@ export const selfTag = {
},
weapon: { lvl: iso, refinement: iso, ascension: iso },
common: {
- isActive: iso,
+ isActive: isoSum,
weaponType: iso,
critMode: fixed,
cappedCritRate_: iso,
diff --git a/libs/gi-formula/src/example.test.ts b/libs/gi-formula/src/example.test.ts
index 58027f1401..2d63c581cf 100644
--- a/libs/gi-formula/src/example.test.ts
+++ b/libs/gi-formula/src/example.test.ts
@@ -7,7 +7,6 @@ import {
detach,
flatten,
} from '@genshin-optimizer/pando'
-import { Calculator } from './calculator'
import { keys, values } from './data'
import type { Tag, TagMapNodeEntries } from './data/util'
import {
@@ -28,6 +27,7 @@ import {
weaponData,
withMember,
} from './util'
+import { genshinCalculatorWithEntries } from './index'
// This test acts as an example usage. It's mostly sufficient to test that the code
// doesn't crash. Any test for correct values should go to `correctness` tests.
@@ -65,7 +65,7 @@ describe('example', () => {
enemyDebuff.common.preRes.add(0.1),
selfBuff.common.critMode.add('avg'),
],
- calc = new Calculator(keys, values, compileTagMapValues(keys, data))
+ calc = genshinCalculatorWithEntries(data)
const member0 = convert(selfTag, { member: 'member0', et: 'self' })
const member1 = convert(selfTag, { member: 'member1', et: 'self' })
@@ -81,6 +81,8 @@ describe('example', () => {
}
})
test('calculate stats', () => {
+ expect(calc.compute(member0.common.isActive).val).toBe(1)
+ expect(calc.compute(member1.common.isActive).val).toBe(0)
expect(calc.compute(member1.final.hp).val).toBeCloseTo(9479.7, 1)
expect(calc.compute(member0.final.atk).val).toBeCloseTo(346.21, 2)
expect(calc.compute(member0.final.def).val).toBeCloseTo(124.15, 2)
@@ -268,7 +270,7 @@ describe('weapon-only example', () => {
...weaponData(rawData[1].weapon as IWeapon),
...conditionalData(rawData[1].conditionals),
],
- calc = new Calculator(keys, values, compileTagMapValues(keys, data))
+ calc = genshinCalculatorWithEntries(data)
const self = convert(selfTag, { et: 'self' })
diff --git a/libs/gi-formula/src/util.ts b/libs/gi-formula/src/util.ts
index fc570c6c2c..492d67b42e 100644
--- a/libs/gi-formula/src/util.ts
+++ b/libs/gi-formula/src/util.ts
@@ -123,6 +123,9 @@ export function teamData(
entry.reread(active.withTag({ dst, member: src }))
)
}),
+ activeMembers.map((member) =>
+ selfBuff.common.isActive.withTag({ member }).add(1)
+ ),
// Team Buff
members.flatMap((dst) => {
const entry = self.with('member', dst)
From 37ceb6a5d27280ea4ec1ec39773219bfcde765c8 Mon Sep 17 00:00:00 2001
From: frzyc
Date: Thu, 28 Dec 2023 14:24:45 -0500
Subject: [PATCH 07/17] use exact mainstat value for calculations (#1395)
---
apps/frontend/src/app/Formula/api.tsx | 14 +++++++-------
libs/gi-util/src/artifact/artifact.ts | 18 +++++++++++++++++-
2 files changed, 24 insertions(+), 8 deletions(-)
diff --git a/apps/frontend/src/app/Formula/api.tsx b/apps/frontend/src/app/Formula/api.tsx
index 6fe77c8049..134e5678be 100644
--- a/apps/frontend/src/app/Formula/api.tsx
+++ b/apps/frontend/src/app/Formula/api.tsx
@@ -6,7 +6,7 @@ import type {
SubstatKey,
} from '@genshin-optimizer/consts'
import { allElementWithPhyKeys } from '@genshin-optimizer/consts'
-import { getMainStatDisplayValue } from '@genshin-optimizer/gi-util'
+import { getMainStatValue } from '@genshin-optimizer/gi-util'
import {
crawlObject,
layeredAssignment,
@@ -66,7 +66,7 @@ function dataObjForArtifact(
art: ICachedArtifact,
mainStatAssumptionLevel = 0
): Data {
- const mainStatVal = getMainStatDisplayValue(
+ const mainStatVal = getMainStatValue(
art.mainStatKey,
art.rarity,
Math.max(Math.min(mainStatAssumptionLevel, art.rarity * 4), art.level)
@@ -428,15 +428,15 @@ function compareInternal(data1: any | undefined, data2: any | undefined): any {
}
}
-export type { NodeDisplay, UIData }
export {
+ compareDisplayUIData,
+ compareTeamBuffUIData,
+ computeUIData,
dataObjForArtifact,
dataObjForCharacter,
dataObjForWeapon,
- mergeData,
- computeUIData,
inferInfoMut,
+ mergeData,
uiDataForTeam,
- compareTeamBuffUIData,
- compareDisplayUIData,
}
+export type { NodeDisplay, UIData }
diff --git a/libs/gi-util/src/artifact/artifact.ts b/libs/gi-util/src/artifact/artifact.ts
index 7e261fbb80..58d5556f21 100644
--- a/libs/gi-util/src/artifact/artifact.ts
+++ b/libs/gi-util/src/artifact/artifact.ts
@@ -93,6 +93,21 @@ export function getSubstatValue(
return toPercent(value, substatKey)
}
+/**
+ * Raw number from the datamine.
+ * @param rarity
+ * @param statKey
+ * @param level
+ * @returns
+ */
+export function getMainStatValue(
+ statKey: MainStatKey,
+ rarity: RarityKey,
+ level: number
+) {
+ return allStats.art.main[rarity][statKey][level]
+}
+
/**
* NOTE: this gives the toPercent value of the main stat
* @param rarity
@@ -113,7 +128,8 @@ export function getMainStatDisplayValue(
rarity: RarityKey,
level: number
): number {
- return getMainStatDisplayValues(rarity, key)[level]
+ const val = getMainStatValue(key, rarity, level)
+ return key === 'eleMas' ? Math.round(val) : toPercent(val, key)
}
export function getMainStatDisplayStr(
From 6345011eef1e3494155a586030db8adf2c333004 Mon Sep 17 00:00:00 2001
From: eeeqeee <103794572+eeeqeee@users.noreply.github.com>
Date: Thu, 28 Dec 2023 14:29:30 -0500
Subject: [PATCH 08/17] Convert DMs to submodules (#1386)
* convert to submodule
* eslint pls
* shallow by default
* prettierignore
* nx caching
---
.github/workflows/ci.yml | 3 ++
.github/workflows/deploy-frontend.yml | 1 +
.github/workflows/new-release.yml | 2 +
.gitmodules | 8 ++++
.nxignore | 2 +
.prettierignore | 1 +
libs/dm/.eslintrc.json | 2 +-
libs/dm/.gitignore | 1 -
libs/dm/GenshinData | 1 +
libs/dm/project.json | 6 +--
.../src/executors/sync-repo/executor.spec.ts | 45 -------------------
.../src/executors/sync-repo/executor.ts | 23 +++-------
.../src/executors/sync-repo/schema.d.ts | 2 -
.../src/executors/sync-repo/schema.json | 8 ----
libs/sr-dm/.eslintrc.json | 2 +-
libs/sr-dm/.gitignore | 1 -
libs/sr-dm/StarRailData | 1 +
libs/sr-dm/project.json | 6 +--
package.json | 2 +-
19 files changed, 32 insertions(+), 85 deletions(-)
create mode 100644 .gitmodules
create mode 160000 libs/dm/GenshinData
delete mode 100644 libs/plugin/src/executors/sync-repo/executor.spec.ts
create mode 160000 libs/sr-dm/StarRailData
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 83cc86a374..81a5413473 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,6 +16,7 @@ jobs:
- uses: actions/checkout@v3
with:
persist-credentials: false
+ submodules: true
- uses: actions/setup-node@v3
with:
node-version: 18
@@ -34,6 +35,7 @@ jobs:
- uses: actions/checkout@v3
with:
persist-credentials: false
+ submodules: true
- uses: actions/setup-node@v3
with:
node-version: 18
@@ -52,6 +54,7 @@ jobs:
- uses: actions/checkout@v3
with:
persist-credentials: false
+ submodules: true
- uses: actions/setup-node@v3
with:
node-version: 18
diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml
index e2314c1201..9699317fb1 100644
--- a/.github/workflows/deploy-frontend.yml
+++ b/.github/workflows/deploy-frontend.yml
@@ -61,6 +61,7 @@ jobs:
with:
repository: ${{ inputs.repo_full_name }}
ref: ${{ inputs.ref }}
+ submodules: true
- uses: actions/setup-node@v3
with:
node-version: 18
diff --git a/.github/workflows/new-release.yml b/.github/workflows/new-release.yml
index 9322c132cc..5c42d90aec 100644
--- a/.github/workflows/new-release.yml
+++ b/.github/workflows/new-release.yml
@@ -14,6 +14,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
+ with:
+ submodules: true
- uses: actions/setup-node@v3
with:
node-version: 18
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000000..72326972a0
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,8 @@
+[submodule "libs/dm/GenshinData"]
+ path = libs/dm/GenshinData
+ url = https://gitlab.com/Dimbreath/AnimeGameData.git
+ shallow = true
+[submodule "libs/sr-dm/StarRailData"]
+ path = libs/sr-dm/StarRailData
+ url = https://github.com/Dimbreath/StarRailData.git
+ shallow = true
diff --git a/.nxignore b/.nxignore
index ee3110726c..fb78a04d0d 100644
--- a/.nxignore
+++ b/.nxignore
@@ -1,2 +1,4 @@
+libs/sr-dm/StarRailData
!libs/dm/GenshinData.hash
+libs/dm/GenshinData
!libs/sr-dm/StarRailData.hash
diff --git a/.prettierignore b/.prettierignore
index f6e48d1fe8..5f08e61f1f 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -5,6 +5,7 @@
/.yarn
/libs/dm/GenshinData
+/libs/sr-dm/StarRailData
*_gen.json
/libs/gi-stats/Data
diff --git a/libs/dm/.eslintrc.json b/libs/dm/.eslintrc.json
index 9d9c0db55b..99880fe204 100644
--- a/libs/dm/.eslintrc.json
+++ b/libs/dm/.eslintrc.json
@@ -1,6 +1,6 @@
{
"extends": ["../../.eslintrc.json"],
- "ignorePatterns": ["!**/*"],
+ "ignorePatterns": ["!**/*", "GenshinData/"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
diff --git a/libs/dm/.gitignore b/libs/dm/.gitignore
index a6d99446ff..5cae7b8ff2 100644
--- a/libs/dm/.gitignore
+++ b/libs/dm/.gitignore
@@ -1,3 +1,2 @@
-GenshinData
GenshinData.hash
Texture2D
diff --git a/libs/dm/GenshinData b/libs/dm/GenshinData
new file mode 160000
index 0000000000..8c799bf156
--- /dev/null
+++ b/libs/dm/GenshinData
@@ -0,0 +1 @@
+Subproject commit 8c799bf156bd08158f411bad8e375d4b255b25ee
diff --git a/libs/dm/project.json b/libs/dm/project.json
index e8f5dc5f44..6fe4ff3e96 100644
--- a/libs/dm/project.json
+++ b/libs/dm/project.json
@@ -6,13 +6,11 @@
"targets": {
"load-dm": {
"options": {
- "repoUrl": "https://gitlab.com/Dimbreath/AnimeGameData.git",
- "outputPath": "libs/dm/GenshinData",
- "branch": "origin/main"
+ "outputPath": "libs/dm/GenshinData"
},
"inputs": [
{
- "runtime": "git ls-remote -q https://gitlab.com/Dimbreath/AnimeGameData.git HEAD"
+ "runtime": "git ls-tree --object-only HEAD libs/dm/GenshinData"
}
]
},
diff --git a/libs/plugin/src/executors/sync-repo/executor.spec.ts b/libs/plugin/src/executors/sync-repo/executor.spec.ts
deleted file mode 100644
index 7dd27c402c..0000000000
--- a/libs/plugin/src/executors/sync-repo/executor.spec.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import executor, { getLocalRepoHash } from './executor'
-import * as path from 'path'
-import * as fs from 'fs'
-
-describe('SyncRepo Executor', () => {
- it('can run', async () => {
- const repoUrl = 'https://github.com/chrislgarry/Apollo-11/'
- const outputPath = path.join(__dirname, 'TestDB')
- const hashPath = path.join(__dirname, 'TestDB.hash')
- const branch = 'origin/master'
-
- if (fs.existsSync(outputPath))
- fs.rmSync(outputPath, { recursive: true, force: true })
- if (fs.existsSync(hashPath)) fs.unlinkSync(hashPath)
-
- const output1 = await executor({
- repoUrl,
- outputPath,
- branch,
- prefixPath: false,
- })
-
- // Clone
- expect(output1.success).toBe(true)
- expect(fs.existsSync(outputPath)).toBe(true)
- expect(fs.existsSync(hashPath)).toBe(true)
- expect(getLocalRepoHash(outputPath)).toEqual(`${fs.readFileSync(hashPath)}`)
-
- // So much fun we try it again (Fetch)
- const output2 = await executor({
- repoUrl,
- outputPath,
- branch,
- prefixPath: false,
- })
- expect(output2.success).toBe(true)
- expect(fs.existsSync(outputPath)).toBe(true)
- expect(fs.existsSync(hashPath)).toBe(true)
- expect(getLocalRepoHash(outputPath)).toEqual(`${fs.readFileSync(hashPath)}`)
-
- // Clean up after oneself
- fs.rmSync(outputPath, { recursive: true, force: true })
- fs.unlinkSync(hashPath)
- })
-})
diff --git a/libs/plugin/src/executors/sync-repo/executor.ts b/libs/plugin/src/executors/sync-repo/executor.ts
index 05e0d41336..b86326bc35 100644
--- a/libs/plugin/src/executors/sync-repo/executor.ts
+++ b/libs/plugin/src/executors/sync-repo/executor.ts
@@ -7,14 +7,9 @@ import * as path from 'path'
export default async function runExecutor(
options: SyncRepoExecutorSchema
): Promise<{ success: boolean }> {
- const {
- outputPath,
- repoUrl: url,
- prefixPath: prefix = true,
- branch,
- } = options
+ const { outputPath, prefixPath: prefix = true } = options
const cwd = prefix ? path.join(workspaceRoot, outputPath) : outputPath
- const remoteHash = getRemoteRepoHash(url)
+ const remoteHash = getRemoteRepoHash(cwd)
const name = path.basename(cwd)
console.log(
@@ -23,18 +18,12 @@ Caution: if this is part of nx cache replay,
no git command is actually executed.` + '\n '
)
- if (fs.existsSync(cwd)) {
+ {
// Fetch & reset
const localHash = getLocalRepoHash(cwd)
if (remoteHash !== localHash) {
- execSync(`git fetch --depth 1`, { cwd })
- execSync(`git reset --hard ${branch}`, { cwd })
+ execSync(`git submodule update ${cwd}`)
} else console.log('Repo already existed with the latest commit')
- } else {
- // Clone
- const parent = path.dirname(cwd)
- fs.mkdirSync(parent, { recursive: true })
- execSync(`git clone ${url} --depth 1 ${name}`, { cwd: parent })
}
// Compute hash
@@ -48,5 +37,5 @@ Caution: if this is part of nx cache replay,
export const getLocalRepoHash = (cwd: string): string =>
`${execSync(`git rev-parse HEAD`, { cwd })}`.trimEnd()
-export const getRemoteRepoHash = (url: string): string =>
- `${execSync(`git ls-remote ${url} HEAD`)}`.replace(/\s+HEAD\s*$/, '')
+export const getRemoteRepoHash = (cwd: string): string =>
+ `${execSync(`git ls-tree --object-only HEAD ${cwd}`)}`.trimEnd()
diff --git a/libs/plugin/src/executors/sync-repo/schema.d.ts b/libs/plugin/src/executors/sync-repo/schema.d.ts
index c0f57dd67f..168b5896eb 100644
--- a/libs/plugin/src/executors/sync-repo/schema.d.ts
+++ b/libs/plugin/src/executors/sync-repo/schema.d.ts
@@ -1,6 +1,4 @@
export interface SyncRepoExecutorSchema {
- repoUrl: string
outputPath: string
- branch: string
prefixPath?: boolean
}
diff --git a/libs/plugin/src/executors/sync-repo/schema.json b/libs/plugin/src/executors/sync-repo/schema.json
index 0d9577ada2..ff9fcd8423 100644
--- a/libs/plugin/src/executors/sync-repo/schema.json
+++ b/libs/plugin/src/executors/sync-repo/schema.json
@@ -5,18 +5,10 @@
"description": "",
"type": "object",
"properties": {
- "repoUrl": {
- "type": "string",
- "description": "Git repo URL"
- },
"outputPath": {
"type": "string",
"description": "Local repo path (including the repo name)"
},
- "branch": {
- "type": "string",
- "description": "Branch to sync (e.g. origin/master)"
- },
"prefixPath": {
"type": "boolean",
"description": "Whether to prepend `localPath` with `{workspaceRoot}`. Default: true"
diff --git a/libs/sr-dm/.eslintrc.json b/libs/sr-dm/.eslintrc.json
index 9d9c0db55b..6cc7c44f06 100644
--- a/libs/sr-dm/.eslintrc.json
+++ b/libs/sr-dm/.eslintrc.json
@@ -1,6 +1,6 @@
{
"extends": ["../../.eslintrc.json"],
- "ignorePatterns": ["!**/*"],
+ "ignorePatterns": ["!**/*", "StarRailData/"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
diff --git a/libs/sr-dm/.gitignore b/libs/sr-dm/.gitignore
index 5119bba576..635ddfb058 100644
--- a/libs/sr-dm/.gitignore
+++ b/libs/sr-dm/.gitignore
@@ -1,3 +1,2 @@
-StarRailData
StarRailData.hash
assets
diff --git a/libs/sr-dm/StarRailData b/libs/sr-dm/StarRailData
new file mode 160000
index 0000000000..267db9b8cc
--- /dev/null
+++ b/libs/sr-dm/StarRailData
@@ -0,0 +1 @@
+Subproject commit 267db9b8cc44face0f376075f0828c5e1dd20bff
diff --git a/libs/sr-dm/project.json b/libs/sr-dm/project.json
index 8abffb625a..530bd28653 100644
--- a/libs/sr-dm/project.json
+++ b/libs/sr-dm/project.json
@@ -6,13 +6,11 @@
"targets": {
"load-dm": {
"options": {
- "repoUrl": "https://github.com/Dimbreath/StarRailData.git",
- "outputPath": "libs/sr-dm/StarRailData",
- "branch": "origin/master"
+ "outputPath": "libs/sr-dm/StarRailData"
},
"inputs": [
{
- "runtime": "git ls-remote -q https://github.com/Dimbreath/StarRailData.git HEAD"
+ "runtime": "git ls-tree --object-only HEAD libs/sr-dm/StarRailData"
}
]
},
diff --git a/package.json b/package.json
index 139347be61..1fd31b4694 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"frontend": "nx serve frontend",
"build-all": "nx run-many -t build",
"mini-ci": "nx format:write && nx affected -t test,lint --max-warnings=0",
- "reload-dm": "nx run-many -t load-dm --skip-nx-cache"
+ "reload-dm": "git submodule update --init"
},
"devDependencies": {
"@babel/core": "^7.14.5",
From 1601b6598f43f63ed45512abf0455bdda7d20b19 Mon Sep 17 00:00:00 2001
From: Van Nguyen <36019388+nguyentvan7@users.noreply.github.com>
Date: Thu, 28 Dec 2023 21:16:14 -0700
Subject: [PATCH 09/17] Update char-cards README.md (#1388)
* Update char-cards README.md
* Format
---
libs/char-cards/README.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/libs/char-cards/README.md b/libs/char-cards/README.md
index fb2abeceb2..aa6f9d1d51 100644
--- a/libs/char-cards/README.md
+++ b/libs/char-cards/README.md
@@ -3,3 +3,11 @@
This library was generated with [Nx](https://nx.dev).
This is a library for the Character cards used by Genshin Optimizer. The cards needs to be manually added because it comes from their drip marketing on twitter.
+
+## Adding new cards
+
+1. Go to Twitter and search for `from:GenshinImpact `.
+2. Download the character card from the drip marketing, such as https://twitter.com/GenshinImpact/status/1736687861444001829. You might need to rename the extension to `.jpg` or `.jpeg`
+3. Resize it with your tool of choice to 350x700. Van uses [PowerToys Image Resizer](https://learn.microsoft.com/en-us/windows/powertoys/image-resizer).
+4. Add the card to [./src/](https://github.com/frzyc/genshin-optimizer/tree/master/libs/char-cards/src).
+5. Update [./src/index.ts](https://github.com/frzyc/genshin-optimizer/blob/master/libs/char-cards/src/index.ts) with the new card name, keeping it alphabetically sorted.
From 33cdc01a22942f261c487cc6ea3a0e9262317234 Mon Sep 17 00:00:00 2001
From: Van Nguyen <36019388+nguyentvan7@users.noreply.github.com>
Date: Thu, 28 Dec 2023 21:16:35 -0700
Subject: [PATCH 10/17] Add SRO database managers (#1387)
* Add sr-util
* Add initial SRO Database manager code
* Add some UI
* Fix build
* Fix build pt2
* Fix tsconfig discrepancy
* Print actual DB contents
* Add JSON import in UI (backend side broken)
* Remove log
* Fix to import
* Fix to export
* Add download button
* Fix eslint
* Add unit tests
* Fix rerender
* Fix lint
* Fix substat calculation and tests
* Update time display
* Fix key collisions
* Fix more errant keys
* Fix rounding for clamp
* Fix mainstat rounding and level validation
* Fix lint
* Move keys to constructor
* Remove probability
* Trim theme colors
---
apps/sr-frontend/.eslintrc.json | 6 +-
apps/sr-frontend/src/app/App.tsx | 57 +-
apps/sr-frontend/src/app/Character.tsx | 2 +-
apps/sr-frontend/src/app/Database.tsx | 150 ++++
apps/sr-frontend/src/app/Theme.tsx | 420 +---------
apps/sr-frontend/tsconfig.json | 3 +-
libs/database/src/lib/DBLocalStorage.ts | 25 +-
libs/database/src/lib/DBStorage.ts | 10 +
libs/database/src/lib/DataManagerBase.ts | 4 +-
libs/database/src/lib/SandboxStorage.ts | 29 +-
libs/sr-consts/src/character.ts | 10 +-
libs/sr-consts/src/lightCone.ts | 2 +
libs/sr-consts/src/relic.ts | 44 +-
libs/sr-db/project.json | 6 +
.../src/Database/DataEntries/DBMetaEntry.ts | 56 ++
libs/sr-db/src/Database/DataEntry.test.ts | 30 +
libs/sr-db/src/Database/DataEntry.ts | 25 +
libs/sr-db/src/Database/DataManager.test.ts | 31 +
libs/sr-db/src/Database/DataManager.ts | 38 +
.../Database/DataManagers/BuildResultData.ts | 76 ++
.../Database/DataManagers/BuildSettingData.ts | 226 ++++++
.../src/Database/DataManagers/CharMetaData.ts | 66 ++
.../Database/DataManagers/CharacterData.ts | 390 ++++++++++
.../Database/DataManagers/LightConeData.ts | 261 +++++++
.../src/Database/DataManagers/RelicData.ts | 529 +++++++++++++
libs/sr-db/src/Database/Database.test.ts | 729 ++++++++++++++++++
libs/sr-db/src/Database/Database.ts | 185 +++++
libs/sr-db/src/Database/exim.ts | 53 ++
libs/sr-db/src/Database/index.ts | 1 +
libs/sr-db/src/Database/migrate.ts | 60 ++
.../src/{ => Interfaces}/ISroCharacter.ts | 2 +-
.../src/{ => Interfaces}/ISroDatabase.ts | 1 -
libs/sr-db/src/Interfaces/ISroLightCone.ts | 5 +
libs/sr-db/src/Interfaces/ISroRelic.ts | 13 +
libs/sr-db/src/Interfaces/index.ts | 4 +
libs/sr-db/src/index.ts | 4 +-
libs/sr-db/tsconfig.json | 8 +-
libs/sr-db/tsconfig.spec.json | 13 +
libs/sr-db/vitest.config.ts | 19 +
libs/sr-srod/src/IRelic.ts | 4 +-
libs/sr-util/.eslintrc.json | 18 +
libs/sr-util/README.md | 3 +
libs/sr-util/project.json | 16 +
libs/sr-util/src/index.ts | 2 +
libs/sr-util/src/level.ts | 20 +
libs/sr-util/src/relic.ts | 141 ++++
libs/sr-util/tsconfig.json | 19 +
libs/sr-util/tsconfig.lib.json | 10 +
.../src/components/GeneralAutocomplete.tsx | 2 +-
libs/ui-common/tsconfig.json | 4 +-
tsconfig.base.json | 1 +
51 files changed, 3389 insertions(+), 444 deletions(-)
create mode 100644 apps/sr-frontend/src/app/Database.tsx
create mode 100644 libs/sr-db/src/Database/DataEntries/DBMetaEntry.ts
create mode 100644 libs/sr-db/src/Database/DataEntry.test.ts
create mode 100644 libs/sr-db/src/Database/DataEntry.ts
create mode 100644 libs/sr-db/src/Database/DataManager.test.ts
create mode 100644 libs/sr-db/src/Database/DataManager.ts
create mode 100644 libs/sr-db/src/Database/DataManagers/BuildResultData.ts
create mode 100644 libs/sr-db/src/Database/DataManagers/BuildSettingData.ts
create mode 100644 libs/sr-db/src/Database/DataManagers/CharMetaData.ts
create mode 100644 libs/sr-db/src/Database/DataManagers/CharacterData.ts
create mode 100644 libs/sr-db/src/Database/DataManagers/LightConeData.ts
create mode 100644 libs/sr-db/src/Database/DataManagers/RelicData.ts
create mode 100644 libs/sr-db/src/Database/Database.test.ts
create mode 100644 libs/sr-db/src/Database/Database.ts
create mode 100644 libs/sr-db/src/Database/exim.ts
create mode 100644 libs/sr-db/src/Database/index.ts
create mode 100644 libs/sr-db/src/Database/migrate.ts
rename libs/sr-db/src/{ => Interfaces}/ISroCharacter.ts (96%)
rename libs/sr-db/src/{ => Interfaces}/ISroDatabase.ts (93%)
create mode 100644 libs/sr-db/src/Interfaces/ISroLightCone.ts
create mode 100644 libs/sr-db/src/Interfaces/ISroRelic.ts
create mode 100644 libs/sr-db/src/Interfaces/index.ts
create mode 100644 libs/sr-db/tsconfig.spec.json
create mode 100644 libs/sr-db/vitest.config.ts
create mode 100644 libs/sr-util/.eslintrc.json
create mode 100644 libs/sr-util/README.md
create mode 100644 libs/sr-util/project.json
create mode 100644 libs/sr-util/src/index.ts
create mode 100644 libs/sr-util/src/level.ts
create mode 100644 libs/sr-util/src/relic.ts
create mode 100644 libs/sr-util/tsconfig.json
create mode 100644 libs/sr-util/tsconfig.lib.json
diff --git a/apps/sr-frontend/.eslintrc.json b/apps/sr-frontend/.eslintrc.json
index 9d9c0db55b..e979009575 100644
--- a/apps/sr-frontend/.eslintrc.json
+++ b/apps/sr-frontend/.eslintrc.json
@@ -1,10 +1,12 @@
{
- "extends": ["../../.eslintrc.json"],
+ "extends": ["plugin:@nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
- "rules": {}
+ "rules": {
+ "@typescript-eslint/consistent-type-imports": "error"
+ }
},
{
"files": ["*.ts", "*.tsx"],
diff --git a/apps/sr-frontend/src/app/App.tsx b/apps/sr-frontend/src/app/App.tsx
index c77b1aac40..f8404e3860 100644
--- a/apps/sr-frontend/src/app/App.tsx
+++ b/apps/sr-frontend/src/app/App.tsx
@@ -1,14 +1,61 @@
-import { CssBaseline, StyledEngineProvider, ThemeProvider } from '@mui/material'
-import React from 'react'
-import { theme } from './Theme'
+import { DBLocalStorage, SandboxStorage } from '@genshin-optimizer/database'
+import type { DatabaseContextObj } from '@genshin-optimizer/sr-db'
+import { DatabaseContext, SroDatabase } from '@genshin-optimizer/sr-db'
+import {
+ CssBaseline,
+ Stack,
+ StyledEngineProvider,
+ ThemeProvider,
+} from '@mui/material'
+import { useCallback, useMemo, useState } from 'react'
import Character from './Character'
+import Database from './Database'
+import { theme } from './Theme'
+
export default function App() {
+ const dbIndex = parseInt(localStorage.getItem('sro_dbIndex') || '1')
+ const [databases, setDatabases] = useState(() => {
+ localStorage.removeItem('SRONewTabDetection')
+ localStorage.setItem('SRONewTabDetection', 'debug')
+ return ([1, 2, 3, 4] as const).map((index) => {
+ if (index === dbIndex) {
+ return new SroDatabase(index, new DBLocalStorage(localStorage, 'sro'))
+ } else {
+ const dbName = `sro_extraDatabase_${index}`
+ const eDB = localStorage.getItem(dbName)
+ const dbObj = eDB ? JSON.parse(eDB) : {}
+ const db = new SroDatabase(index, new SandboxStorage(dbObj, 'sro'))
+ db.toExtraLocalDB()
+ return db
+ }
+ })
+ })
+ const setDatabase = useCallback(
+ (index: number, db: SroDatabase) => {
+ const dbs = [...databases]
+ dbs[index] = db
+ setDatabases(dbs)
+ },
+ [databases, setDatabases]
+ )
+
+ const database = databases[dbIndex - 1]
+ const dbContextObj: DatabaseContextObj = useMemo(
+ () => ({ databases, setDatabases, database, setDatabase }),
+ [databases, setDatabases, database, setDatabase]
+ )
+
return (
{/* https://mui.com/guides/interoperability/#css-injection-order-2 */}
-
-
+
+
+
+
+
+
+
)
diff --git a/apps/sr-frontend/src/app/Character.tsx b/apps/sr-frontend/src/app/Character.tsx
index 3b9444ccb4..ade1af8ea5 100644
--- a/apps/sr-frontend/src/app/Character.tsx
+++ b/apps/sr-frontend/src/app/Character.tsx
@@ -17,7 +17,7 @@ import {
Typography,
} from '@mui/material'
import { Container } from '@mui/system'
-import React, { useMemo, useState } from 'react'
+import { useMemo, useState } from 'react'
export default function Character() {
const [charKey, setcharKey] = useState('March7th')
diff --git a/apps/sr-frontend/src/app/Database.tsx b/apps/sr-frontend/src/app/Database.tsx
new file mode 100644
index 0000000000..72f5550c47
--- /dev/null
+++ b/apps/sr-frontend/src/app/Database.tsx
@@ -0,0 +1,150 @@
+import { SandboxStorage } from '@genshin-optimizer/database'
+import { DatabaseContext, SroDatabase } from '@genshin-optimizer/sr-db'
+import { CardThemed, DropdownButton } from '@genshin-optimizer/ui-common'
+import { range } from '@genshin-optimizer/util'
+import {
+ Button,
+ CardContent,
+ Container,
+ Grid,
+ MenuItem,
+ Typography,
+} from '@mui/material'
+import type { ChangeEvent } from 'react'
+import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
+
+export default function Database() {
+ const {
+ database: mainDB,
+ databases,
+ setDatabase,
+ } = useContext(DatabaseContext)
+ const [index, setIndex] = useState(0)
+ const database = databases[index]
+ const current = database === mainDB
+ const [data, setData] = useState('')
+ // Need to update the dbMeta when database changes
+ const [{ name, lastEdit }, setDBMeta] = useState(database.dbMeta.get())
+ useEffect(
+ () => database.dbMeta.follow((_r, dbMeta) => setDBMeta(dbMeta)),
+ [database]
+ )
+ useEffect(() => setDBMeta(database.dbMeta.get()), [database])
+
+ const { importedDatabase } =
+ useMemo(() => {
+ if (!data) return undefined
+ let parsed: any
+ try {
+ parsed = JSON.parse(data)
+ console.log(parsed)
+ if (typeof parsed !== 'object') {
+ return undefined
+ }
+ } catch (e) {
+ return undefined
+ }
+ // Figure out the file format
+ if (parsed.format === 'SROD' || parsed.format === 'SRO') {
+ // Parse as SROD format
+ const copyStorage = new SandboxStorage(undefined, 'sro')
+ copyStorage.copyFrom(database.storage)
+ const importedDatabase = new SroDatabase(
+ (index + 1) as 1 | 2 | 3 | 4,
+ copyStorage
+ )
+ const importResult = importedDatabase.importSROD(parsed, false, false)
+ if (!importResult) {
+ return undefined
+ }
+
+ return { importResult, importedDatabase }
+ }
+ return undefined
+ }, [data, database, index]) ?? {}
+
+ const onUpload = async (e: ChangeEvent) => {
+ const file = (e.target.files ?? [''])[0]
+ if (typeof file === 'string') return
+ const reader = new FileReader()
+ reader.onload = () => setData(reader.result as string)
+ reader.readAsText(file)
+ }
+
+ const download = useCallback(() => {
+ const date = new Date()
+ const dateStr = date
+ .toISOString()
+ .split('.')[0]
+ .replace('T', '_')
+ .replaceAll(':', '-')
+ const JSONStr = JSON.stringify(database.exportSROD())
+ const filename = `${name.trim().replaceAll(' ', '_')}_${dateStr}.json`
+ const contentType = 'application/json;charset=utf-8'
+ const a = document.createElement('a')
+ a.download = filename
+ a.href = `data:${contentType},${encodeURIComponent(JSONStr)}`
+ a.target = '_blank'
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ }, [database, name])
+
+ const replaceDb = useCallback(() => {
+ if (!importedDatabase) return
+ importedDatabase.swapStorage(database)
+ setDatabase(index, importedDatabase)
+ importedDatabase.toExtraLocalDB()
+ }, [database, importedDatabase, index, setDatabase])
+
+ const swapDb = useCallback(() => {
+ if (current) return
+ mainDB.toExtraLocalDB()
+ database.swapStorage(mainDB)
+ setDatabase(index, database)
+ }, [current, database, index, mainDB, setDatabase])
+
+ const clearDb = useCallback(() => {
+ database.clear()
+ database.toExtraLocalDB()
+ }, [database])
+
+ return (
+
+
+
+ Database
+
+
+ {range(0, 3).map((i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ Last Edit: {new Date(lastEdit).toLocaleString()}
+
+
+ {JSON.stringify(database.exportSROD(), undefined, 2)}
+
+
+
+
+ )
+}
diff --git a/apps/sr-frontend/src/app/Theme.tsx b/apps/sr-frontend/src/app/Theme.tsx
index 39d80df7fb..aea1c5c90e 100644
--- a/apps/sr-frontend/src/app/Theme.tsx
+++ b/apps/sr-frontend/src/app/Theme.tsx
@@ -1,95 +1,24 @@
-import { createTheme, darkScrollbar } from '@mui/material'
+import { createTheme } from '@mui/material'
+
+import { theme as commonTheme } from '@genshin-optimizer/ui-common'
declare module '@mui/material/styles' {
interface Palette {
- warning: Palette['primary']
- contentDark: Palette['primary']
- contentDarker: Palette['primary']
- contentLight: Palette['primary']
roll1: Palette['primary']
roll2: Palette['primary']
roll3: Palette['primary']
roll4: Palette['primary']
roll5: Palette['primary']
roll6: Palette['primary']
- geo: Palette['primary']
- dendro: Palette['primary']
- pyro: Palette['primary']
- hydro: Palette['primary']
- cryo: Palette['primary']
- electro: Palette['primary']
- anemo: Palette['primary']
- physical: Palette['primary']
- vaporize: Palette['primary']
- melt: Palette['primary']
- spread: Palette['primary']
- aggravate: Palette['primary']
- overloaded: Palette['primary']
- superconduct: Palette['primary']
- electrocharged: Palette['primary']
- shattered: Palette['primary']
- swirl: Palette['primary']
- burning: Palette['primary']
- crystallize: Palette['primary']
- heal: Palette['primary']
- bloom: Palette['primary']
- burgeon: Palette['primary']
- hyperbloom: Palette['primary']
-
- white: Palette['primary']
- red: Palette['primary']
-
- discord: Palette['primary']
- patreon: Palette['primary']
- twitch: Palette['primary']
- twitter: Palette['primary']
- paypal: Palette['primary']
- keqing: Palette['primary']
}
interface PaletteOptions {
- warning?: PaletteOptions['primary']
- contentDark?: PaletteOptions['primary']
- contentDarker?: PaletteOptions['primary']
- contentLight?: PaletteOptions['primary']
roll1?: PaletteOptions['primary']
roll2?: PaletteOptions['primary']
roll3?: PaletteOptions['primary']
roll4?: PaletteOptions['primary']
roll5?: PaletteOptions['primary']
roll6?: PaletteOptions['primary']
- geo?: PaletteOptions['primary']
- dendro?: PaletteOptions['primary']
- pyro?: PaletteOptions['primary']
- hydro?: PaletteOptions['primary']
- cryo?: PaletteOptions['primary']
- electro?: PaletteOptions['primary']
- anemo?: PaletteOptions['primary']
- physical?: PaletteOptions['primary']
- vaporize?: PaletteOptions['primary']
- melt?: PaletteOptions['primary']
- spread?: PaletteOptions['primary']
- aggravate?: PaletteOptions['primary']
- overloaded?: PaletteOptions['primary']
- superconduct?: PaletteOptions['primary']
- electrocharged?: PaletteOptions['primary']
- shattered?: PaletteOptions['primary']
- swirl?: PaletteOptions['primary']
- burning?: PaletteOptions['primary']
- crystallize?: PaletteOptions['primary']
heal?: PaletteOptions['primary']
- bloom?: PaletteOptions['primary']
- burgeon?: PaletteOptions['primary']
- hyperbloom?: PaletteOptions['primary']
-
- white?: PaletteOptions['primary']
- red?: PaletteOptions['primary']
-
- discord?: PaletteOptions['primary']
- patreon?: PaletteOptions['primary']
- twitch?: PaletteOptions['primary']
- twitter?: PaletteOptions['primary']
- paypal?: PaletteOptions['primary']
- keqing?: PaletteOptions['primary']
}
}
@@ -102,394 +31,69 @@ declare module '@mui/material/Button' {
roll4: true
roll5: true
roll6: true
- geo: true
- dendro: true
- pyro: true
- hydro: true
- cryo: true
- electro: true
- anemo: true
- physical: true
- vaporize: true
- melt: true
- spread: true
- aggravate: true
- overloaded: true
- superconduct: true
- electrocharged: true
- shattered: true
- swirl: true
- burning: true
- crystallize: true
heal: true
- bloom: true
- burgeon: true
- hyperbloom: true
-
- white: true
- red: true
-
- discord: true
- patreon: true
- twitch: true
- twitter: true
- paypal: true
- keqing: true
}
}
declare module '@mui/material/Chip' {
interface ChipPropsColorOverrides {
- warning: true
roll1: true
roll2: true
roll3: true
roll4: true
roll5: true
roll6: true
- geo: true
- dendro: true
- pyro: true
- hydro: true
- cryo: true
- electro: true
- anemo: true
- physical: true
- vaporize: true
- melt: true
- spread: true
- aggravate: true
- overloaded: true
- superconduct: true
- electrocharged: true
- shattered: true
- swirl: true
- burning: true
- crystallize: true
heal: true
- bloom: true
- burgeon: true
- hyperbloom: true
}
}
declare module '@mui/material/InputBase' {
interface InputBasePropsColorOverrides {
- warning: true
roll1: true
roll2: true
roll3: true
roll4: true
roll5: true
roll6: true
- geo: true
- dendro: true
- pyro: true
- hydro: true
- cryo: true
- electro: true
- anemo: true
- physical: true
- vaporize: true
- melt: true
}
}
declare module '@mui/material/SvgIcon' {
interface SvgIconPropsColorOverrides {
- geo: true
- dendro: true
- pyro: true
- hydro: true
- cryo: true
- electro: true
- anemo: true
- physical: true
- vaporize: true
- melt: true
- spread: true
- aggravate: true
- overloaded: true
- superconduct: true
- electrocharged: true
- shattered: true
- swirl: true
- burning: true
- crystallize: true
heal: true
- bloom: true
- burgeon: true
- hyperbloom: true
}
}
-const defaultTheme = createTheme({
- palette: {
- mode: `dark`,
- },
-})
export const theme = createTheme({
+ ...commonTheme,
palette: {
- mode: 'dark',
- primary: defaultTheme.palette.augmentColor({
- color: { main: '#1e78c8' },
- name: 'primary',
- }),
- secondary: defaultTheme.palette.augmentColor({
- color: { main: '#6c757d' },
- name: 'secondary',
- }),
- success: defaultTheme.palette.augmentColor({
- color: { main: '#46a046' },
- name: 'success',
- }),
- warning: defaultTheme.palette.augmentColor({
- color: { main: `#ffc107` },
- name: 'warning',
- }),
- error: defaultTheme.palette.augmentColor({
- color: { main: `#c83c3c` },
- name: 'error',
- }),
- background: {
- default: '#0C1020',
- paper: '#0C1020',
- },
- info: defaultTheme.palette.augmentColor({
- color: { main: '#17a2b8' },
- name: 'info',
- }),
- text: {
- primary: 'rgba(255,255,255,0.9)',
- },
- contentDark: defaultTheme.palette.augmentColor({
- color: { main: '#1b263b' },
- name: 'contentDark',
- }),
- contentDarker: defaultTheme.palette.augmentColor({
- color: { main: '#172032' },
- name: 'contentDarker',
- }),
- contentLight: defaultTheme.palette.augmentColor({
- color: { main: '#2a364d' },
- name: 'contentLight',
- }),
- roll1: defaultTheme.palette.augmentColor({
+ ...commonTheme.palette,
+ roll1: commonTheme.palette.augmentColor({
color: { main: '#a3a7a9' },
name: 'roll1',
}),
- roll2: defaultTheme.palette.augmentColor({
+ roll2: commonTheme.palette.augmentColor({
color: { main: '#6fa376' },
name: 'roll2',
}),
- roll3: defaultTheme.palette.augmentColor({
+ roll3: commonTheme.palette.augmentColor({
color: { main: '#8eea83' },
name: 'roll3',
}),
- roll4: defaultTheme.palette.augmentColor({
+ roll4: commonTheme.palette.augmentColor({
color: { main: '#31e09d' },
name: 'roll4',
}),
- roll5: defaultTheme.palette.augmentColor({
+ roll5: commonTheme.palette.augmentColor({
color: { main: '#27bbe4' },
name: 'roll5',
}),
- roll6: defaultTheme.palette.augmentColor({
+ roll6: commonTheme.palette.augmentColor({
color: { main: '#de79f0' },
name: 'roll6',
}),
- geo: defaultTheme.palette.augmentColor({
- color: { main: '#f8ba4e', contrastText: '#fff' },
- name: 'geo',
- }),
- dendro: defaultTheme.palette.augmentColor({
- color: { main: '#a5c83b', contrastText: '#fff' },
- name: 'dendro',
- }),
- pyro: defaultTheme.palette.augmentColor({
- color: { main: '#bf2818' },
- name: 'pyro',
- }),
- hydro: defaultTheme.palette.augmentColor({
- color: { main: '#2f63d4' },
- name: 'hydro',
- }),
- cryo: defaultTheme.palette.augmentColor({
- color: { main: '#77a2e6', contrastText: '#fff' },
- name: 'cryo',
- }),
- electro: defaultTheme.palette.augmentColor({
- color: { main: '#b25dcd' },
- name: 'electro',
- }),
- anemo: defaultTheme.palette.augmentColor({
- color: { main: '#61dbbb', contrastText: '#fff' },
- name: 'anemo',
- }),
- physical: defaultTheme.palette.augmentColor({
- color: { main: '#aaaaaa' },
- name: 'physical',
- }),
- vaporize: defaultTheme.palette.augmentColor({
- color: { main: '#ffcb65' },
- name: 'vaporize',
- }),
- melt: defaultTheme.palette.augmentColor({
- color: { main: '#ffcb65' },
- name: 'melt',
- }),
- spread: defaultTheme.palette.augmentColor({
- color: { main: '#3bc8a7', contrastText: '#fff' },
- name: 'spread',
- }),
- aggravate: defaultTheme.palette.augmentColor({
- color: { main: '#3ba0c8', contrastText: '#fff' },
- name: 'aggravate',
- }),
- overloaded: defaultTheme.palette.augmentColor({
- color: { main: '#ff7e9a' },
- name: 'overloaded',
- }),
- superconduct: defaultTheme.palette.augmentColor({
- color: { main: '#b7b1ff' },
- name: 'superconduct',
- }),
- electrocharged: defaultTheme.palette.augmentColor({
- color: { main: '#e299fd' },
- name: 'electrocharged',
- }),
- shattered: defaultTheme.palette.augmentColor({
- color: { main: '#98fffd' },
- name: 'shattered',
- }),
- swirl: defaultTheme.palette.augmentColor({
- color: { main: '#66ffcb' },
- name: 'swirl',
- }),
- burning: defaultTheme.palette.augmentColor({
- color: { main: '#bf2818' },
- name: 'burning',
- }),
- crystallize: defaultTheme.palette.augmentColor({
- color: { main: '#f8ba4e' },
- name: 'crystallize',
- }),
- heal: defaultTheme.palette.augmentColor({
+ heal: commonTheme.palette.augmentColor({
color: { main: '#c0e86c' },
name: 'heal',
}),
- bloom: defaultTheme.palette.augmentColor({
- color: { main: '#47c83b', contrastText: '#fff' },
- name: 'bloom',
- }),
- burgeon: defaultTheme.palette.augmentColor({
- color: { main: '#c8b33b', contrastText: '#fff' },
- name: 'burgeon',
- }),
- hyperbloom: defaultTheme.palette.augmentColor({
- color: { main: '#3b8dc8', contrastText: '#fff' },
- name: 'hyperbloom',
- }),
-
- white: defaultTheme.palette.augmentColor({
- color: { main: '#FFFFFF' },
- name: 'white',
- }),
- red: defaultTheme.palette.augmentColor({
- color: { main: '#ff0000' },
- name: 'red',
- }),
-
- discord: defaultTheme.palette.augmentColor({
- color: { main: '#5663F7' },
- name: 'discord',
- }),
- patreon: defaultTheme.palette.augmentColor({
- color: { main: '#f96854', contrastText: '#ffffff' },
- name: 'patreon',
- }),
- twitch: defaultTheme.palette.augmentColor({
- color: { main: '#6441a5' },
- name: 'twitch',
- }),
- twitter: defaultTheme.palette.augmentColor({
- color: { main: '#55acee', contrastText: '#ffffff' },
- name: 'twitter',
- }),
- paypal: defaultTheme.palette.augmentColor({
- color: { main: '#00457C' },
- name: 'paypal',
- }),
- keqing: defaultTheme.palette.augmentColor({
- color: { main: '#584862' },
- name: 'keqing',
- }),
- },
- typography: {
- button: {
- textTransform: 'none',
- },
- },
- components: {
- MuiCssBaseline: {
- styleOverrides: {
- body: defaultTheme.palette.mode === 'dark' ? darkScrollbar() : null,
- },
- },
- MuiAppBar: {
- defaultProps: {
- enableColorOnDark: true,
- },
- },
- MuiPaper: {
- defaultProps: {
- elevation: 0,
- },
- },
- MuiButton: {
- defaultProps: {
- variant: 'contained',
- },
- },
- MuiButtonGroup: {
- defaultProps: {
- variant: 'contained',
- },
- },
- MuiList: {
- styleOverrides: {
- root: {
- padding: 0,
- marginTop: defaultTheme.spacing(1),
- marginBottom: defaultTheme.spacing(1),
- },
- },
- },
- MuiTypography: {
- styleOverrides: {
- root: {
- '& ul': {
- margin: 0,
- paddingLeft: defaultTheme.spacing(3),
- },
- },
- },
- },
- MuiCardContent: {
- styleOverrides: {
- root: {
- [defaultTheme.breakpoints.down('sm')]: {
- padding: defaultTheme.spacing(1),
- '&:last-child': {
- paddingBottom: defaultTheme.spacing(1),
- },
- },
- [defaultTheme.breakpoints.up('sm')]: {
- '&:last-child': {
- paddingBottom: defaultTheme.spacing(2),
- },
- },
- },
- },
- },
},
})
diff --git a/apps/sr-frontend/tsconfig.json b/apps/sr-frontend/tsconfig.json
index 137333e4b7..617479b130 100644
--- a/apps/sr-frontend/tsconfig.json
+++ b/apps/sr-frontend/tsconfig.json
@@ -2,7 +2,7 @@
"extends": "../../tsconfig.base.json",
"files": [],
"compilerOptions": {
- "jsx": "react",
+ "jsx": "react-jsx",
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ESNext", "DOM"],
@@ -15,6 +15,7 @@
"noUnusedParameters": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
+ "exactOptionalPropertyTypes": false,
"types": ["vite/client", "vitest"]
},
"include": ["src"],
diff --git a/libs/database/src/lib/DBLocalStorage.ts b/libs/database/src/lib/DBLocalStorage.ts
index 57b369a876..202e507cf5 100644
--- a/libs/database/src/lib/DBLocalStorage.ts
+++ b/libs/database/src/lib/DBLocalStorage.ts
@@ -1,10 +1,23 @@
-import type { DBStorage } from './DBStorage'
+import type { DbIndexKey, DbVersionKey } from './DBStorage'
+import { type DBStorage, type StorageType } from './DBStorage'
export class DBLocalStorage implements DBStorage {
private storage: Storage
+ dbVersionKey: DbVersionKey
+ dbIndexKey: DbIndexKey
- constructor(storage: Storage) {
+ constructor(storage: Storage, storageType: StorageType = 'go') {
this.storage = storage
+ switch (storageType) {
+ case 'go':
+ this.dbVersionKey = 'db_ver'
+ this.dbIndexKey = 'dbIndex'
+ break
+ case 'sro':
+ this.dbVersionKey = 'sro_db_ver'
+ this.dbIndexKey = 'sro_dbIndex'
+ break
+ }
}
get keys(): string[] {
@@ -52,15 +65,15 @@ export class DBLocalStorage implements DBStorage {
}
}
getDBVersion(): number {
- return parseInt(this.getString('db_ver') ?? '0')
+ return parseInt(this.getString(this.dbVersionKey) ?? '0')
}
setDBVersion(version: number): void {
- this.setString('db_ver', version.toString())
+ this.setString(this.dbVersionKey, version.toString())
}
getDBIndex(): 1 | 2 | 3 | 4 {
- return parseInt(this.getString('dbIndex') ?? '1') as 1 | 2 | 3 | 4
+ return parseInt(this.getString(this.dbIndexKey) ?? '1') as 1 | 2 | 3 | 4
}
setDBIndex(ind: 1 | 2 | 3 | 4) {
- this.setString('dbIndex', ind.toString())
+ this.setString(this.dbIndexKey, ind.toString())
}
}
diff --git a/libs/database/src/lib/DBStorage.ts b/libs/database/src/lib/DBStorage.ts
index 03e3382deb..b399d3d7a2 100644
--- a/libs/database/src/lib/DBStorage.ts
+++ b/libs/database/src/lib/DBStorage.ts
@@ -1,6 +1,16 @@
+export const dbVersionKeys = ['db_ver', 'sro_db_ver'] as const
+export type DbVersionKey = (typeof dbVersionKeys)[number]
+
+export const dbIndexKeys = ['dbIndex', 'sro_dbIndex'] as const
+export type DbIndexKey = (typeof dbIndexKeys)[number]
+
+export type StorageType = 'go' | 'sro'
+
export interface DBStorage {
keys: string[]
entries: [key: string, value: string][]
+ dbVersionKey: DbVersionKey
+ dbIndexKey: DbIndexKey
get(key: string): any | undefined
set(key: string, value: any): void
diff --git a/libs/database/src/lib/DataManagerBase.ts b/libs/database/src/lib/DataManagerBase.ts
index 4f6b3a0900..92ddf6f4b1 100644
--- a/libs/database/src/lib/DataManagerBase.ts
+++ b/libs/database/src/lib/DataManagerBase.ts
@@ -53,10 +53,10 @@ export class DataManagerBase<
}
get keys() {
- return Object.keys(this.data)
+ return Object.keys(this.data) as CacheKey[]
}
get values() {
- return Object.values(this.data)
+ return Object.values(this.data) as CacheValue[]
}
get(key: CacheKey | '' | undefined): CacheValue | undefined {
return key ? this.data[key] : undefined
diff --git a/libs/database/src/lib/SandboxStorage.ts b/libs/database/src/lib/SandboxStorage.ts
index c287c005be..48568c718f 100644
--- a/libs/database/src/lib/SandboxStorage.ts
+++ b/libs/database/src/lib/SandboxStorage.ts
@@ -1,10 +1,27 @@
-import type { DBStorage } from './DBStorage'
+import type {
+ DBStorage,
+ DbIndexKey,
+ DbVersionKey,
+ StorageType,
+} from './DBStorage'
export class SandboxStorage implements DBStorage {
protected storage: Record = {}
+ dbVersionKey: DbVersionKey
+ dbIndexKey: DbIndexKey
- constructor(obj?: Record) {
+ constructor(obj?: Record, storageType: StorageType = 'go') {
if (obj) this.storage = obj
+ switch (storageType) {
+ case 'go':
+ this.dbVersionKey = 'db_ver'
+ this.dbIndexKey = 'dbIndex'
+ break
+ case 'sro':
+ this.dbVersionKey = 'sro_db_ver'
+ this.dbIndexKey = 'sro_dbIndex'
+ break
+ }
}
get keys(): string[] {
@@ -52,15 +69,15 @@ export class SandboxStorage implements DBStorage {
)
}
getDBVersion(): number {
- return parseInt(this.getString('db_ver') ?? '0')
+ return parseInt(this.getString(this.dbVersionKey) ?? '0')
}
setDBVersion(version: number): void {
- this.setString('db_ver', version.toString())
+ this.setString(this.dbVersionKey, version.toString())
}
getDBIndex(): 1 | 2 | 3 | 4 {
- return parseInt(this.getString('dbIndex') ?? '1') as 1 | 2 | 3 | 4
+ return parseInt(this.getString(this.dbIndexKey) ?? '1') as 1 | 2 | 3 | 4
}
setDBIndex(ind: 1 | 2 | 3 | 4) {
- this.setString('dbIndex', ind.toString())
+ this.setString(this.dbIndexKey, ind.toString())
}
}
diff --git a/libs/sr-consts/src/character.ts b/libs/sr-consts/src/character.ts
index 667c658f1b..fefbfdfd06 100644
--- a/libs/sr-consts/src/character.ts
+++ b/libs/sr-consts/src/character.ts
@@ -69,7 +69,7 @@ export type TrailblazerKey = (typeof allTrailblazerKeys)[number]
export const allCharacterKeys = [
...nonTrailblazerCharacterKeys,
- ...allTrailblazerGenderedKeys,
+ ...allTrailblazerKeys,
] as const
export type CharacterKey = (typeof allCharacterKeys)[number]
@@ -87,3 +87,11 @@ export type BonusAbilityKey = (typeof allBonusAbilityKeys)[number]
export const allStatBoostKeys = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const
export type StatBoostKey = (typeof allStatBoostKeys)[number]
+
+export function charKeyToCharLocKey(
+ charKey: CharacterKey
+): CharacterLocationKey {
+ return charKey.includes('Trailblazer')
+ ? 'Trailblazer'
+ : (charKey as CharacterLocationKey)
+}
diff --git a/libs/sr-consts/src/lightCone.ts b/libs/sr-consts/src/lightCone.ts
index 02c870d81d..0b9389b815 100644
--- a/libs/sr-consts/src/lightCone.ts
+++ b/libs/sr-consts/src/lightCone.ts
@@ -73,3 +73,5 @@ export type LightConeKey = (typeof allLightConeKeys)[number]
export const allSuperimposeKeys = [0, 1, 2, 3, 4, 5] as const
export type SuperimposeKey = (typeof allSuperimposeKeys)[number]
+
+export const lightConeMaxLevel = 80
diff --git a/libs/sr-consts/src/relic.ts b/libs/sr-consts/src/relic.ts
index d7b00b0687..0066bae8d3 100644
--- a/libs/sr-consts/src/relic.ts
+++ b/libs/sr-consts/src/relic.ts
@@ -42,7 +42,6 @@ export const allRelicSetKeys = [
...allRelicCavernSetKeys,
...allRelicPlanarSetKeys,
] as const
-
export type RelicSetKey = (typeof allRelicSetKeys)[number]
export const allRelicSubStatKeys = [
@@ -59,7 +58,6 @@ export const allRelicSubStatKeys = [
'eff_res_',
'brEff_',
] as const
-
export type RelicSubStatKey = (typeof allRelicSubStatKeys)[number]
export const allRelicMainStatKeys = [
@@ -83,5 +81,45 @@ export const allRelicMainStatKeys = [
'brEff_',
'enerRegen_',
] as const
-
export type RelicMainStatKey = (typeof allRelicMainStatKeys)[number]
+
+export const allRelicRarityKeys = [2, 3, 4, 5] as const
+export type RelicRarityKey = (typeof allRelicRarityKeys)[number]
+
+export const relicMaxLevel: Record = {
+ 2: 6,
+ 3: 9,
+ 4: 12,
+ 5: 15,
+} as const
+
+export const relicSubstatRollData: Record<
+ RelicRarityKey,
+ { low: number; high: number; numUpgrades: number }
+> = {
+ 2: { low: 0, high: 0, numUpgrades: 0 },
+ 3: { low: 1, high: 2, numUpgrades: 1 },
+ 4: { low: 2, high: 3, numUpgrades: 3 },
+ 5: { low: 3, high: 4, numUpgrades: 5 },
+} as const
+
+export const relicSlotToMainStatKeys: Record =
+ {
+ head: ['hp'],
+ hand: ['atk'],
+ body: ['hp_', 'atk_', 'def_', 'eff_', 'heal_', 'crit_', 'crit_dmg_'],
+ feet: ['hp_', 'atk_', 'def_', 'spd'],
+ sphere: [
+ 'hp_',
+ 'atk_',
+ 'def_',
+ 'physical_dmg_',
+ 'fire_dmg_',
+ 'ice_dmg_',
+ 'wind_dmg_',
+ 'lightning_dmg_',
+ 'quantum_dmg_',
+ 'imaginary_dmg_',
+ ],
+ rope: ['brEff_', 'enerRegen_'],
+ }
diff --git a/libs/sr-db/project.json b/libs/sr-db/project.json
index 74ac1693f9..820e2e56f9 100644
--- a/libs/sr-db/project.json
+++ b/libs/sr-db/project.json
@@ -10,6 +10,12 @@
"options": {
"lintFilePatterns": ["libs/sr-db/**/*.ts"]
}
+ },
+ "test": {
+ "executor": "@nx/vite:test",
+ "options": {
+ "config": "libs/sr-db/vitest.config.ts"
+ }
}
},
"tags": []
diff --git a/libs/sr-db/src/Database/DataEntries/DBMetaEntry.ts b/libs/sr-db/src/Database/DataEntries/DBMetaEntry.ts
new file mode 100644
index 0000000000..10cf5bce7b
--- /dev/null
+++ b/libs/sr-db/src/Database/DataEntries/DBMetaEntry.ts
@@ -0,0 +1,56 @@
+import type { Database } from '@genshin-optimizer/database'
+import type { GenderKey } from '@genshin-optimizer/sr-consts'
+import { allGenderKeys } from '@genshin-optimizer/sr-consts'
+import type { ISrObjectDescription } from '@genshin-optimizer/sr-srod'
+import type { ISroDatabase } from '../../Interfaces'
+import { DataEntry } from '../DataEntry'
+import type { SroDatabase } from '../Database'
+import type { ImportResult } from '../exim'
+
+interface IDBMeta {
+ name: string
+ lastEdit: number
+ gender: GenderKey
+}
+
+function dbMetaInit(database: Database): IDBMeta {
+ return {
+ name: `Database ${database.storage.getDBIndex()}`,
+ lastEdit: 0,
+ gender: 'F',
+ }
+}
+
+const storageKey = 'sro_dbMeta'
+export class DBMetaEntry extends DataEntry<
+ typeof storageKey,
+ typeof storageKey,
+ IDBMeta,
+ IDBMeta
+> {
+ constructor(database: SroDatabase) {
+ super(database, storageKey, dbMetaInit, storageKey)
+ }
+ override validate(obj: any): IDBMeta | undefined {
+ if (typeof obj !== 'object') return undefined
+ let { name, lastEdit, gender } = obj
+ if (typeof name !== 'string')
+ name = `Database ${this.database.storage.getDBIndex()}`
+ if (typeof lastEdit !== 'number') console.warn('lastEdit INVALID')
+ if (typeof lastEdit !== 'number') lastEdit = 0
+ if (!allGenderKeys.includes(gender)) gender = 'F'
+
+ return { name, lastEdit, gender } as IDBMeta
+ }
+ override importSROD(
+ sroDb: ISroDatabase & ISrObjectDescription,
+ _result: ImportResult
+ ): void {
+ const data = sroDb[this.goKey]
+ if (data) {
+ // Don't copy over lastEdit data
+ const { lastEdit, ...rest } = data as IDBMeta
+ this.set(rest)
+ }
+ }
+}
diff --git a/libs/sr-db/src/Database/DataEntry.test.ts b/libs/sr-db/src/Database/DataEntry.test.ts
new file mode 100644
index 0000000000..8cdcb663bd
--- /dev/null
+++ b/libs/sr-db/src/Database/DataEntry.test.ts
@@ -0,0 +1,30 @@
+import { DBLocalStorage } from '@genshin-optimizer/database'
+import { SroDatabase } from './Database'
+
+const dbStorage = new DBLocalStorage(localStorage, 'sro')
+const dbIndex = 1
+let database = new SroDatabase(dbIndex, dbStorage)
+
+describe('Database', () => {
+ beforeEach(() => {
+ dbStorage.clear()
+ database = new SroDatabase(dbIndex, dbStorage)
+ })
+ test('initialValue', () => {
+ expect(database.dbMeta.get().gender).toEqual('F')
+ })
+ test('DataEntry.set', () => {
+ expect(database.dbMeta.get().name).toEqual('Database 1')
+
+ database.dbMeta.set({ name: 'test' })
+ expect(database.dbMeta.get().name).toEqual('test')
+
+ database.dbMeta.set((dbMeta) => {
+ dbMeta.name = `test ${dbMeta.name}`
+ })
+ expect(database.dbMeta.get().name).toEqual('test test')
+
+ database.dbMeta.set(({ name }) => ({ name: `test ${name}` }))
+ expect(database.dbMeta.get().name).toEqual('test test test')
+ })
+})
diff --git a/libs/sr-db/src/Database/DataEntry.ts b/libs/sr-db/src/Database/DataEntry.ts
new file mode 100644
index 0000000000..a169a04840
--- /dev/null
+++ b/libs/sr-db/src/Database/DataEntry.ts
@@ -0,0 +1,25 @@
+import { DataEntryBase } from '@genshin-optimizer/database'
+import type { ISrObjectDescription } from '@genshin-optimizer/sr-srod'
+import type { ISroDatabase } from '../Interfaces'
+import type { ImportResult } from './exim'
+
+export class DataEntry<
+ Key extends string,
+ SROKey extends string,
+ CacheValue,
+ StorageValue
+> extends DataEntryBase {
+ exportSROD(sroDb: Partial) {
+ const key = this.goKey.replace('sro_', '')
+ sroDb[key] = this.data
+ }
+ importSROD(
+ sroDb: ISrObjectDescription &
+ ISroDatabase & { [k in SROKey]?: Partial | never },
+ _result: ImportResult
+ ) {
+ const key = this.goKey.replace('sro_', '')
+ const data = sroDb[key]
+ if (data) this.set(data)
+ }
+}
diff --git a/libs/sr-db/src/Database/DataManager.test.ts b/libs/sr-db/src/Database/DataManager.test.ts
new file mode 100644
index 0000000000..9b2fc2f23f
--- /dev/null
+++ b/libs/sr-db/src/Database/DataManager.test.ts
@@ -0,0 +1,31 @@
+import { DBLocalStorage } from '@genshin-optimizer/database'
+import { randomizeRelic } from '@genshin-optimizer/sr-util'
+import { SroDatabase } from './Database'
+
+const dbStorage = new DBLocalStorage(localStorage, 'sro')
+const dbIndex = 1
+let database = new SroDatabase(dbIndex, dbStorage)
+
+describe('Database', () => {
+ beforeEach(() => {
+ dbStorage.clear()
+ database = new SroDatabase(dbIndex, dbStorage)
+ })
+
+ test('DataManager.set', () => {
+ const invalid = database.relics.set('INVALID', () => ({ level: 0 }))
+ expect(invalid).toEqual(false)
+ expect(database.relics.values.length).toEqual(0)
+ const id = 'testid'
+ database.relics.set(id, randomizeRelic({ rarity: 4, level: 0 }))
+ expect(database.relics.get(id)?.level).toEqual(0)
+
+ database.relics.set(id, (art) => {
+ art.level = art.level + 3
+ })
+ expect(database.relics.get(id)?.level).toEqual(3)
+
+ database.relics.set(id, ({ level }) => ({ level: level + 3 }))
+ expect(database.relics.get(id)?.level).toEqual(6)
+ })
+})
diff --git a/libs/sr-db/src/Database/DataManager.ts b/libs/sr-db/src/Database/DataManager.ts
new file mode 100644
index 0000000000..cd2f85b683
--- /dev/null
+++ b/libs/sr-db/src/Database/DataManager.ts
@@ -0,0 +1,38 @@
+import type { Database } from '@genshin-optimizer/database'
+import { DataManagerBase } from '@genshin-optimizer/database'
+import type { ISrObjectDescription } from '@genshin-optimizer/sr-srod'
+import type { ISroDatabase } from '../'
+import type { ImportResult } from './exim'
+export class DataManager<
+ CacheKey extends string,
+ DataKey extends string,
+ CacheValue extends StorageValue,
+ StorageValue,
+ DatabaseType extends Database
+> extends DataManagerBase<
+ CacheKey,
+ DataKey,
+ CacheValue,
+ StorageValue,
+ DatabaseType
+> {
+ exportSROD(sro: Partial) {
+ const key = this.dataKey.replace('sro_', '')
+ sro[key] = (Object.entries(this.data) as [CacheKey, CacheValue][]).map(
+ ([id, value]) => ({
+ ...this.deCache(value),
+ id,
+ })
+ )
+ }
+ importSROD(sro: ISrObjectDescription & ISroDatabase, _result: ImportResult) {
+ const entries = sro[this.dataKey]
+ if (entries && Array.isArray(entries))
+ entries.forEach((ele) => ele.id && this.set(ele.id, ele))
+ }
+ override get goKeySingle() {
+ const key = this.dataKey.replace('sro_', '')
+ if (key.endsWith('s')) return key.slice(0, -1)
+ return key
+ }
+}
diff --git a/libs/sr-db/src/Database/DataManagers/BuildResultData.ts b/libs/sr-db/src/Database/DataManagers/BuildResultData.ts
new file mode 100644
index 0000000000..96124663ea
--- /dev/null
+++ b/libs/sr-db/src/Database/DataManagers/BuildResultData.ts
@@ -0,0 +1,76 @@
+import type { CharacterKey } from '@genshin-optimizer/sr-consts'
+import {
+ allCharacterKeys,
+ allRelicSlotKeys,
+} from '@genshin-optimizer/sr-consts'
+import { deepClone } from '@genshin-optimizer/util'
+import { DataManager } from '../DataManager'
+import type { SroDatabase } from '../Database'
+
+export interface IBuildResult {
+ builds: string[][]
+ buildDate: number
+}
+
+const storageKey = 'sro_buildResults'
+const storageHash = 'sro_buildResult_'
+export class BuildResultDataManager extends DataManager<
+ CharacterKey,
+ typeof storageKey,
+ IBuildResult,
+ IBuildResult,
+ SroDatabase
+> {
+ constructor(database: SroDatabase) {
+ super(database, storageKey)
+ for (const key of this.database.storage.keys)
+ if (key.startsWith(storageHash)) {
+ const charKey = key.split(storageHash)[1] as CharacterKey
+ if (!this.set(charKey, {})) this.database.storage.remove(key)
+ }
+ }
+ override toStorageKey(key: string): string {
+ return `${storageHash}${key}`
+ }
+ override validate(obj: unknown, key: CharacterKey): IBuildResult | undefined {
+ if (typeof obj !== 'object') return undefined
+ if (!allCharacterKeys.includes(key)) return undefined
+ let { builds, buildDate } = obj as IBuildResult
+
+ if (!Array.isArray(builds)) {
+ builds = []
+ buildDate = 0
+ } else {
+ builds = builds
+ .map((build) => {
+ if (!Array.isArray(build)) return []
+ const filteredBuild = build.filter((id) =>
+ this.database.relics.get(id)
+ )
+ // Check that builds has only 1 relic of each slot
+ if (
+ allRelicSlotKeys.some(
+ (s) =>
+ filteredBuild.filter(
+ (id) => this.database.relics.get(id)?.slotKey === s
+ ).length > 1
+ )
+ )
+ return []
+ return filteredBuild
+ })
+ .filter((x) => x.length)
+ if (!Number.isInteger(buildDate)) buildDate = 0
+ }
+
+ return { builds, buildDate }
+ }
+ override get(key: CharacterKey) {
+ return super.get(key) ?? initialBuildResult
+ }
+}
+
+const initialBuildResult: IBuildResult = deepClone({
+ builds: [],
+ buildDate: 0,
+})
diff --git a/libs/sr-db/src/Database/DataManagers/BuildSettingData.ts b/libs/sr-db/src/Database/DataManagers/BuildSettingData.ts
new file mode 100644
index 0000000000..46fd6f7c3f
--- /dev/null
+++ b/libs/sr-db/src/Database/DataManagers/BuildSettingData.ts
@@ -0,0 +1,226 @@
+import type {
+ CharacterKey,
+ CharacterLocationKey,
+ RelicMainStatKey,
+} from '@genshin-optimizer/sr-consts'
+import {
+ allCharacterKeys,
+ allCharacterLocationKeys,
+ allRelicSetKeys,
+ relicSlotToMainStatKeys,
+} from '@genshin-optimizer/sr-consts'
+import { deepClone, deepFreeze, validateArr } from '@genshin-optimizer/util'
+import { DataManager } from '../DataManager'
+import type { SroDatabase } from '../Database'
+
+export const maxBuildsToShowList = [1, 2, 3, 4, 5, 8, 10] as const
+export const maxBuildsToShowDefault = 5
+
+export const allAllowLocationsState = [
+ 'unequippedOnly',
+ 'customList',
+ 'all',
+] as const
+export type AllowLocationsState = (typeof allAllowLocationsState)[number]
+
+export const allRelicSetExclusionKeys = [...allRelicSetKeys, 'rainbow'] as const
+export type RelicSetExclusionKey = (typeof allRelicSetExclusionKeys)[number]
+
+export type RelicSetExclusion = Partial>
+
+export interface StatFilterSetting {
+ value: number
+ disabled: boolean
+}
+export type StatFilters = Record
+export interface BuildSetting {
+ relicSetExclusion: RelicSetExclusion
+ statFilters: StatFilters
+ mainStatKeys: {
+ body: RelicMainStatKey[]
+ feet: RelicMainStatKey[]
+ sphere: RelicMainStatKey[]
+ rope: RelicMainStatKey[]
+ head?: never
+ hand?: never
+ }
+ excludedLocations: CharacterLocationKey[]
+ allowLocationsState: AllowLocationsState
+ relicExclusion: string[]
+ useExcludedRelics: boolean
+ optimizationTarget?: string[]
+ mainStatAssumptionLevel: number
+ allowPartial: boolean
+ maxBuildsToShow: number
+ plotBase?: string[]
+ compareBuild: boolean
+ levelLow: number
+ levelHigh: number
+}
+
+const storageKey = 'sro_buildSettings'
+const storageHash = 'sro_buildSetting_'
+export class BuildSettingDataManager extends DataManager<
+ CharacterKey,
+ typeof storageKey,
+ BuildSetting,
+ BuildSetting,
+ SroDatabase
+> {
+ constructor(database: SroDatabase) {
+ super(database, storageKey)
+ for (const key of this.database.storage.keys)
+ if (
+ key.startsWith(storageHash) &&
+ !this.set(key.split(storageHash)[1] as CharacterKey, {})
+ )
+ this.database.storage.remove(key)
+ }
+ override toStorageKey(key: string): string {
+ return `${storageHash}${key}`
+ }
+ override validate(obj: object, key: string): BuildSetting | undefined {
+ if (!allCharacterKeys.includes(key as CharacterKey)) return undefined
+ if (typeof obj !== 'object') return undefined
+ let {
+ relicSetExclusion,
+ relicExclusion,
+ useExcludedRelics,
+ statFilters,
+ mainStatKeys,
+ optimizationTarget,
+ mainStatAssumptionLevel,
+ excludedLocations,
+ allowLocationsState,
+ allowPartial,
+ maxBuildsToShow,
+ plotBase,
+ compareBuild,
+ levelLow,
+ levelHigh,
+ } = obj as BuildSetting
+
+ if (typeof statFilters !== 'object') statFilters = {}
+
+ if (
+ !mainStatKeys ||
+ !mainStatKeys.body ||
+ !mainStatKeys.feet ||
+ !mainStatKeys.sphere ||
+ !mainStatKeys.rope
+ )
+ mainStatKeys = deepClone(initialBuildSettings.mainStatKeys)
+ else {
+ // make sure the arrays are not empty
+ ;(['body', 'feet', 'sphere', 'rope'] as const).forEach((sk) => {
+ if (!mainStatKeys[sk].length)
+ mainStatKeys[sk] = [...relicSlotToMainStatKeys[sk]]
+ })
+ }
+
+ if (!optimizationTarget || !Array.isArray(optimizationTarget))
+ optimizationTarget = undefined
+ if (
+ typeof mainStatAssumptionLevel !== 'number' ||
+ mainStatAssumptionLevel < 0 ||
+ mainStatAssumptionLevel > 15
+ )
+ mainStatAssumptionLevel = 0
+
+ if (!relicExclusion || !Array.isArray(relicExclusion)) relicExclusion = []
+ else
+ relicExclusion = [...new Set(relicExclusion)].filter((id) =>
+ this.database.relics.keys.includes(id)
+ )
+
+ excludedLocations = validateArr(
+ excludedLocations,
+ allCharacterLocationKeys.filter((k) => k !== key),
+ [] // Remove self from list
+ ).filter(
+ (lk) =>
+ this.database.chars.get(this.database.chars.LocationToCharacterKey(lk)) // Remove characters who do not exist in the DB
+ )
+ if (!allowLocationsState) allowLocationsState = 'unequippedOnly'
+
+ if (
+ !maxBuildsToShowList.includes(
+ maxBuildsToShow as (typeof maxBuildsToShowList)[number]
+ )
+ )
+ maxBuildsToShow = maxBuildsToShowDefault
+ if (!plotBase || !Array.isArray(plotBase)) plotBase = undefined
+ if (compareBuild === undefined) compareBuild = false
+ if (levelLow === undefined) levelLow = 0
+ if (levelHigh === undefined) levelHigh = 20
+ if (!relicSetExclusion) relicSetExclusion = {}
+ if (useExcludedRelics === undefined) useExcludedRelics = false
+ if (!allowPartial) allowPartial = false
+ relicSetExclusion = Object.fromEntries(
+ Object.entries(relicSetExclusion as RelicSetExclusion)
+ .map(([k, r]) => [k, [...new Set(r)]])
+ .filter(([_, a]) => a.length)
+ )
+ return {
+ relicSetExclusion,
+ relicExclusion,
+ useExcludedRelics,
+ statFilters,
+ mainStatKeys,
+ optimizationTarget,
+ mainStatAssumptionLevel,
+ excludedLocations,
+ allowLocationsState,
+ allowPartial,
+ maxBuildsToShow,
+ plotBase,
+ compareBuild,
+ levelLow,
+ levelHigh,
+ }
+ }
+ override get(key: CharacterKey) {
+ return super.get(key) ?? initialBuildSettings
+ }
+}
+
+const initialBuildSettings: BuildSetting = deepFreeze({
+ relicSetExclusion: {},
+ relicExclusion: [],
+ useExcludedRelics: false,
+ statFilters: {},
+ mainStatKeys: {
+ body: relicSlotToMainStatKeys.body,
+ feet: relicSlotToMainStatKeys.feet,
+ sphere: relicSlotToMainStatKeys.sphere,
+ rope: relicSlotToMainStatKeys.rope,
+ },
+ optimizationTarget: undefined,
+ mainStatAssumptionLevel: 0,
+ excludedLocations: [],
+ allowLocationsState: 'unequippedOnly',
+ allowPartial: false,
+ maxBuildsToShow: 5,
+ plotBase: undefined,
+ compareBuild: true,
+ levelLow: 0,
+ levelHigh: 20,
+})
+
+// TODO: Remove 4-set exclusion for planar relics
+export function handleRelicSetExclusion(
+ currentRelicSetExclusion: RelicSetExclusion,
+ setKey: RelicSetExclusionKey,
+ num: 2 | 4
+) {
+ const relicSetExclusion = deepClone(currentRelicSetExclusion)
+ const setExclusion = relicSetExclusion[setKey]
+ if (!setExclusion) relicSetExclusion[setKey] = [num]
+ else if (!setExclusion.includes(num))
+ relicSetExclusion[setKey] = [...setExclusion, num]
+ else {
+ relicSetExclusion[setKey] = setExclusion.filter((n) => n !== num)
+ if (!setExclusion.length) delete relicSetExclusion[setKey]
+ }
+ return relicSetExclusion
+}
diff --git a/libs/sr-db/src/Database/DataManagers/CharMetaData.ts b/libs/sr-db/src/Database/DataManagers/CharMetaData.ts
new file mode 100644
index 0000000000..a6f559be68
--- /dev/null
+++ b/libs/sr-db/src/Database/DataManagers/CharMetaData.ts
@@ -0,0 +1,66 @@
+import type {
+ CharacterKey,
+ CharacterLocationKey,
+ RelicSubStatKey,
+} from '@genshin-optimizer/sr-consts'
+import {
+ allRelicSubStatKeys,
+ allTrailblazerKeys,
+} from '@genshin-optimizer/sr-consts'
+import { deepFreeze } from '@genshin-optimizer/util'
+import { DataManager } from '../DataManager'
+import type { SroDatabase } from '../Database'
+
+interface ICharMeta {
+ rvFilter: RelicSubStatKey[]
+ favorite: boolean
+}
+const initCharMeta: ICharMeta = deepFreeze({
+ rvFilter: [...allRelicSubStatKeys],
+ favorite: false,
+})
+
+const storageKey = 'sro_charMetas'
+const storageHash = 'sro_charMeta_'
+export class CharMetaDataManager extends DataManager<
+ CharacterKey,
+ typeof storageKey,
+ ICharMeta,
+ ICharMeta,
+ SroDatabase
+> {
+ constructor(database: SroDatabase) {
+ super(database, storageKey)
+ for (const key of this.database.storage.keys)
+ if (
+ key.startsWith(storageHash) &&
+ !this.set(key.split(storageHash)[1] as CharacterKey, {})
+ )
+ this.database.storage.remove(key)
+ }
+ override validate(obj: any): ICharMeta | undefined {
+ if (typeof obj !== 'object') return undefined
+
+ let { rvFilter, favorite } = obj
+ if (!Array.isArray(rvFilter)) rvFilter = []
+ else rvFilter = rvFilter.filter((k) => allRelicSubStatKeys.includes(k))
+ if (typeof favorite !== 'boolean') favorite = false
+ return { rvFilter, favorite }
+ }
+
+ override toStorageKey(key: CharacterKey): string {
+ return `${storageHash}${key}`
+ }
+ getTrailblazerCharacterKey(): CharacterKey {
+ return (
+ allTrailblazerKeys.find((k) => this.keys.includes(k)) ??
+ allTrailblazerKeys[0]
+ )
+ }
+ LocationToCharacterKey(key: CharacterLocationKey): CharacterKey {
+ return key === 'Trailblazer' ? this.getTrailblazerCharacterKey() : key
+ }
+ override get(key: CharacterKey): ICharMeta {
+ return this.data[key] ?? initCharMeta
+ }
+}
diff --git a/libs/sr-db/src/Database/DataManagers/CharacterData.ts b/libs/sr-db/src/Database/DataManagers/CharacterData.ts
new file mode 100644
index 0000000000..1b80f33447
--- /dev/null
+++ b/libs/sr-db/src/Database/DataManagers/CharacterData.ts
@@ -0,0 +1,390 @@
+import type { TriggerString } from '@genshin-optimizer/database'
+import { validateLevelAsc } from '@genshin-optimizer/gi-util'
+import type {
+ CharacterKey,
+ CharacterLocationKey,
+ RelicSlotKey,
+ TrailblazerKey,
+} from '@genshin-optimizer/sr-consts'
+import {
+ allBonusAbilityKeys,
+ allCharacterKeys,
+ allHitModeKeys,
+ allRelicSlotKeys,
+ allStatBoostKeys,
+ allTrailblazerKeys,
+ charKeyToCharLocKey,
+} from '@genshin-optimizer/sr-consts'
+import type { ISrObjectDescription } from '@genshin-optimizer/sr-srod'
+import { clamp, deepClone, objKeyMap } from '@genshin-optimizer/util'
+import type {
+ ICachedSroCharacter,
+ ISroCharacter,
+ ISroDatabase,
+} from '../../Interfaces'
+import { SroSource } from '../../Interfaces'
+import { DataManager } from '../DataManager'
+import type { SroDatabase } from '../Database'
+import type { ImportResult } from '../exim'
+
+const storageKey = 'sro_characters'
+const storageHash = 'sro_char_'
+export class CharacterDataManager extends DataManager<
+ CharacterKey,
+ typeof storageKey,
+ ICachedSroCharacter,
+ ISroCharacter,
+ SroDatabase
+> {
+ constructor(database: SroDatabase) {
+ super(database, storageKey)
+ for (const key of this.database.storage.keys) {
+ if (
+ key.startsWith(storageHash) &&
+ !this.set(key.split(storageHash)[1] as CharacterKey, {})
+ )
+ this.database.storage.remove(key)
+ }
+ }
+ override validate(obj: unknown): ISroCharacter | undefined {
+ if (!obj || typeof obj !== 'object') return undefined
+ const {
+ key: characterKey,
+ level: rawLevel,
+ ascension: rawAscension,
+ } = obj as ISroCharacter
+ let {
+ hitMode,
+ basic,
+ skill,
+ ult,
+ talent,
+ bonusAbilities,
+ statBoosts,
+ eidolon,
+ team,
+ compareData,
+ } = obj as ISroCharacter
+
+ if (!allCharacterKeys.includes(characterKey)) return undefined // non-recoverable
+
+ if (!allHitModeKeys.includes(hitMode)) hitMode = 'avgHit'
+ if (typeof eidolon !== 'number' && eidolon < 0 && eidolon > 6) eidolon = 0
+
+ const { level, ascension } = validateLevelAsc(rawLevel, rawAscension)
+
+ if (typeof bonusAbilities !== 'object')
+ bonusAbilities = objKeyMap(allBonusAbilityKeys, (_key) => false)
+ else {
+ bonusAbilities = objKeyMap(allBonusAbilityKeys, (key) =>
+ typeof bonusAbilities[key] !== 'boolean'
+ ? false
+ : bonusAbilities[key] ?? false
+ )
+ }
+ if (typeof statBoosts !== 'object')
+ statBoosts = objKeyMap(allStatBoostKeys, (_key) => false)
+ else {
+ statBoosts = objKeyMap(allStatBoostKeys, (key) =>
+ typeof statBoosts[key] !== 'boolean' ? false : statBoosts[key] ?? false
+ )
+ }
+ basic = typeof basic !== 'number' ? 1 : clamp(basic, 1, 6)
+ skill = typeof skill !== 'number' ? 1 : clamp(skill, 1, 10)
+ ult = typeof ult !== 'number' ? 1 : clamp(ult, 1, 10)
+ talent = typeof talent !== 'number' ? 1 : clamp(talent, 1, 10)
+
+ if (!team || !Array.isArray(team)) team = ['', '', '']
+ else
+ team = team.map((t, i) =>
+ t &&
+ allCharacterKeys.includes(t) &&
+ !team.find((ot, j) => i > j && t === ot)
+ ? t
+ : ''
+ ) as ISroCharacter['team']
+
+ if (typeof compareData !== 'boolean') compareData = false
+
+ const char: ISroCharacter = {
+ key: characterKey,
+ level,
+ ascension,
+ hitMode,
+ basic,
+ skill,
+ ult,
+ talent,
+ bonusAbilities,
+ statBoosts,
+ eidolon,
+ team,
+ compareData,
+ }
+ return char
+ }
+ override toCache(
+ storageObj: ISroCharacter,
+ id: CharacterKey
+ ): ICachedSroCharacter {
+ const oldChar = this.get(id)
+ return {
+ equippedRelics: oldChar
+ ? oldChar.equippedRelics
+ : objKeyMap(
+ allRelicSlotKeys,
+ (sk) =>
+ Object.values(this.database.relics?.data ?? {}).find(
+ (r) =>
+ r?.location === charKeyToCharLocKey(id) && r.slotKey === sk
+ )?.id ?? ''
+ ),
+ equippedLightCone: oldChar
+ ? oldChar.equippedLightCone
+ : Object.values(this.database.lightCones?.data ?? {}).find(
+ (lc) => lc?.location === charKeyToCharLocKey(id)
+ )?.id ?? '',
+ ...storageObj,
+ }
+ }
+ override deCache(char: ICachedSroCharacter): ISroCharacter {
+ const {
+ key,
+ level,
+ ascension,
+ hitMode,
+ basic,
+ skill,
+ ult,
+ talent,
+ bonusAbilities,
+ statBoosts,
+ eidolon,
+ team,
+ compareData,
+ } = char
+ const result: ISroCharacter = {
+ key,
+ level,
+ ascension,
+ hitMode,
+ basic,
+ skill,
+ ult,
+ talent,
+ bonusAbilities,
+ statBoosts,
+ eidolon,
+ team,
+ compareData,
+ }
+ return result
+ }
+ override toStorageKey(key: CharacterKey): string {
+ return `${storageHash}${key}`
+ }
+ getTrailblazerCharacterKey(): CharacterKey {
+ return (
+ allTrailblazerKeys.find((k) => this.keys.includes(k)) ??
+ allTrailblazerKeys[0]
+ )
+ }
+ LocationToCharacterKey(key: CharacterLocationKey): CharacterKey {
+ return key === 'Trailblazer' ? this.getTrailblazerCharacterKey() : key
+ }
+ getWithInitWeapon(key: CharacterKey): ICachedSroCharacter {
+ if (!this.keys.includes(key)) {
+ this.set(key, initialCharacter(key))
+ }
+ return this.get(key) as ICachedSroCharacter
+ }
+
+ override remove(key: CharacterKey) {
+ const char = this.get(key)
+ if (!char) return
+ for (const relicKey of Object.values(char.equippedRelics)) {
+ const relic = this.database.relics.get(relicKey)
+ // Only unequip relic from Trailblazer if there are no more "Trailblazer"s in the database
+ if (
+ relic &&
+ (relic.location === key ||
+ (relic.location === 'Trailblazer' &&
+ allTrailblazerKeys.includes(key as TrailblazerKey) &&
+ !allTrailblazerKeys.find(
+ (t) => t !== key && this.keys.includes(t)
+ )))
+ )
+ this.database.relics.setCached(relicKey, { ...relic, location: '' })
+ }
+ const lightCone = this.database.lightCones.get(char.equippedLightCone)
+ // Only unequip light cone from Trailblazer if there are no more "Trailblazer"s in the database
+ if (
+ lightCone &&
+ (lightCone.location === key ||
+ (lightCone.location === 'Trailblazer' &&
+ allTrailblazerKeys.includes(key as TrailblazerKey) &&
+ !allTrailblazerKeys.find(
+ (t) => t !== key && this.keys.includes(t)
+ ))) &&
+ char.equippedLightCone
+ )
+ this.database.lightCones.setCached(char.equippedLightCone, {
+ ...lightCone,
+ location: '',
+ })
+ super.remove(key)
+ }
+
+ /**
+ * **Caution**:
+ * This does not update the `location` on relic
+ * This function should be use internally for database to maintain cache on ICachedSroCharacter.
+ */
+ setEquippedRelic(
+ key: CharacterLocationKey,
+ slotKey: RelicSlotKey,
+ relicId: string
+ ) {
+ const setEq = (k: CharacterKey) => {
+ const char = super.get(k)
+ if (!char) return
+ const equippedRelics = deepClone(char.equippedRelics)
+ equippedRelics[slotKey] = relicId
+ super.setCached(k, { ...char, equippedRelics })
+ }
+ if (key === 'Trailblazer') allTrailblazerKeys.forEach((k) => setEq(k))
+ else setEq(key)
+ }
+
+ /**
+ * **Caution**:
+ * This does not update the `location` on light cone
+ * This function should be use internally for database to maintain cache on ICachedSroCharacter.
+ */
+ setEquippedLightCone(
+ key: CharacterLocationKey,
+ equippedLightCone: ICachedSroCharacter['equippedLightCone']
+ ) {
+ const setEq = (k: CharacterKey) => {
+ const char = super.get(k)
+ if (!char) return
+ super.setCached(k, { ...char, equippedLightCone })
+ }
+ if (key === 'Trailblazer') allTrailblazerKeys.forEach((k) => setEq(k))
+ else setEq(key)
+ }
+
+ hasDup(char: ISroCharacter, isSro: boolean) {
+ const db = this.getStorage(char.key)
+ if (!db) return false
+ if (isSro) {
+ return JSON.stringify(db) === JSON.stringify(char)
+ } else {
+ let {
+ key,
+ level,
+ eidolon,
+ ascension,
+ basic,
+ skill,
+ ult,
+ talent,
+ bonusAbilities,
+ statBoosts,
+ } = db
+ const dbSr = {
+ key,
+ level,
+ eidolon,
+ ascension,
+ basic,
+ skill,
+ ult,
+ talent,
+ bonusAbilities,
+ statBoosts,
+ }
+ ;({
+ key,
+ level,
+ eidolon,
+ ascension,
+ basic,
+ skill,
+ ult,
+ talent,
+ bonusAbilities,
+ statBoosts,
+ } = char)
+ const charSr = {
+ key,
+ level,
+ eidolon,
+ ascension,
+ basic,
+ skill,
+ ult,
+ talent,
+ bonusAbilities,
+ statBoosts,
+ }
+ return JSON.stringify(dbSr) === JSON.stringify(charSr)
+ }
+ }
+ triggerCharacter(key: CharacterLocationKey, reason: TriggerString) {
+ if (key === 'Trailblazer')
+ allTrailblazerKeys.forEach((ck) => this.trigger(ck, reason, this.get(ck)))
+ else this.trigger(key, reason, this.get(key))
+ }
+ override importSROD(
+ sr: ISrObjectDescription & ISroDatabase,
+ result: ImportResult
+ ) {
+ result.characters.beforeMerge = this.values.length
+
+ const source = sr.source ?? 'Unknown'
+ const characters = sr.characters
+ if (Array.isArray(characters) && characters?.length) {
+ result.characters.import = characters.length
+ const idsToRemove = new Set(this.keys)
+ characters.forEach((c) => {
+ if (!c.key) result.characters.invalid.push(c as ISroCharacter)
+ idsToRemove.delete(c.key)
+ if (
+ this.hasDup(
+ { ...initialCharacter(c.key), ...c },
+ source === SroSource
+ )
+ )
+ result.characters.unchanged.push(c as ISroCharacter)
+ else this.set(c.key, c)
+ })
+
+ const idtoRemoveArr = Array.from(idsToRemove)
+ if (result.keepNotInImport || result.ignoreDups)
+ result.characters.notInImport = idtoRemoveArr.length
+ else idtoRemoveArr.forEach((k) => this.remove(k))
+ result.characters.unchanged = []
+ } else result.characters.notInImport = this.values.length
+ }
+}
+
+export function initialCharacter(key: CharacterKey): ICachedSroCharacter {
+ return {
+ key,
+ level: 1,
+ eidolon: 0,
+ ascension: 0,
+ basic: 1,
+ skill: 1,
+ ult: 1,
+ talent: 1,
+ bonusAbilities: {},
+ statBoosts: {},
+ hitMode: 'avgHit',
+ team: ['', '', ''],
+ compareData: false,
+ equippedRelics: objKeyMap(allRelicSlotKeys, () => ''),
+ equippedLightCone: '',
+ }
+}
diff --git a/libs/sr-db/src/Database/DataManagers/LightConeData.ts b/libs/sr-db/src/Database/DataManagers/LightConeData.ts
new file mode 100644
index 0000000000..a48f79b469
--- /dev/null
+++ b/libs/sr-db/src/Database/DataManagers/LightConeData.ts
@@ -0,0 +1,261 @@
+import type { CharacterLocationKey } from '@genshin-optimizer/sr-consts'
+import {
+ allCharacterLocationKeys,
+ allLightConeKeys,
+ charKeyToCharLocKey,
+ lightConeMaxLevel,
+} from '@genshin-optimizer/sr-consts'
+import type {
+ ILightCone,
+ ISrObjectDescription,
+} from '@genshin-optimizer/sr-srod'
+import { validateLevelAsc } from '@genshin-optimizer/sr-util'
+import type {
+ ICachedLightCone,
+ ICachedSroCharacter,
+ ISroDatabase,
+} from '../../Interfaces'
+import { DataManager } from '../DataManager'
+import type { SroDatabase } from '../Database'
+import type { ImportResult } from '../exim'
+import { initialCharacter } from './CharacterData'
+
+const storageKey = 'sro_lightCones'
+const storageHash = 'sro_lightCone_'
+export class LightConeDataManager extends DataManager<
+ string,
+ typeof storageKey,
+ ICachedLightCone,
+ ILightCone,
+ SroDatabase
+> {
+ constructor(database: SroDatabase) {
+ super(database, storageKey)
+ for (const key of this.database.storage.keys)
+ if (key.startsWith(storageHash) && !this.set(key, {}))
+ this.database.storage.remove(key)
+ }
+ override validate(obj: unknown): ILightCone | undefined {
+ if (typeof obj !== 'object') return undefined
+ const { key, level: rawLevel, ascension: rawAscension } = obj as ILightCone
+ let { superimpose, location, lock } = obj as ILightCone
+
+ if (!allLightConeKeys.includes(key)) return undefined
+ if (rawLevel > lightConeMaxLevel) return undefined
+ const { level, ascension } = validateLevelAsc(rawLevel, rawAscension)
+ if (typeof superimpose !== 'number' || superimpose < 1 || superimpose > 5)
+ superimpose = 1
+ if (location && !allCharacterLocationKeys.includes(location)) location = ''
+ lock = !!lock
+ return { key, level, ascension, superimpose, location, lock }
+ }
+ override toCache(
+ storageObj: ILightCone,
+ id: string
+ ): ICachedLightCone | undefined {
+ const newLightCone = { ...storageObj, id }
+ const oldLightCone = super.get(id)
+
+ // During initialization of the database, if you import lightCones with location without a corresponding character, the char will be generated here.
+ const getWithInit = (lk: CharacterLocationKey): ICachedSroCharacter => {
+ const cKey = this.database.chars.LocationToCharacterKey(lk)
+ if (!this.database.chars.keys.includes(cKey))
+ this.database.chars.set(cKey, initialCharacter(cKey))
+ return this.database.chars.get(cKey) as ICachedSroCharacter
+ }
+ if (newLightCone.location !== oldLightCone?.location) {
+ const prevChar = oldLightCone?.location
+ ? getWithInit(oldLightCone.location)
+ : undefined
+ const newChar = newLightCone.location
+ ? getWithInit(newLightCone.location)
+ : undefined
+
+ // previously equipped light cone at new location
+ let prevLightCone = super.get(newChar?.equippedLightCone)
+
+ //current prevLightCone <-> newChar && newLightCone <-> prevChar
+ //swap to prevLightCone <-> prevChar && newLightCone <-> newChar(outside of this if)
+
+ if (prevLightCone)
+ super.setCached(prevLightCone.id, {
+ ...prevLightCone,
+ location: prevChar?.key ? charKeyToCharLocKey(prevChar.key) : '',
+ })
+ else if (prevChar?.key) prevLightCone = undefined
+
+ if (newChar)
+ this.database.chars.setEquippedLightCone(
+ charKeyToCharLocKey(newChar.key),
+ newLightCone.id
+ )
+ if (prevChar)
+ this.database.chars.setEquippedLightCone(
+ charKeyToCharLocKey(prevChar.key),
+ prevLightCone?.id
+ )
+ } else
+ newLightCone.location &&
+ this.database.chars.triggerCharacter(newLightCone.location, 'update')
+ return newLightCone
+ }
+ override deCache(lightCone: ICachedLightCone): ILightCone {
+ const { key, level, ascension, superimpose, location, lock } = lightCone
+ return { key, level, ascension, superimpose, location, lock }
+ }
+
+ new(value: ILightCone): string {
+ const id = this.generateKey()
+ this.set(id, value)
+ return id
+ }
+ override toStorageKey(key: string): string {
+ return `${storageHash}${key}`
+ }
+ override remove(key: string, notify = true) {
+ const lc = this.get(key)
+ if (!lc) return
+ lc.location && this.database.chars.setEquippedLightCone(lc.location, '')
+ super.remove(key, notify)
+ }
+ override importSROD(
+ srod: ISrObjectDescription & ISroDatabase,
+ result: ImportResult
+ ) {
+ result.lightCones.beforeMerge = this.values.length
+
+ // Match lightCones for counter, metadata, and locations.
+ const lightCones = srod.lightCones
+
+ if (!Array.isArray(lightCones) || !lightCones.length) {
+ result.lightCones.notInImport = this.values.length
+ return
+ }
+
+ const takenIds = new Set(this.keys)
+ lightCones.forEach((a) => {
+ const id = (a as ICachedLightCone).id
+ if (!id) return
+ takenIds.add(id)
+ })
+
+ result.lightCones.import = lightCones.length
+ const idsToRemove = new Set(this.values.map((w) => w.id))
+ const hasEquipment = lightCones.some((w) => w.location)
+ lightCones.forEach((w): void => {
+ const lightCone = this.validate(w)
+ if (!lightCone) {
+ result.lightCones.invalid.push(w)
+ return
+ }
+
+ let importLightCone = lightCone
+ let importId: string | undefined = (w as ICachedLightCone).id
+ let foundDupOrUpgrade = false
+ if (!result.ignoreDups) {
+ const { duplicated, upgraded } = this.findDups(
+ lightCone,
+ Array.from(idsToRemove)
+ )
+ if (duplicated[0] || upgraded[0]) {
+ foundDupOrUpgrade = true
+ // Favor upgrades with the same location, else use 1st dupe
+ let [match, isUpgrade] =
+ hasEquipment &&
+ lightCone.location &&
+ upgraded[0]?.location === lightCone.location
+ ? [upgraded[0], true]
+ : duplicated[0]
+ ? [duplicated[0], false]
+ : [upgraded[0], true]
+ if (importId) {
+ // favor exact id matches
+ const up = upgraded.find((w) => w.id === importId)
+ if (up) [match, isUpgrade] = [up, true]
+ const dup = duplicated.find((w) => w.id === importId)
+ if (dup) [match, isUpgrade] = [dup, false]
+ }
+ isUpgrade
+ ? result.lightCones.upgraded.push(lightCone)
+ : result.lightCones.unchanged.push(lightCone)
+ idsToRemove.delete(match.id)
+
+ //Imported lightCone will be set to `importId` later, so remove the dup/upgrade now to avoid a duplicate
+ super.remove(match.id, false) // Do not notify, since this is a "replacement". Also use super to bypass the equipment check
+ if (!importId) importId = match.id // always resolve some id
+ importLightCone = {
+ ...lightCone,
+ location: hasEquipment ? lightCone.location : match.location,
+ }
+ }
+ }
+ if (importId) {
+ if (this.get(importId)) {
+ // `importid` already in use, get a new id
+ const newId = this.generateKey(takenIds)
+ takenIds.add(newId)
+ if (this.changeId(importId, newId)) {
+ // Sync the id in `idsToRemove` due to the `changeId`
+ if (idsToRemove.has(importId)) {
+ idsToRemove.delete(importId)
+ idsToRemove.add(newId)
+ }
+ }
+ }
+ this.set(importId, importLightCone, !foundDupOrUpgrade)
+ } else {
+ importId = this.generateKey(takenIds)
+ takenIds.add(importId)
+ }
+ this.set(importId, importLightCone, !foundDupOrUpgrade)
+ })
+
+ // Shouldn't remove Somnia's signature
+ const idtoRemoveArr = Array.from(idsToRemove)
+ if (result.keepNotInImport || result.ignoreDups)
+ result.lightCones.notInImport = idtoRemoveArr.length
+ else idtoRemoveArr.forEach((k) => this.remove(k))
+ }
+
+ findDups(
+ lightCone: ILightCone,
+ idList = this.keys
+ ): { duplicated: ICachedLightCone[]; upgraded: ICachedLightCone[] } {
+ const { key, level, ascension, superimpose } = lightCone
+
+ const lightCones = idList
+ .map((id) => this.get(id))
+ .filter((a) => a) as ICachedLightCone[]
+ const candidates = lightCones.filter(
+ (candidate) =>
+ key === candidate.key &&
+ level >= candidate.level &&
+ ascension >= candidate.ascension &&
+ superimpose >= candidate.superimpose
+ )
+
+ // Strictly upgraded lightCones
+ const upgraded = candidates
+ .filter(
+ (candidate) =>
+ level > candidate.level ||
+ ascension > candidate.ascension ||
+ superimpose > candidate.superimpose
+ )
+ .sort((candidates) =>
+ candidates.location === lightCone.location ? -1 : 1
+ )
+ // Strictly duplicated lightCones
+ const duplicated = candidates
+ .filter(
+ (candidate) =>
+ level === candidate.level &&
+ ascension === candidate.ascension &&
+ superimpose === candidate.superimpose
+ )
+ .sort((candidates) =>
+ candidates.location === lightCone.location ? -1 : 1
+ )
+ return { duplicated, upgraded }
+ }
+}
diff --git a/libs/sr-db/src/Database/DataManagers/RelicData.ts b/libs/sr-db/src/Database/DataManagers/RelicData.ts
new file mode 100644
index 0000000000..f17730423c
--- /dev/null
+++ b/libs/sr-db/src/Database/DataManagers/RelicData.ts
@@ -0,0 +1,529 @@
+import type {
+ RelicMainStatKey,
+ RelicRarityKey,
+ RelicSubStatKey,
+} from '@genshin-optimizer/sr-consts'
+import {
+ allCharacterLocationKeys,
+ allRelicMainStatKeys,
+ allRelicRarityKeys,
+ allRelicSetKeys,
+ allRelicSlotKeys,
+ allRelicSubStatKeys,
+ charKeyToCharLocKey,
+ relicMaxLevel,
+ relicSlotToMainStatKeys,
+} from '@genshin-optimizer/sr-consts'
+import type {
+ IRelic,
+ ISrObjectDescription,
+ ISubstat,
+} from '@genshin-optimizer/sr-srod'
+import {
+ getRelicMainStatDisplayVal,
+ getSubstatRange,
+} from '@genshin-optimizer/sr-util'
+import { clamp } from '@genshin-optimizer/util'
+import type {
+ ICachedRelic,
+ ICachedSubstat,
+ ISroDatabase,
+} from '../../Interfaces'
+import { DataManager } from '../DataManager'
+import type { SroDatabase } from '../Database'
+import type { ImportResult } from '../exim'
+
+const storageKey = 'sro_relics'
+const storageHash = 'sro_relic_'
+export class RelicDataManager extends DataManager<
+ string,
+ typeof storageKey,
+ ICachedRelic,
+ IRelic,
+ SroDatabase
+> {
+ constructor(database: SroDatabase) {
+ super(database, storageKey)
+ for (const key of this.database.storage.keys)
+ if (key.startsWith(storageHash) && !this.set(key, {}))
+ this.database.storage.remove(key)
+ }
+ override validate(obj: unknown): IRelic | undefined {
+ return validateRelic(obj)
+ }
+ override toCache(storageObj: IRelic, id: string): ICachedRelic | undefined {
+ // Generate cache fields
+ const newRelic = cachedRelic(storageObj, id).relic
+
+ // Check relations and update equipment
+ const oldRelic = super.get(id)
+ if (newRelic.location !== oldRelic?.location) {
+ const slotKey = newRelic.slotKey
+ const prevChar = oldRelic?.location
+ ? this.database.chars.getWithInitWeapon(
+ this.database.chars.LocationToCharacterKey(oldRelic.location)
+ )
+ : undefined
+ const newChar = newRelic.location
+ ? this.database.chars.getWithInitWeapon(
+ this.database.chars.LocationToCharacterKey(newRelic.location)
+ )
+ : undefined
+
+ // previously equipped relic at new location
+ const prevRelic = super.get(newChar?.equippedRelics[slotKey])
+
+ //current prevRelic <-> newChar && newRelic <-> prevChar
+ //swap to prevRelic <-> prevChar && newRelic <-> newChar(outside of this if)
+
+ if (prevRelic)
+ super.setCached(prevRelic.id, {
+ ...prevRelic,
+ location: prevChar?.key ? charKeyToCharLocKey(prevChar.key) : '',
+ })
+ if (newChar)
+ this.database.chars.setEquippedRelic(
+ charKeyToCharLocKey(newChar.key),
+ slotKey,
+ newRelic.id
+ )
+ if (prevChar)
+ this.database.chars.setEquippedRelic(
+ charKeyToCharLocKey(prevChar.key),
+ slotKey,
+ prevRelic?.id ?? ''
+ )
+ } else
+ newRelic.location &&
+ this.database.chars.triggerCharacter(newRelic.location, 'update')
+ return newRelic
+ }
+ override deCache(relic: ICachedRelic): IRelic {
+ const {
+ setKey,
+ rarity,
+ level,
+ slotKey,
+ mainStatKey,
+ substats,
+ location,
+ lock,
+ } = relic
+ return {
+ setKey,
+ rarity,
+ level,
+ slotKey,
+ mainStatKey,
+ substats: substats.map((substat) => ({
+ key: substat.key,
+ value: substat.value,
+ })),
+ location,
+ lock,
+ }
+ }
+
+ new(value: IRelic): string {
+ const id = this.generateKey()
+ this.set(id, value)
+ return id
+ }
+ override toStorageKey(key: string): string {
+ return `${storageHash}${key}`
+ }
+ override remove(key: string, notify = true) {
+ const relic = this.get(key)
+ if (!relic) return
+ relic.location &&
+ this.database.chars.setEquippedRelic(relic.location, relic.slotKey, '')
+ super.remove(key, notify)
+ }
+ override importSROD(
+ srod: ISrObjectDescription & ISroDatabase,
+ result: ImportResult
+ ) {
+ result.relics.beforeMerge = this.values.length
+
+ // Match relics for counter, metadata, and locations
+ const relics = srod.relics
+
+ if (!Array.isArray(relics) || !relics.length) {
+ result.relics.notInImport = this.values.length
+ return
+ }
+
+ const takenIds = new Set(this.keys)
+ relics.forEach((r) => {
+ const id = (r as ICachedRelic).id
+ if (!id) return
+ takenIds.add(id)
+ })
+
+ result.relics.import = relics.length
+ const idsToRemove = new Set(this.values.map((r) => r.id))
+ const hasEquipment = relics.some((r) => r.location)
+ relics.forEach((r): void => {
+ const relic = this.validate(r)
+ if (!relic) {
+ result.relics.invalid.push(r)
+ return
+ }
+
+ let importRelic = relic
+ let importId: string | undefined = (r as ICachedRelic).id
+ let foundDupOrUpgrade = false
+ if (!result.ignoreDups) {
+ const { duplicated, upgraded } = this.findDups(
+ relic,
+ Array.from(idsToRemove)
+ )
+ if (duplicated[0] || upgraded[0]) {
+ foundDupOrUpgrade = true
+ // Favor upgrades with the same location, else use 1st dupe
+ let [match, isUpgrade] =
+ hasEquipment &&
+ relic.location &&
+ upgraded[0]?.location === relic.location
+ ? [upgraded[0], true]
+ : duplicated[0]
+ ? [duplicated[0], false]
+ : [upgraded[0], true]
+ if (importId) {
+ // favor exact id matches
+ const up = upgraded.find((a) => a.id === importId)
+ if (up) [match, isUpgrade] = [up, true]
+ const dup = duplicated.find((a) => a.id === importId)
+ if (dup) [match, isUpgrade] = [dup, false]
+ }
+ isUpgrade
+ ? result.relics.upgraded.push(relic)
+ : result.relics.unchanged.push(relic)
+ idsToRemove.delete(match.id)
+
+ //Imported relic will be set to `importId` later, so remove the dup/upgrade now to avoid a duplicate
+ this.remove(match.id, false) // Do not notify, since this is a "replacement"
+ if (!importId) importId = match.id // always resolve some id
+ importRelic = {
+ ...relic,
+ location: hasEquipment ? relic.location : match.location,
+ }
+ }
+ }
+ if (importId) {
+ if (this.get(importId)) {
+ // `importid` already in use, get a new id
+ const newId = this.generateKey(takenIds)
+ takenIds.add(newId)
+ if (this.changeId(importId, newId)) {
+ // Sync the id in `idsToRemove` due to the `changeId`
+ if (idsToRemove.has(importId)) {
+ idsToRemove.delete(importId)
+ idsToRemove.add(newId)
+ }
+ }
+ }
+ } else {
+ importId = this.generateKey(takenIds)
+ takenIds.add(importId)
+ }
+ this.set(importId, importRelic, !foundDupOrUpgrade)
+ })
+ const idtoRemoveArr = Array.from(idsToRemove)
+ if (result.keepNotInImport || result.ignoreDups)
+ result.relics.notInImport = idtoRemoveArr.length
+ else idtoRemoveArr.forEach((k) => this.remove(k))
+ }
+ findDups(
+ editorRelic: IRelic,
+ idList = this.keys
+ ): { duplicated: ICachedRelic[]; upgraded: ICachedRelic[] } {
+ const { setKey, rarity, level, slotKey, mainStatKey, substats } =
+ editorRelic
+
+ const relics = idList
+ .map((id) => this.get(id))
+ .filter((r) => r) as ICachedRelic[]
+ const candidates = relics.filter(
+ (candidate) =>
+ setKey === candidate.setKey &&
+ rarity === candidate.rarity &&
+ slotKey === candidate.slotKey &&
+ mainStatKey === candidate.mainStatKey &&
+ level >= candidate.level &&
+ substats.every(
+ (substat, i) =>
+ !candidate.substats[i].key || // Candidate doesn't have anything on this slot
+ (substat.key === candidate.substats[i].key && // Or editor simply has better substat
+ substat.value >= candidate.substats[i].value)
+ )
+ )
+
+ // Strictly upgraded relic
+ const upgraded = candidates
+ .filter(
+ (candidate) =>
+ level > candidate.level &&
+ (Math.floor(level / 3) === Math.floor(candidate.level / 3) // Check for extra rolls
+ ? substats.every(
+ (
+ substat,
+ i // Has no extra roll
+ ) =>
+ substat.key === candidate.substats[i].key &&
+ substat.value === candidate.substats[i].value
+ )
+ : substats.some(
+ (
+ substat,
+ i // Has extra rolls
+ ) =>
+ candidate.substats[i].key
+ ? substat.value > candidate.substats[i].value // Extra roll to existing substat
+ : substat.key // Extra roll to new substat
+ ))
+ )
+ .sort((candidates) =>
+ candidates.location === editorRelic.location ? -1 : 1
+ )
+ // Strictly duplicated relic
+ const duplicated = candidates
+ .filter(
+ (candidate) =>
+ level === candidate.level &&
+ substats.every(
+ (substat) =>
+ !substat.key || // Empty slot
+ candidate.substats.some(
+ (candidateSubstat) =>
+ substat.key === candidateSubstat.key && // Or same slot
+ substat.value === candidateSubstat.value
+ )
+ )
+ )
+ .sort((candidates) =>
+ candidates.location === editorRelic.location ? -1 : 1
+ )
+ return { duplicated, upgraded }
+ }
+}
+
+export function cachedRelic(
+ flex: IRelic,
+ id: string
+): { relic: ICachedRelic; errors: string[] } {
+ const { location, lock, setKey, slotKey, rarity, mainStatKey } = flex
+ const level = Math.round(
+ Math.min(Math.max(0, flex.level), relicMaxLevel[rarity])
+ )
+ const mainStatVal = getRelicMainStatDisplayVal(rarity, mainStatKey, level)
+
+ const errors: string[] = []
+ const substats: ICachedSubstat[] = flex.substats.map((substat) => ({
+ ...substat,
+ rolls: [],
+ efficiency: 0,
+ accurateValue: substat.value,
+ }))
+ // Carry over the probability, since its a cached value calculated outside of the relic.
+ const validated: ICachedRelic = {
+ id,
+ setKey,
+ location,
+ slotKey,
+ lock,
+ mainStatKey,
+ rarity,
+ level,
+ substats,
+ mainStatVal,
+ }
+
+ // TODO: Validate rolls
+ // const allPossibleRolls: { index: number; substatRolls: number[][] }[] = []
+ // let totalUnambiguousRolls = 0
+
+ // function efficiency(value: number, key: RelicSubStatKey): number {
+ // return (value / getSubstatValue(rarity, key, 'high')) * 100
+ // }
+
+ // substats.forEach((substat, _index): void => {
+ // const { key, value } = substat
+ // if (!key) {
+ // substat.value = 0
+ // return
+ // }
+ // substat.efficiency = efficiency(value, key)
+
+ // const possibleRolls = getSubstatRolls(key, value, rarity)
+
+ // if (possibleRolls.length) {
+ // // Valid Substat
+ // const possibleLengths = new Set(possibleRolls.map((roll) => roll.length))
+
+ // if (possibleLengths.size !== 1) {
+ // // Ambiguous Rolls
+ // allPossibleRolls.push({ index, substatRolls: possibleRolls })
+ // } else {
+ // // Unambiguous Rolls
+ // totalUnambiguousRolls += possibleRolls[0].length
+ // }
+
+ // substat.rolls = possibleRolls.reduce((best, current) =>
+ // best.length < current.length ? best : current
+ // )
+ // substat.efficiency = efficiency(
+ // substat.rolls.reduce((a, b) => a + b, 0),
+ // key
+ // )
+ // substat.accurateValue = substat.rolls.reduce((a, b) => a + b, 0)
+ // } else {
+ // // Invalid Substat
+ // substat.rolls = []
+ // // TODO: Translate
+ // errors.push(`Invalid substat ${substat.key}`)
+ // }
+ // })
+
+ // if (errors.length) return { relic: validated, errors }
+
+ // const { low, high } = relicSubstatRollData[rarity],
+ // lowerBound = low + Math.floor(level / 3),
+ // upperBound = high + Math.floor(level / 3)
+
+ // let highestScore = -Infinity // -Max(substats.rolls[i].length) over ambiguous rolls
+ // const tryAllSubstats = (
+ // rolls: { index: number; roll: number[] }[],
+ // currentScore: number,
+ // total: number
+ // ) => {
+ // if (rolls.length === allPossibleRolls.length) {
+ // if (
+ // total <= upperBound &&
+ // total >= lowerBound &&
+ // highestScore < currentScore
+ // ) {
+ // highestScore = currentScore
+ // for (const { index, roll } of rolls) {
+ // const key = substats[index].key as RelicSubStatKey
+ // const accurateValue = roll.reduce((a, b) => a + b, 0)
+ // substats[index].rolls = roll
+ // substats[index].accurateValue = accurateValue
+ // substats[index].efficiency = efficiency(accurateValue, key)
+ // }
+ // }
+
+ // return
+ // }
+
+ // const { index, substatRolls } = allPossibleRolls[rolls.length]
+ // for (const roll of substatRolls) {
+ // rolls.push({ index, roll })
+ // const newScore = Math.min(currentScore, -roll.length)
+ // if (newScore >= highestScore)
+ // // Scores won't get better, so we can skip.
+ // tryAllSubstats(rolls, newScore, total + roll.length)
+ // rolls.pop()
+ // }
+ // }
+
+ // tryAllSubstats([], Infinity, totalUnambiguousRolls)
+
+ // const totalRolls = substats.reduce(
+ // (accu, { rolls }) => accu + rolls.length,
+ // 0
+ // )
+
+ // if (totalRolls > upperBound)
+ // errors.push(
+ // `${rarity}-star relic (level ${level}) should have no more than ${upperBound} rolls. It currently has ${totalRolls} rolls.`
+ // )
+ // else if (totalRolls < lowerBound)
+ // errors.push(
+ // `${rarity}-star relic (level ${level}) should have at least ${lowerBound} rolls. It currently has ${totalRolls} rolls.`
+ // )
+
+ // if (substats.some((substat) => !substat.key)) {
+ // const substat = substats.find((substat) => (substat.rolls?.length ?? 0) > 1)
+ // if (substat)
+ // // TODO: Translate
+ // errors.push(
+ // `Substat ${substat.key} has > 1 roll, but not all substats are unlocked.`
+ // )
+ // }
+
+ return { relic: validated, errors }
+}
+
+export function validateRelic(
+ obj: unknown = {},
+ allowZeroSub = false
+): IRelic | undefined {
+ if (!obj || typeof obj !== 'object') return undefined
+ const { setKey, rarity, slotKey } = obj as IRelic
+ let { level, mainStatKey, substats, location, lock } = obj as IRelic
+
+ if (
+ !allRelicSetKeys.includes(setKey) ||
+ !allRelicSlotKeys.includes(slotKey) ||
+ !allRelicMainStatKeys.includes(mainStatKey) ||
+ !allRelicRarityKeys.includes(rarity) ||
+ typeof level !== 'number' ||
+ level < 0 ||
+ level > 15
+ )
+ return undefined // non-recoverable
+ level = Math.round(level)
+ if (level > relicMaxLevel[rarity]) return undefined
+
+ substats = parseSubstats(substats, rarity, allowZeroSub)
+ // substat cannot have same key as mainstat
+ if (substats.find((sub) => sub.key === mainStatKey)) return undefined
+ lock = !!lock
+ const plausibleMainStats = relicSlotToMainStatKeys[slotKey]
+ if (!(plausibleMainStats as RelicMainStatKey[]).includes(mainStatKey))
+ if (plausibleMainStats.length === 1) mainStatKey = plausibleMainStats[0]
+ else return undefined // ambiguous mainstat
+ if (!location || !allCharacterLocationKeys.includes(location)) location = ''
+ return {
+ setKey,
+ rarity,
+ level,
+ slotKey,
+ mainStatKey,
+ substats,
+ location,
+ lock,
+ }
+}
+function defSub(): ISubstat {
+ return { key: '', value: 0 }
+}
+function parseSubstats(
+ obj: unknown,
+ rarity: RelicRarityKey,
+ allowZeroSub = false
+): ISubstat[] {
+ if (!Array.isArray(obj)) return new Array(4).map((_) => defSub())
+ const substats = (obj as ISubstat[])
+ .slice(0, 4)
+ .map(({ key = '', value = 0 }) => {
+ if (
+ !allRelicSubStatKeys.includes(key as RelicSubStatKey) ||
+ typeof value !== 'number' ||
+ !isFinite(value)
+ )
+ return defSub()
+ if (key) {
+ value = key.endsWith('_')
+ ? Math.round(value * 10) / 10
+ : Math.round(value)
+ const { low, high } = getSubstatRange(rarity, key)
+ value = clamp(value, allowZeroSub ? 0 : low, high)
+ } else value = 0
+ return { key, value }
+ })
+ while (substats.length < 4) substats.push(defSub())
+
+ return substats
+}
diff --git a/libs/sr-db/src/Database/Database.test.ts b/libs/sr-db/src/Database/Database.test.ts
new file mode 100644
index 0000000000..896aa17a83
--- /dev/null
+++ b/libs/sr-db/src/Database/Database.test.ts
@@ -0,0 +1,729 @@
+import { DBLocalStorage, SandboxStorage } from '@genshin-optimizer/database'
+import type { LightConeKey } from '@genshin-optimizer/sr-consts'
+import type {
+ ILightCone,
+ IRelic,
+ ISrObjectDescription,
+} from '@genshin-optimizer/sr-srod'
+import { randomizeRelic } from '@genshin-optimizer/sr-util'
+import { objKeyMap, range } from '@genshin-optimizer/util'
+import type { ICachedLightCone, ISroDatabase } from '../Interfaces'
+import { SroSource } from '../Interfaces'
+import { initialCharacter } from './DataManagers/CharacterData'
+import { SroDatabase } from './Database'
+
+const dbStorage = new DBLocalStorage(localStorage, 'sro')
+const dbIndex = 1
+let database = new SroDatabase(dbIndex, dbStorage)
+
+function newLightCone(key: LightConeKey): ICachedLightCone {
+ return {
+ key,
+ level: 1,
+ ascension: 0,
+ superimpose: 0,
+ location: '',
+ lock: false,
+ id: '',
+ }
+}
+
+describe('Database', () => {
+ beforeEach(() => {
+ dbStorage.clear()
+ database = new SroDatabase(dbIndex, dbStorage)
+ })
+
+ test('Support roundtrip import-export', () => {
+ const march7th = initialCharacter('March7th'),
+ tingyun = initialCharacter('Tingyun')
+ const march7thLightCone = newLightCone('TrendOfTheUniversalMarket'),
+ tingyunLightCone = newLightCone('Chorus')
+
+ const relic1 = randomizeRelic({ slotKey: 'body' }),
+ relic2 = randomizeRelic()
+ march7th.basic = 4
+ relic1.location = 'March7th'
+ march7thLightCone.location = 'March7th'
+
+ database.chars.set(march7th.key, march7th)
+ database.chars.set(tingyun.key, tingyun)
+
+ database.lightCones.new(march7thLightCone)
+ const tingyunLightConeid = database.lightCones.new(tingyunLightCone)
+
+ database.relics.new(relic1)
+ const relic2id = database.relics.new(relic2)
+ database.relics.set(relic2id, { location: 'Tingyun' })
+ database.lightCones.set(tingyunLightConeid, { location: 'Tingyun' })
+
+ const newDB = new SroDatabase(dbIndex, new SandboxStorage(undefined, 'sro'))
+ const srod = database.exportSROD()
+ newDB.importSROD(srod, false, false)
+ expect(
+ database.storage.entries.filter(
+ ([k]) =>
+ k.startsWith('lightCone_') ||
+ k.startsWith('character_') ||
+ k.startsWith('relic_')
+ )
+ ).toEqual(
+ newDB.storage.entries.filter(
+ ([k]) =>
+ k.startsWith('lightCone_') ||
+ k.startsWith('character_') ||
+ k.startsWith('relic_')
+ )
+ )
+ expect(database.chars.values).toEqual(newDB.chars.values)
+ expect(database.lightCones.values).toEqual(newDB.lightCones.values)
+ expect(database.relics.values).toEqual(newDB.relics.values)
+ // Can't check IcharacterCache because equipped can have differing id
+ })
+
+ test('Does not crash from invalid storage', () => {
+ function tryStorage(
+ setup: (storage: Storage) => void,
+ verify: (storage: Storage) => void = () => null
+ ) {
+ localStorage.clear()
+ setup(localStorage)
+ new SroDatabase(dbIndex, dbStorage)
+ verify(localStorage)
+ }
+
+ tryStorage(
+ (storage) => {
+ storage['sro_char_x'] = '{ test: "test" }'
+ storage['sro_relic_x'] = '{}'
+ },
+ (storage) => {
+ expect(storage.getItem('sro_char_x')).toBeNull()
+ }
+ )
+ tryStorage(
+ (storage) => {
+ storage['sro_char_x'] = '{ test: "test" }'
+ storage['sro_relic_x'] = '{}'
+ expect(storage.getItem('sro_char_x')).not.toBeNull()
+ },
+ (storage) => {
+ expect(storage.getItem('sro_char_x')).toBeNull()
+ expect(storage.getItem('sro_relic_x')).toBeNull()
+ }
+ )
+ })
+
+ test('Equip swap', () => {
+ database.chars.set('March7th', initialCharacter('March7th'))
+ database.lightCones.new({
+ ...newLightCone('TrendOfTheUniversalMarket'),
+ location: 'March7th',
+ })
+
+ const relic1 = randomizeRelic({ slotKey: 'body' })
+ relic1.location = 'March7th'
+ const relic1id = database.relics.new(relic1)
+ expect(database.chars.get('March7th')!.equippedRelics.body).toEqual(
+ relic1id
+ )
+ const relic2 = randomizeRelic({ slotKey: 'body' })
+ relic2.location = 'March7th'
+ const relic2id = database.relics.new(relic2)
+ expect(database.chars.get('March7th')!.equippedRelics.body).toEqual(
+ relic2id
+ )
+ expect(database.relics.get(relic1id)?.location).toEqual('')
+
+ database.chars.set('Gepard', initialCharacter('Gepard'))
+ const chorusId = database.lightCones.new(newLightCone('WeAreWildfire'))
+ database.lightCones.set(chorusId, { location: 'Gepard' })
+ expect(database.chars.get('Gepard')!.equippedLightCone).toEqual(chorusId)
+ database.relics.set(relic1id, { location: 'Gepard' })
+ expect(database.chars.get('Gepard')!.equippedRelics.body).toEqual(relic1id)
+
+ database.relics.set(relic2id, { location: 'Gepard' })
+ expect(database.chars.get('March7th')!.equippedRelics.body).toEqual(
+ relic1id
+ )
+ expect(database.chars.get('Gepard')!.equippedRelics.body).toEqual(relic2id)
+ expect(database.relics.get(relic1id)!.location).toEqual('March7th')
+ })
+
+ test('can remove equipped lightCone', () => {
+ database.chars.set('March7th', initialCharacter('March7th'))
+ const sword1 = database.lightCones.new({
+ ...newLightCone('TrendOfTheUniversalMarket'),
+ location: 'March7th',
+ })
+ database.lightCones.remove(sword1)
+ expect(database.lightCones.get(sword1)).toBeFalsy()
+ expect(database.chars.get('March7th')!.equippedLightCone).toEqual('')
+ })
+
+ test('Remove relic with equipment', () => {
+ database.chars.set('March7th', initialCharacter('March7th'))
+ const relic1id = database.relics.new({
+ ...randomizeRelic({ slotKey: 'body' }),
+ location: 'March7th',
+ })
+ expect(database.chars.get('March7th')!.equippedRelics.body).toEqual(
+ relic1id
+ )
+ expect(database.relics.get(relic1id)?.location).toEqual('March7th')
+ database.relics.remove(relic1id)
+ expect(database.chars.get('March7th')!.equippedRelics.body).toEqual('')
+ expect(database.relics.get(relic1id)).toBeUndefined()
+ })
+
+ test('Test import with initials', () => {
+ // When adding relics with equipment, expect character/lightCones to be created
+ const relic1 = randomizeRelic({ slotKey: 'body', location: 'March7th' }),
+ relic2 = randomizeRelic({ location: 'Tingyun' })
+
+ const tingyunLightCone = newLightCone('Chorus')
+ tingyunLightCone.location = 'Tingyun'
+
+ const srod: ISrObjectDescription = {
+ format: 'SROD',
+ version: 1,
+ source: 'Scanner',
+ relics: [relic1, relic2],
+ lightCones: [tingyunLightCone],
+ }
+ const importResult = database.importSROD(
+ srod as ISrObjectDescription & ISroDatabase,
+ false,
+ false
+ )
+ expect(importResult.characters?.new?.length).toEqual(2)
+ expect(importResult.relics.invalid.length).toEqual(0)
+ expect(importResult.relics?.new?.length).toEqual(2)
+ expect(importResult.lightCones?.new?.length).toEqual(1)
+ })
+
+ test('Test import with no equip', () => {
+ // When adding relics with equipment, expect character/lightCones to be created
+ const relic1 = randomizeRelic({ slotKey: 'body', location: 'Tingyun' })
+
+ // Implicitly assign location
+ const id = database.relics.new(relic1)
+
+ expect(database.chars.get('Tingyun')!.equippedRelics.body).toEqual(id)
+
+ const srod: ISrObjectDescription = {
+ format: 'SROD',
+ version: 1,
+ source: 'Scanner',
+ relics: [relic1],
+ }
+
+ // Import the new relic, with no location. this should respect current equipment
+ database.importSROD(
+ srod as ISrObjectDescription & ISroDatabase,
+ false,
+ false
+ )
+ expect(database.chars.get('Tingyun')?.equippedRelics.body).toEqual(id)
+ })
+
+ test('Test partial merge', () => {
+ // Add Character and Relic
+ const march7th = initialCharacter('March7th')
+ const march7thLightCone = newLightCone('TrendOfTheUniversalMarket')
+ march7thLightCone.location = 'March7th'
+
+ const relic1 = randomizeRelic({
+ slotKey: 'body',
+ setKey: 'GuardOfWutheringSnow',
+ location: 'March7th',
+ })
+
+ database.chars.set(march7th.key, march7th)
+ const lightConeid = database.lightCones.new(march7thLightCone)
+ database.lightCones.set(lightConeid, march7thLightCone)
+
+ const relic1id = database.relics.new(relic1)
+ expect(database.chars.get('March7th')?.equippedRelics.body).toEqual(
+ relic1id
+ )
+ expect(database.chars.get('March7th')?.equippedLightCone).toEqual(
+ lightConeid
+ )
+ const srod1: ISrObjectDescription = {
+ format: 'SROD',
+ version: 1,
+ source: 'Scanner',
+ relics: [
+ randomizeRelic({ slotKey: 'body', setKey: 'BandOfSizzlingThunder' }),
+ randomizeRelic({
+ slotKey: 'body',
+ setKey: 'BandOfSizzlingThunder',
+ location: 'March7th',
+ }),
+ ],
+ lightCones: [{ ...newLightCone('WeAreWildfire'), location: 'March7th' }],
+ }
+ const importResult = database.importSROD(
+ srod1 as ISrObjectDescription & ISroDatabase,
+ true,
+ false
+ )
+ expect(importResult.relics.new.length).toEqual(2)
+ expect(importResult.lightCones.new.length).toEqual(1)
+ expect(importResult.characters.new.length).toEqual(0)
+
+ const relics = database.relics.values
+ expect(relics.length).toEqual(3)
+ expect(
+ database.relics.values.reduce(
+ (t, relic) => t + (relic.location === 'March7th' ? 1 : 0),
+ 0
+ )
+ ).toEqual(1)
+ const bodyId = database.chars.get('March7th')?.equippedRelics.body
+ expect(bodyId).toBeTruthy()
+ expect(database.relics.get(bodyId)?.setKey).toEqual('BandOfSizzlingThunder')
+ expect(
+ database.lightCones.get(database.chars.get('March7th')?.equippedLightCone)
+ ?.key
+ ).toEqual('WeAreWildfire')
+ })
+
+ test('should merge scanner with dups for lightCones', () => {
+ const a1 = newLightCone('Adversarial')
+ const a2old = newLightCone('Chorus')
+ const a2new = newLightCone('Chorus')
+ a2new.level = 20
+ const a3 = newLightCone('MakeTheWorldClamor') // in db but not in import
+ const a4 = newLightCone('ASecretVow') // in import but not in db
+
+ const dupId = database.lightCones.new(a1)
+ const upgradeId = database.lightCones.new(a2old)
+ database.lightCones.new(a3)
+ const srod1: ISrObjectDescription = {
+ format: 'SROD',
+ version: 1,
+ source: 'Scanner',
+ lightCones: [a1, a2new, a4],
+ }
+ const importResult = database.importSROD(
+ srod1 as ISrObjectDescription & ISroDatabase,
+ true,
+ false
+ )
+ expect(importResult.lightCones.upgraded.length).toEqual(1)
+ expect(importResult.lightCones.unchanged.length).toEqual(1)
+ expect(importResult.lightCones.notInImport).toEqual(1)
+ expect(importResult.lightCones.new.length).toEqual(1)
+ expect(database.lightCones.values.length).toEqual(4)
+ const dbA1 = database.lightCones.get(dupId)
+ expect(dbA1?.key).toEqual('Adversarial')
+ const dbA2 = database.lightCones.get(upgradeId)
+ expect(dbA2?.key).toEqual('Chorus')
+ expect(dbA2?.level).toEqual(20)
+ })
+
+ test('should merge scanner with dups for relics', () => {
+ const a1 = randomizeRelic({
+ setKey: 'EagleOfTwilightLine',
+ slotKey: 'head',
+ }) // dup
+ const a2old: IRelic = {
+ // before
+ level: 0,
+ location: '',
+ lock: false,
+ mainStatKey: 'atk',
+ rarity: 3,
+ setKey: 'BandOfSizzlingThunder',
+ slotKey: 'hand',
+ substats: [{ key: 'atk_', value: 5 }],
+ }
+ const a2new: IRelic = {
+ // upgrade
+ level: 4,
+ location: '',
+ lock: false,
+ mainStatKey: 'atk',
+ rarity: 3,
+ setKey: 'BandOfSizzlingThunder',
+ slotKey: 'hand',
+ substats: [
+ { key: 'atk_', value: 5 },
+ { key: 'def_', value: 5 },
+ ],
+ }
+ const a3 = randomizeRelic({ slotKey: 'sphere' }) // in db but not in import
+ const a4 = randomizeRelic({ slotKey: 'body' }) // in import but not in db
+
+ const dupId = database.relics.new(a1)
+ const upgradeId = database.relics.new(a2old)
+ database.relics.new(a3)
+ const srod1: ISrObjectDescription = {
+ format: 'SROD',
+ version: 1,
+ source: 'Scanner',
+ relics: [a1, a2new, a4],
+ }
+ const importResult = database.importSROD(
+ srod1 as ISrObjectDescription & ISroDatabase,
+ true,
+ false
+ )
+ expect(importResult.relics.upgraded.length).toEqual(1)
+ expect(importResult.relics.unchanged.length).toEqual(1)
+ expect(importResult.relics.notInImport).toEqual(1)
+ expect(importResult.relics.new.length).toEqual(1)
+ expect(database.relics.values.length).toEqual(4)
+ const dbA1 = database.relics.get(dupId)
+ expect(dbA1?.slotKey).toEqual('head')
+ const dbA2 = database.relics.get(upgradeId)
+ expect(dbA2?.slotKey).toEqual('hand')
+ expect(dbA2?.level).toEqual(4)
+ })
+ test('Import character without lightCone should not give default lightCone', () => {
+ const srod: ISrObjectDescription = {
+ format: 'SROD',
+ version: 1,
+ source: 'Scanner',
+ characters: [
+ {
+ key: 'March7th',
+ level: 40,
+ eidolon: 0,
+ ascension: 1,
+ basic: 1,
+ skill: 1,
+ ult: 1,
+ talent: 1,
+ bonusAbilities: objKeyMap(range(1, 3), () => false),
+ statBoosts: objKeyMap(range(1, 10), () => false),
+ },
+ ],
+ }
+ const importResult = database.importSROD(
+ srod as ISrObjectDescription & ISroDatabase,
+ false,
+ false
+ )
+ expect(importResult.lightCones.new.length).toEqual(0)
+ expect(importResult.characters.new.length).toEqual(1)
+ expect(database.chars.get('March7th')?.equippedLightCone).toBeFalsy()
+ })
+ describe('import again with overlapping ids', () => {
+ test('import again with overlapping relic ids', () => {
+ const old1 = randomizeRelic({ slotKey: 'head' })
+ const old2 = randomizeRelic({ slotKey: 'hand' })
+ const old3 = randomizeRelic({ slotKey: 'sphere' })
+ const old4 = randomizeRelic({ slotKey: 'body' })
+
+ const oldId1 = database.relics.new(old1)
+ const oldId2 = database.relics.new(old2)
+ const oldId3 = database.relics.new(old3)
+ const oldId4 = database.relics.new(old4)
+ expect([oldId1, oldId2, oldId3, oldId4]).toEqual([
+ 'relic_0',
+ 'relic_1',
+ 'relic_2',
+ 'relic_3',
+ ])
+
+ const srod: ISrObjectDescription = {
+ format: 'SROD',
+ version: 1,
+ source: SroSource,
+ relics: [
+ { ...old1, id: oldId1 } as IRelic,
+ { ...old2, id: oldId2 } as IRelic,
+
+ //swap these two
+ { ...old4, id: oldId3 } as IRelic,
+ { ...old3, id: oldId4 } as IRelic,
+ ],
+ }
+
+ const importResult = database.importSROD(
+ srod as ISrObjectDescription & ISroDatabase,
+ true,
+ false
+ )
+ expect(importResult.relics.notInImport).toEqual(0)
+ expect(importResult.relics.unchanged.length).toEqual(4)
+ expect(database.relics.values.length).toEqual(4)
+ // Expect imports to overwrite the id of old
+ expect(database.relics.get(oldId1)?.slotKey).toEqual('head')
+ expect(database.relics.get(oldId2)?.slotKey).toEqual('hand')
+ expect(database.relics.get(oldId3)?.slotKey).toEqual('body')
+ expect(database.relics.get(oldId4)?.slotKey).toEqual('sphere')
+ })
+
+ test('import again with overlapping lightCone ids', () => {
+ const old1 = newLightCone('MakeTheWorldClamor')
+ const old2 = newLightCone('TrendOfTheUniversalMarket')
+ const old3 = newLightCone('Chorus')
+ const old4 = newLightCone('ASecretVow')
+
+ const oldId1 = database.lightCones.new(old1)
+ const oldId2 = database.lightCones.new(old2)
+ const oldId3 = database.lightCones.new(old3)
+ const oldId4 = database.lightCones.new(old4)
+ expect([oldId1, oldId2, oldId3, oldId4]).toEqual([
+ 'lightCone_0',
+ 'lightCone_1',
+ 'lightCone_2',
+ 'lightCone_3',
+ ])
+
+ const srod: ISrObjectDescription = {
+ format: 'SROD',
+ version: 1,
+ source: SroSource,
+ lightCones: [
+ { ...old1, id: oldId1 } as ILightCone,
+ { ...old2, id: oldId2 } as ILightCone,
+
+ //swap these two
+ { ...old4, id: oldId3 } as ILightCone,
+ { ...old3, id: oldId4 } as ILightCone,
+ ],
+ }
+
+ const importResult = database.importSROD(
+ srod as ISrObjectDescription & ISroDatabase,
+ true,
+ false
+ )
+ expect(importResult.lightCones.notInImport).toEqual(0)
+ expect(importResult.lightCones.unchanged.length).toEqual(4)
+ expect(database.lightCones.values.length).toEqual(4)
+ // Expect imports to overwrite the id of old
+ expect(database.lightCones.get(oldId1)?.key).toEqual('MakeTheWorldClamor')
+ expect(database.lightCones.get(oldId2)?.key).toEqual(
+ 'TrendOfTheUniversalMarket'
+ )
+ expect(database.lightCones.get(oldId3)?.key).toEqual('ASecretVow')
+ expect(database.lightCones.get(oldId4)?.key).toEqual('Chorus')
+ })
+ })
+
+ describe('mutual exclusion import with ids', () => {
+ test('import with mutually-exclusive relic ids', () => {
+ const old1 = randomizeRelic({ slotKey: 'head' })
+ const old2 = randomizeRelic({ slotKey: 'hand' })
+ const new1 = randomizeRelic({ slotKey: 'sphere' })
+ const new2 = randomizeRelic({ slotKey: 'body' })
+
+ const oldId1 = database.relics.new(old1)
+ const oldId2 = database.relics.new(old2)
+ expect([oldId1, oldId2]).toEqual(['relic_0', 'relic_1'])
+
+ const srod: ISrObjectDescription = {
+ format: 'SROD',
+ version: 1,
+ source: SroSource,
+ relics: [
+ { ...new1, id: oldId1 } as IRelic,
+ { ...new2, id: oldId2 } as IRelic,
+ ],
+ }
+
+ const importResult = database.importSROD(
+ srod as ISrObjectDescription & ISroDatabase,
+ true,
+ false
+ )
+ expect(importResult.relics.notInImport).toEqual(2)
+ expect(database.relics.values.length).toEqual(4)
+ // Expect imports to overwrite the id of old
+ expect(database.relics.get(oldId1)?.slotKey).toEqual('sphere')
+ expect(database.relics.get(oldId2)?.slotKey).toEqual('body')
+ // Expect old relics to have new id
+ expect(
+ database.relics.values.find((a) => a.slotKey === 'head')?.id
+ ).not.toEqual(oldId1)
+ expect(
+ database.relics.values.find((a) => a.slotKey === 'hand')?.id
+ ).not.toEqual(oldId2)
+ })
+
+ test('import with mutually exclusive lightCone ids', () => {
+ const old1 = newLightCone('ASecretVow')
+ const old2 = newLightCone('Cornucopia')
+ const new1 = newLightCone('Amber')
+ const new2 = newLightCone('DataBank')
+
+ const oldId1 = database.lightCones.new(old1)
+ const oldId2 = database.lightCones.new(old2)
+ expect([oldId1, oldId2]).toEqual(['lightCone_0', 'lightCone_1'])
+
+ const srod: ISrObjectDescription = {
+ format: 'SROD',
+ version: 1,
+ source: SroSource,
+ lightCones: [
+ { ...new1, id: oldId1 } as ILightCone,
+ { ...new2, id: oldId2 } as ILightCone,
+ ],
+ }
+
+ const importResult = database.importSROD(
+ srod as ISrObjectDescription & ISroDatabase,
+ true,
+ false
+ )
+ expect(importResult.lightCones.notInImport).toEqual(2)
+ expect(database.lightCones.values.length).toEqual(4)
+ // Expect imports to overwrite the id of old
+ expect(database.lightCones.get(oldId1)?.key).toEqual('Amber')
+ expect(database.lightCones.get(oldId2)?.key).toEqual('DataBank')
+ // Expect old relics to have new id
+ expect(
+ database.lightCones.values.find((a) => a.key === 'ASecretVow')?.id
+ ).not.toEqual(oldId1)
+ expect(
+ database.lightCones.values.find((a) => a.key === 'Cornucopia')?.id
+ ).not.toEqual(oldId2)
+ })
+ })
+
+ describe('Trailblazer Handling', () => {
+ test('Test Trailblazer share equipment', () => {
+ database.chars.set(
+ 'TrailblazerPhysical',
+ initialCharacter('TrailblazerPhysical')
+ )
+ database.chars.set('TrailblazerFire', initialCharacter('TrailblazerFire'))
+ const relic1 = randomizeRelic({
+ slotKey: 'body',
+ setKey: 'BandOfSizzlingThunder',
+ })
+ const relic1Id = database.relics.new({
+ ...relic1,
+ location: 'Trailblazer',
+ })
+
+ expect(
+ database.chars.get('TrailblazerPhysical')!.equippedRelics.body
+ ).toEqual(relic1Id)
+ expect(
+ database.chars.get('TrailblazerFire')!.equippedRelics.body
+ ).toEqual(relic1Id)
+ const lightCone1Id = database.chars.get(
+ 'TrailblazerPhysical'
+ )!.equippedLightCone
+ expect(database.chars.get('TrailblazerFire')!.equippedLightCone).toEqual(
+ lightCone1Id
+ )
+
+ const relic2 = randomizeRelic({
+ slotKey: 'body',
+ setKey: 'BelobogOfTheArchitects',
+ })
+ const relic2Id = database.relics.new({
+ ...relic2,
+ location: 'Trailblazer',
+ })
+ expect(
+ database.chars.get('TrailblazerPhysical')!.equippedRelics.body
+ ).toEqual(relic2Id)
+ expect(
+ database.chars.get('TrailblazerFire')!.equippedRelics.body
+ ).toEqual(relic2Id)
+
+ const lightCone2Id = database.lightCones.new({
+ ...newLightCone('Chorus'),
+ location: 'Trailblazer',
+ })
+ expect(
+ database.chars.get('TrailblazerPhysical')!.equippedLightCone
+ ).toEqual(lightCone2Id)
+ expect(database.chars.get('TrailblazerFire')!.equippedLightCone).toEqual(
+ lightCone2Id
+ )
+
+ // deletion dont remove equipment until all traveler is gone
+ database.chars.remove('TrailblazerFire')
+
+ expect(
+ database.chars.get('TrailblazerPhysical')!.equippedRelics.body
+ ).toEqual(relic2Id)
+ expect(
+ database.chars.get('TrailblazerPhysical')!.equippedLightCone
+ ).toEqual(lightCone2Id)
+ expect(database.relics.get(relic2Id)!.location).toEqual('Trailblazer')
+ expect(database.lightCones.get(lightCone2Id)!.location).toEqual(
+ 'Trailblazer'
+ )
+
+ // deletion of final traveler unequips
+ database.chars.remove('TrailblazerPhysical')
+
+ expect(database.relics.get(relic2Id)!.location).toEqual('')
+ expect(database.lightCones.get(lightCone2Id)!.location).toEqual('')
+ })
+ })
+
+ describe('DataManager.changeId', () => {
+ test('should changeId for relics', () => {
+ const relic = randomizeRelic({ location: 'March7th', slotKey: 'head' })
+ const oldId = database.relics.new(relic)
+ const newId = 'newTestId'
+ expect(database.relics.changeId(oldId, newId)).toBeTruthy()
+
+ expect(database.relics.get(oldId)).toBeUndefined()
+
+ const cachrelic = database.relics.get(newId)
+ expect(cachrelic).toBeTruthy()
+ expect(cachrelic?.location).toEqual('March7th')
+ expect(database.chars.get('March7th')?.equippedRelics.head).toEqual(newId)
+ })
+ test('should changeId for lightCones', () => {
+ const lightCone = newLightCone('TrendOfTheUniversalMarket')
+ lightCone.location = 'March7th'
+ const oldId = database.lightCones.new(lightCone)
+ const newId = 'newTestId'
+ database.lightCones.changeId(oldId, newId)
+
+ expect(database.lightCones.get(oldId)).toBeUndefined()
+
+ const cachWea = database.lightCones.get(newId)
+ expect(cachWea).toBeTruthy()
+ expect(cachWea?.location).toEqual('March7th')
+ expect(database.chars.get('March7th')?.equippedLightCone).toEqual(newId)
+ })
+ })
+
+ test('Test mismatch lightCone path location; lc should stay equipped', () => {
+ // Add Character and Relic
+ const march7th = initialCharacter('March7th')
+ const march7thLightCone = newLightCone('TrendOfTheUniversalMarket')
+ march7thLightCone.location = 'March7th'
+
+ database.chars.set(march7th.key, march7th)
+ const swordid = database.lightCones.new(march7thLightCone)
+ database.lightCones.set(swordid, march7thLightCone)
+
+ expect(database.chars.get('March7th')?.equippedLightCone).toEqual(swordid)
+ const srod1: ISrObjectDescription = {
+ format: 'SROD',
+ version: 1,
+ source: 'Scanner',
+ lightCones: [
+ // Invalid Abundance on Preservation char
+ { ...newLightCone('Chorus'), location: 'March7th' },
+ ],
+ }
+ const importResult = database.importSROD(
+ srod1 as ISrObjectDescription & ISroDatabase,
+ true,
+ false
+ )
+ expect(importResult.lightCones.new.length).toEqual(1)
+ expect(importResult.characters.new.length).toEqual(0)
+ expect(
+ database.lightCones.get(database.chars.get('March7th')?.equippedLightCone)
+ ?.key
+ ).toEqual('Chorus')
+ })
+})
diff --git a/libs/sr-db/src/Database/Database.ts b/libs/sr-db/src/Database/Database.ts
new file mode 100644
index 0000000000..d03f6a17e9
--- /dev/null
+++ b/libs/sr-db/src/Database/Database.ts
@@ -0,0 +1,185 @@
+import type { DBStorage } from '@genshin-optimizer/database'
+import { Database, SandboxStorage } from '@genshin-optimizer/database'
+import type { GenderKey } from '@genshin-optimizer/sr-consts'
+import type { ISrObjectDescription } from '@genshin-optimizer/sr-srod'
+import { createContext } from 'react'
+import type { ISroDatabase } from '../Interfaces'
+import { SroSource } from '../Interfaces'
+import { DBMetaEntry } from './DataEntries/DBMetaEntry'
+import { BuildResultDataManager } from './DataManagers/BuildResultData'
+import { BuildSettingDataManager } from './DataManagers/BuildSettingData'
+import { CharMetaDataManager } from './DataManagers/CharMetaData'
+import { CharacterDataManager } from './DataManagers/CharacterData'
+import { LightConeDataManager } from './DataManagers/LightConeData'
+import { RelicDataManager } from './DataManagers/RelicData'
+import type { ImportResult } from './exim'
+import { newImportResult } from './exim'
+import {
+ currentDBVersion,
+ migrateSr as migrateSROD,
+ migrateStorage,
+} from './migrate'
+export class SroDatabase extends Database {
+ relics: RelicDataManager
+ chars: CharacterDataManager
+ lightCones: LightConeDataManager
+ buildSettings: BuildSettingDataManager
+ buildResult: BuildResultDataManager
+ charMeta: CharMetaDataManager
+
+ dbMeta: DBMetaEntry
+ dbIndex: 1 | 2 | 3 | 4
+ dbVer: number
+
+ constructor(dbIndex: 1 | 2 | 3 | 4, storage: DBStorage) {
+ super(storage)
+ migrateStorage(storage)
+ // Transfer non DataManager/DataEntry data from storage
+ this.dbIndex = dbIndex
+ this.dbVer = storage.getDBVersion()
+ this.storage.setDBVersion(this.dbVer)
+ this.storage.setDBIndex(this.dbIndex)
+
+ // Handle Datamanagers
+ this.chars = new CharacterDataManager(this)
+
+ // Weapons needs to be instantiated after character to check for relations
+ this.lightCones = new LightConeDataManager(this)
+
+ // Artifacts needs to be instantiated after character to check for relations
+ this.relics = new RelicDataManager(this)
+
+ this.buildSettings = new BuildSettingDataManager(this)
+
+ // This should be instantiated after artifacts, so that invalid artifacts that persists in build results can be pruned.
+ this.buildResult = new BuildResultDataManager(this)
+
+ this.charMeta = new CharMetaDataManager(this)
+
+ // Handle DataEntries
+ this.dbMeta = new DBMetaEntry(this)
+
+ // invalidates character when things change.
+ this.chars.followAny(() => {
+ this.dbMeta.set({ lastEdit: Date.now() })
+ })
+ this.relics.followAny(() => {
+ this.dbMeta.set({ lastEdit: Date.now() })
+ })
+ this.lightCones.followAny(() => {
+ this.dbMeta.set({ lastEdit: Date.now() })
+ })
+ }
+ get dataManagers() {
+ // IMPORTANT: it must be chars, weapon, arts in order, to respect import order
+ return [
+ this.chars,
+ this.lightCones,
+ this.relics,
+ this.buildSettings,
+ this.buildResult,
+ this.charMeta,
+ ] as const
+ }
+ get dataEntries() {
+ return [this.dbMeta] as const
+ }
+
+ clear() {
+ this.dataManagers.map((dm) => dm.clear())
+ this.dataEntries.map((de) => de.clear())
+ }
+ get gender() {
+ const gender: GenderKey = this.dbMeta.get().gender ?? 'F'
+ return gender
+ }
+ exportSROD() {
+ const srod: Partial = {
+ format: 'SROD',
+ dbVersion: currentDBVersion,
+ source: SroSource,
+ version: 1,
+ }
+ this.dataManagers.map((dm) => dm.exportSROD(srod))
+ this.dataEntries.map((de) => de.exportSROD(srod))
+ return srod as ISroDatabase & ISrObjectDescription
+ }
+ importSROD(
+ srod: ISrObjectDescription & ISroDatabase,
+ keepNotInImport: boolean,
+ ignoreDups: boolean
+ ): ImportResult {
+ srod = migrateSROD(srod)
+ const source = srod.source ?? 'Unknown'
+ // Some Scanners might carry their own id field, which would conflict with GO dup resolution.
+ if (source !== SroSource) {
+ srod.relics?.forEach((a) => delete (a as unknown as { id?: string }).id)
+ srod.lightCones?.forEach(
+ (a) => delete (a as unknown as { id?: string }).id
+ )
+ }
+ const result: ImportResult = newImportResult(
+ source,
+ keepNotInImport,
+ ignoreDups
+ )
+
+ // Follow updates from char/relic/lightCone to gather import results
+ const unfollows = [
+ this.chars.followAny((key, reason, value) => {
+ const arr = result.characters[reason]
+ const ind = arr.findIndex((c) => c?.key === key)
+ if (ind < 0) arr.push(value)
+ else arr[ind] = value
+ }),
+ this.relics.followAny((_key, reason, value) =>
+ result.relics[reason].push(value)
+ ),
+ this.lightCones.followAny((_key, reason, value) =>
+ result.lightCones[reason].push(value)
+ ),
+ ]
+
+ this.dataManagers.map((dm) => dm.importSROD(srod, result))
+ this.dataEntries.map((de) => de.importSROD(srod, result))
+ unfollows.forEach((f) => f())
+
+ return result
+ }
+ clearStorage() {
+ this.dataManagers.map((dm) => dm.clearStorage())
+ this.dataEntries.map((de) => de.clearStorage())
+ }
+ saveStorage() {
+ this.dataManagers.map((dm) => dm.saveStorage())
+ this.dataEntries.map((de) => de.saveStorage())
+ this.storage.setDBVersion(this.dbVer)
+ this.storage.setDBIndex(this.dbIndex)
+ }
+ swapStorage(other: SroDatabase) {
+ this.clearStorage()
+ other.clearStorage()
+
+ const thisStorage = this.storage
+ this.storage = other.storage
+ other.storage = thisStorage
+
+ this.saveStorage()
+ other.saveStorage()
+ }
+ toExtraLocalDB() {
+ const key = `extraDatabase_${this.storage.getDBIndex()}`
+ const other = new SandboxStorage(undefined, 'sro')
+ const oldstorage = this.storage
+ this.storage = other
+ this.saveStorage()
+ this.storage = oldstorage
+ localStorage.setItem(key, JSON.stringify(Object.fromEntries(other.entries)))
+ }
+}
+export type DatabaseContextObj = {
+ databases: SroDatabase[]
+ setDatabase: (index: number, db: SroDatabase) => void
+ database: SroDatabase
+}
+export const DatabaseContext = createContext({} as DatabaseContextObj)
diff --git a/libs/sr-db/src/Database/exim.ts b/libs/sr-db/src/Database/exim.ts
new file mode 100644
index 0000000000..58e66412a4
--- /dev/null
+++ b/libs/sr-db/src/Database/exim.ts
@@ -0,0 +1,53 @@
+import type { ILightCone, IRelic } from '@genshin-optimizer/sr-srod'
+import type { ISroCharacter } from '../Interfaces'
+
+function newCounter(): ImportResultCounter {
+ return {
+ import: 0,
+ invalid: [],
+ new: [],
+ update: [],
+ unchanged: [],
+ upgraded: [],
+ remove: [],
+ notInImport: 0,
+ beforeMerge: 0,
+ }
+}
+
+export function newImportResult(
+ source: string,
+ keepNotInImport: boolean,
+ ignoreDups: boolean
+): ImportResult {
+ return {
+ type: 'SR',
+ source,
+ relics: newCounter(),
+ lightCones: newCounter(),
+ characters: newCounter(),
+ keepNotInImport,
+ ignoreDups,
+ }
+}
+
+export type ImportResultCounter = {
+ import: number // total # in file
+ new: T[]
+ update: T[] // Use new object
+ unchanged: T[] // Use new object
+ upgraded: T[]
+ remove: T[]
+ invalid: T[]
+ notInImport: number
+ beforeMerge: number
+}
+export type ImportResult = {
+ type: 'SR'
+ source: string
+ relics: ImportResultCounter
+ lightCones: ImportResultCounter
+ characters: ImportResultCounter
+ keepNotInImport: boolean
+ ignoreDups: boolean
+}
diff --git a/libs/sr-db/src/Database/index.ts b/libs/sr-db/src/Database/index.ts
new file mode 100644
index 0000000000..79a45dbc02
--- /dev/null
+++ b/libs/sr-db/src/Database/index.ts
@@ -0,0 +1 @@
+export * from './Database'
diff --git a/libs/sr-db/src/Database/migrate.ts b/libs/sr-db/src/Database/migrate.ts
new file mode 100644
index 0000000000..ba7d021984
--- /dev/null
+++ b/libs/sr-db/src/Database/migrate.ts
@@ -0,0 +1,60 @@
+// MIGRATION STEP
+// 0. DO NOT change old `migrateVersion` calls
+// 1. Add new `migrateVersion` call within `migrateSr` function
+// 2. Add new `migrateVersion` call within `migrateStorage` function
+// 3. Update `currentDBVersion`
+// 4. Test on import, and also on version update
+
+import type { DBStorage } from '@genshin-optimizer/database'
+import type { ISrObjectDescription } from '@genshin-optimizer/sr-srod'
+import type { ISroDatabase } from '../Interfaces'
+
+export const currentDBVersion = 1
+
+export function migrateSr(
+ sr: ISrObjectDescription & ISroDatabase
+): ISrObjectDescription & ISroDatabase {
+ const version = sr.dbVersion ?? 0
+ // function migrateVersion(version: number, cb: () => void) {
+ // const dbver = sr.dbVersion ?? 0
+ // if (dbver < version) {
+ // cb()
+ // // Update version upon each successful migration, so we don't
+ // // need to migrate that part again if later parts fail.
+ // sr.dbVersion = version
+ // }
+ // }
+
+ // migrateVersion(2, () => {})
+
+ sr.dbVersion = currentDBVersion
+ if (version > currentDBVersion)
+ throw new Error(`Database version ${version} is not supported`)
+ return sr
+}
+
+/**
+ * Migrate parsed data in `storage` in-place to a parsed data of the latest supported DB version.
+ *
+ * **CAUTION**
+ * Throw an error if `storage` uses unsupported DB version.
+ */
+export function migrateStorage(storage: DBStorage) {
+ const version = storage.getDBVersion()
+
+ // function migrateVersion(version: number, cb: () => void) {
+ // const dbver = storage.getDBVersion()
+ // if (dbver < version) {
+ // cb()
+ // // Update version upon each successful migration, so we don't
+ // // need to migrate that part again if later parts fail.
+ // storage.setDBVersion(version)
+ // }
+ // }
+
+ // migrateVersion(2, () => {})
+
+ storage.setDBVersion(currentDBVersion)
+ if (version > currentDBVersion)
+ throw new Error(`Database version ${version} is not supported`)
+}
diff --git a/libs/sr-db/src/ISroCharacter.ts b/libs/sr-db/src/Interfaces/ISroCharacter.ts
similarity index 96%
rename from libs/sr-db/src/ISroCharacter.ts
rename to libs/sr-db/src/Interfaces/ISroCharacter.ts
index cbb14d1da9..a8271b0d97 100644
--- a/libs/sr-db/src/ISroCharacter.ts
+++ b/libs/sr-db/src/Interfaces/ISroCharacter.ts
@@ -26,5 +26,5 @@ export interface ISroCharacter extends ICharacter {
export interface ICachedSroCharacter extends ISroCharacter {
equippedRelics: Record
- equippedLightCone: string
+ equippedLightCone?: string
}
diff --git a/libs/sr-db/src/ISroDatabase.ts b/libs/sr-db/src/Interfaces/ISroDatabase.ts
similarity index 93%
rename from libs/sr-db/src/ISroDatabase.ts
rename to libs/sr-db/src/Interfaces/ISroDatabase.ts
index 6addcb2e17..be71e9e0e6 100644
--- a/libs/sr-db/src/ISroDatabase.ts
+++ b/libs/sr-db/src/Interfaces/ISroDatabase.ts
@@ -4,7 +4,6 @@ export const SroSource = 'Star Rail Optimizer' as const
export const SroFormat = 'SRO' as const
export interface ISroDatabase extends ISrObjectDescription {
- format: typeof SroFormat
version: 1
dbVersion: number
source: typeof SroSource
diff --git a/libs/sr-db/src/Interfaces/ISroLightCone.ts b/libs/sr-db/src/Interfaces/ISroLightCone.ts
new file mode 100644
index 0000000000..23bb96f66e
--- /dev/null
+++ b/libs/sr-db/src/Interfaces/ISroLightCone.ts
@@ -0,0 +1,5 @@
+import type { ILightCone } from '@genshin-optimizer/sr-srod'
+
+export interface ICachedLightCone extends ILightCone {
+ id: string
+}
diff --git a/libs/sr-db/src/Interfaces/ISroRelic.ts b/libs/sr-db/src/Interfaces/ISroRelic.ts
new file mode 100644
index 0000000000..791a6a77cf
--- /dev/null
+++ b/libs/sr-db/src/Interfaces/ISroRelic.ts
@@ -0,0 +1,13 @@
+import type { IRelic, ISubstat } from '@genshin-optimizer/sr-srod'
+
+export interface ICachedRelic extends IRelic {
+ id: string
+ mainStatVal: number
+ substats: ICachedSubstat[]
+}
+
+export interface ICachedSubstat extends ISubstat {
+ rolls: number[]
+ efficiency: number
+ accurateValue: number
+}
diff --git a/libs/sr-db/src/Interfaces/index.ts b/libs/sr-db/src/Interfaces/index.ts
new file mode 100644
index 0000000000..800eef268d
--- /dev/null
+++ b/libs/sr-db/src/Interfaces/index.ts
@@ -0,0 +1,4 @@
+export * from './ISroCharacter'
+export * from './ISroDatabase'
+export * from './ISroLightCone'
+export * from './ISroRelic'
diff --git a/libs/sr-db/src/index.ts b/libs/sr-db/src/index.ts
index f51df54c23..85ceb4bf91 100644
--- a/libs/sr-db/src/index.ts
+++ b/libs/sr-db/src/index.ts
@@ -1,2 +1,2 @@
-export * from './ISroCharacter'
-export * from './ISroDatabase'
+export * from './Interfaces'
+export * from './Database'
diff --git a/libs/sr-db/tsconfig.json b/libs/sr-db/tsconfig.json
index db7b566661..61630f6864 100644
--- a/libs/sr-db/tsconfig.json
+++ b/libs/sr-db/tsconfig.json
@@ -7,13 +7,19 @@
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
- "noFallthroughCasesInSwitch": true
+ "noFallthroughCasesInSwitch": true,
+ "exactOptionalPropertyTypes": false,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
}
]
}
diff --git a/libs/sr-db/tsconfig.spec.json b/libs/sr-db/tsconfig.spec.json
new file mode 100644
index 0000000000..9f6b5825cc
--- /dev/null
+++ b/libs/sr-db/tsconfig.spec.json
@@ -0,0 +1,13 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "types": ["vitest/globals", "node"]
+ },
+ "include": [
+ "vitest.config.ts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/libs/sr-db/vitest.config.ts b/libs/sr-db/vitest.config.ts
new file mode 100644
index 0000000000..132b0c5118
--- /dev/null
+++ b/libs/sr-db/vitest.config.ts
@@ -0,0 +1,19 @@
+import { resolve } from 'path'
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ globals: true,
+ cache: {
+ dir: '../../node_modules/.vitest',
+ },
+ include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ environment: 'jsdom',
+ },
+ resolve: {
+ alias: [
+ // e.g. Resolves '@genshin-optimizer/pando' -> 'libs/pando/src'
+ { find: /@genshin-optimizer\/(.*)/, replacement: resolve('libs/$1/src') },
+ ],
+ },
+})
diff --git a/libs/sr-srod/src/IRelic.ts b/libs/sr-srod/src/IRelic.ts
index 7b51f7c7b0..fc0a9f2e4c 100644
--- a/libs/sr-srod/src/IRelic.ts
+++ b/libs/sr-srod/src/IRelic.ts
@@ -1,7 +1,7 @@
import type {
LocationKey,
- RarityKey,
RelicMainStatKey,
+ RelicRarityKey,
RelicSetKey,
RelicSlotKey,
RelicSubStatKey,
@@ -11,7 +11,7 @@ export interface IRelic {
setKey: RelicSetKey
slotKey: RelicSlotKey
level: number
- rarity: RarityKey
+ rarity: RelicRarityKey
mainStatKey: RelicMainStatKey
location: LocationKey
lock: boolean
diff --git a/libs/sr-util/.eslintrc.json b/libs/sr-util/.eslintrc.json
new file mode 100644
index 0000000000..9d9c0db55b
--- /dev/null
+++ b/libs/sr-util/.eslintrc.json
@@ -0,0 +1,18 @@
+{
+ "extends": ["../../.eslintrc.json"],
+ "ignorePatterns": ["!**/*"],
+ "overrides": [
+ {
+ "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.ts", "*.tsx"],
+ "rules": {}
+ },
+ {
+ "files": ["*.js", "*.jsx"],
+ "rules": {}
+ }
+ ]
+}
diff --git a/libs/sr-util/README.md b/libs/sr-util/README.md
new file mode 100644
index 0000000000..0630119d22
--- /dev/null
+++ b/libs/sr-util/README.md
@@ -0,0 +1,3 @@
+# sr-util
+
+This library was generated with [Nx](https://nx.dev).
diff --git a/libs/sr-util/project.json b/libs/sr-util/project.json
new file mode 100644
index 0000000000..20097fdf49
--- /dev/null
+++ b/libs/sr-util/project.json
@@ -0,0 +1,16 @@
+{
+ "name": "sr-util",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/sr-util/src",
+ "projectType": "library",
+ "targets": {
+ "lint": {
+ "executor": "@nx/linter:eslint",
+ "outputs": ["{options.outputFile}"],
+ "options": {
+ "lintFilePatterns": ["libs/sr-util/**/*.ts"]
+ }
+ }
+ },
+ "tags": []
+}
diff --git a/libs/sr-util/src/index.ts b/libs/sr-util/src/index.ts
new file mode 100644
index 0000000000..973a8361fb
--- /dev/null
+++ b/libs/sr-util/src/index.ts
@@ -0,0 +1,2 @@
+export * from './level'
+export * from './relic'
diff --git a/libs/sr-util/src/level.ts b/libs/sr-util/src/level.ts
new file mode 100644
index 0000000000..9864ef4a67
--- /dev/null
+++ b/libs/sr-util/src/level.ts
@@ -0,0 +1,20 @@
+import type { AscensionKey } from '@genshin-optimizer/sr-consts'
+export const ascensionMaxLevel = [20, 30, 40, 50, 60, 70, 80] as const
+
+export function validateLevelAsc(
+ level: number,
+ ascension: AscensionKey
+): { level: number; ascension: AscensionKey } {
+ if (typeof level !== 'number' || level < 1 || level > 80) level = 1
+ if (typeof ascension !== 'number' || ascension < 0 || ascension > 6)
+ ascension = 0
+
+ if (
+ level > ascensionMaxLevel[ascension] ||
+ level < (ascensionMaxLevel[ascension - 1] ?? 0)
+ )
+ ascension = ascensionMaxLevel.findIndex(
+ (maxLvl) => level <= maxLvl
+ ) as AscensionKey
+ return { level, ascension }
+}
diff --git a/libs/sr-util/src/relic.ts b/libs/sr-util/src/relic.ts
new file mode 100644
index 0000000000..8caab1eaec
--- /dev/null
+++ b/libs/sr-util/src/relic.ts
@@ -0,0 +1,141 @@
+import type { RelicSlotKey } from '@genshin-optimizer/sr-consts'
+import {
+ allRelicCavernSlotKeys,
+ allRelicPlanarSetKeys,
+ allRelicPlanarSlotKeys,
+ allRelicRarityKeys,
+ allRelicSetKeys,
+ allRelicSubStatKeys,
+ relicMaxLevel,
+ relicSlotToMainStatKeys,
+ relicSubstatRollData,
+ type RelicMainStatKey,
+ type RelicRarityKey,
+ type RelicSubStatKey,
+} from '@genshin-optimizer/sr-consts'
+import type { IRelic, ISubstat } from '@genshin-optimizer/sr-srod'
+import { allStats } from '@genshin-optimizer/sr-stats'
+import {
+ getRandomElementFromArray,
+ getRandomIntInclusive,
+ range,
+ toPercent,
+ unit,
+} from '@genshin-optimizer/util'
+
+export function getRelicMainStatVal(
+ rarity: RelicRarityKey,
+ statKey: RelicMainStatKey,
+ level: number
+) {
+ const { base, add } = allStats.relic.main[rarity][statKey] ?? {}
+ if (base === undefined || add === undefined)
+ throw new Error(
+ `Attempted to get relic main stat value that doesn't exist for a level ${level} ${rarity}-star, ${statKey} relic.`
+ )
+ return base + add * level
+}
+
+export function getRelicMainStatDisplayVal(
+ rarity: RelicRarityKey,
+ statKey: RelicMainStatKey,
+ level: number
+) {
+ return roundStat(
+ toPercent(getRelicMainStatVal(rarity, statKey, level), statKey),
+ statKey
+ )
+}
+
+// TODO: Update this with proper corrected rolls
+export function getSubstatValue(
+ rarity: RelicRarityKey,
+ statKey: RelicSubStatKey,
+ type: 'low' | 'med' | 'high' = 'high',
+ round = true
+) {
+ const { base, step } = allStats.relic.sub[rarity][statKey] ?? {}
+ if (base === undefined || step === undefined)
+ throw new Error(
+ `Attempted to get relic sub stat value that doesn't exist for a ${rarity}-star relic with substat ${statKey}.`
+ )
+ const steps = type === 'low' ? 0 : type === 'med' ? 1 : 2
+ const value = base + steps * step
+ return round ? roundStat(value, statKey) : value
+}
+
+// TODO: Update this with proper corrected rolls
+export function getSubstatRange(
+ rarity: RelicRarityKey,
+ statKey: RelicSubStatKey,
+ round = true
+) {
+ const { numUpgrades } = relicSubstatRollData[rarity]
+ const high =
+ getSubstatValue(rarity, statKey, 'high', false) * (numUpgrades + 1)
+ return {
+ low: getSubstatValue(rarity, statKey, 'low', round),
+ high: round ? roundStat(high, statKey) : high,
+ }
+}
+
+export function randomizeRelic(base: Partial = {}): IRelic {
+ const setKey = base.setKey ?? getRandomElementFromArray(allRelicSetKeys)
+
+ const rarity = base.rarity ?? getRandomElementFromArray(allRelicRarityKeys)
+ const slot: RelicSlotKey =
+ base.slotKey ??
+ getRandomElementFromArray(
+ [...(allRelicPlanarSetKeys as readonly string[])].includes(setKey)
+ ? allRelicPlanarSlotKeys
+ : allRelicCavernSlotKeys
+ )
+ const mainStatKey: RelicMainStatKey =
+ base.mainStatKey ?? getRandomElementFromArray(relicSlotToMainStatKeys[slot])
+ const level = base.level ?? getRandomIntInclusive(0, relicMaxLevel[rarity])
+ const substats: ISubstat[] = [0, 1, 2, 3].map(() => ({ key: '', value: 0 }))
+
+ const { low, high } = relicSubstatRollData[rarity]
+ const totRolls = Math.floor(level / 3) + getRandomIntInclusive(low, high)
+ const numOfInitialSubstats = Math.min(totRolls, 4)
+ const numUpgradesOrUnlocks = totRolls - numOfInitialSubstats
+
+ const RollStat = (substat: RelicSubStatKey): number =>
+ allStats.relic.sub[rarity][substat].base +
+ getRandomElementFromArray(range(0, 2)) *
+ allStats.relic.sub[rarity][substat].step
+
+ let remainingSubstats = allRelicSubStatKeys.filter(
+ (key) => mainStatKey !== key
+ )
+ for (const substat of substats.slice(0, numOfInitialSubstats)) {
+ substat.key = getRandomElementFromArray(remainingSubstats)
+ substat.value = RollStat(substat.key as RelicSubStatKey)
+ remainingSubstats = remainingSubstats.filter((key) => key !== substat.key)
+ }
+ for (let i = 0; i < numUpgradesOrUnlocks; i++) {
+ const substat = getRandomElementFromArray(substats)
+ substat.value += RollStat(substat.key as any)
+ }
+ for (const substat of substats)
+ if (substat.key) {
+ substat.value = roundStat(substat.value, substat.key)
+ }
+
+ return {
+ setKey,
+ rarity,
+ slotKey: slot,
+ mainStatKey,
+ level,
+ substats,
+ location: base.location ?? '',
+ lock: false,
+ }
+}
+
+function roundStat(value: number, statKey: RelicMainStatKey | RelicSubStatKey) {
+ return unit(statKey) === '%'
+ ? Math.round(value * 10000) / 10000
+ : Math.round(value * 100) / 100
+}
diff --git a/libs/sr-util/tsconfig.json b/libs/sr-util/tsconfig.json
new file mode 100644
index 0000000000..db7b566661
--- /dev/null
+++ b/libs/sr-util/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "commonjs",
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ }
+ ]
+}
diff --git a/libs/sr-util/tsconfig.lib.json b/libs/sr-util/tsconfig.lib.json
new file mode 100644
index 0000000000..33eca2c2cd
--- /dev/null
+++ b/libs/sr-util/tsconfig.lib.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "declaration": true,
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
+}
diff --git a/libs/ui-common/src/components/GeneralAutocomplete.tsx b/libs/ui-common/src/components/GeneralAutocomplete.tsx
index 72cb507ef3..b466f5d723 100644
--- a/libs/ui-common/src/components/GeneralAutocomplete.tsx
+++ b/libs/ui-common/src/components/GeneralAutocomplete.tsx
@@ -178,7 +178,7 @@ export function GeneralAutocompleteMulti({
multiple
disableCloseOnSelect
value={value}
- onChange={(event, newValue, reason) => {
+ onChange={(_event, newValue, reason) => {
if (reason === 'clear') return onChange([])
return newValue !== null && onChange(newValue.map((v) => v.key))
}}
diff --git a/libs/ui-common/tsconfig.json b/libs/ui-common/tsconfig.json
index 95d63299d7..7ff89730ac 100644
--- a/libs/ui-common/tsconfig.json
+++ b/libs/ui-common/tsconfig.json
@@ -3,7 +3,9 @@
"jsx": "react-jsx",
"esModuleInterop": false,
"jsxImportSource": "@emotion/react",
- "exactOptionalPropertyTypes": false
+ "exactOptionalPropertyTypes": false,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true
},
"files": [],
"include": [],
diff --git a/tsconfig.base.json b/tsconfig.base.json
index aed68436fb..67735688e6 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -69,6 +69,7 @@
"@genshin-optimizer/sr-formula": ["libs/sr-formula/src/index.ts"],
"@genshin-optimizer/sr-srod": ["libs/sr-srod/src/index.ts"],
"@genshin-optimizer/sr-stats": ["libs/sr-stats/src/index.ts"],
+ "@genshin-optimizer/sr-util": ["libs/sr-util/src/index.ts"],
"@genshin-optimizer/svgicons": ["libs/svgicons/src/index.ts"],
"@genshin-optimizer/ui-common": ["libs/ui-common/src/index.ts"],
"@genshin-optimizer/util": ["libs/util/src/index.ts"]
From 203af6e56d3c0481b164a829b3b2fae7b7cd8a95 Mon Sep 17 00:00:00 2001
From: Van Nguyen <36019388+nguyentvan7@users.noreply.github.com>
Date: Thu, 28 Dec 2023 21:42:56 -0700
Subject: [PATCH 11/17] Update naming for beta release; add SRO beta release
(#1397)
---
.../{beta-release.yml => beta-go-release.yml} | 2 +-
.github/workflows/beta-sro-release.yml | 23 +++++++++++++++++++
2 files changed, 24 insertions(+), 1 deletion(-)
rename .github/workflows/{beta-release.yml => beta-go-release.yml} (95%)
create mode 100644 .github/workflows/beta-sro-release.yml
diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-go-release.yml
similarity index 95%
rename from .github/workflows/beta-release.yml
rename to .github/workflows/beta-go-release.yml
index 9dde70c1ec..fc1b3ec254 100644
--- a/.github/workflows/beta-release.yml
+++ b/.github/workflows/beta-go-release.yml
@@ -1,4 +1,4 @@
-name: New Beta Release
+name: New Beta GO Release
on:
push:
diff --git a/.github/workflows/beta-sro-release.yml b/.github/workflows/beta-sro-release.yml
new file mode 100644
index 0000000000..eadec771e7
--- /dev/null
+++ b/.github/workflows/beta-sro-release.yml
@@ -0,0 +1,23 @@
+name: New Beta SRO Release
+
+on:
+ push:
+ branches:
+ - master
+
+# Only allow the most recent run of beta release to complete, if multiple are queued.
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: true
+
+jobs:
+ call-deploy-frontend:
+ uses: ./.github/workflows/deploy-frontend.yml
+ with:
+ frontend_name: 'sro-frontend'
+ repo_full_name: ${{ github.repository }}
+ ref: ${{ github.ref }}
+ deployment_name: 'beta'
+ pr_repo: ${{ vars.PR_REPO }}
+ show_dev_components: true
+ secrets: inherit
From e7a9fba631155501b6d5f85609d3f21c70f74035 Mon Sep 17 00:00:00 2001
From: Jie Hao Liao
Date: Thu, 28 Dec 2023 21:17:21 -0800
Subject: [PATCH 12/17] Character talent tab translations (#1359)
* Add all translations for character talent page
* Use interpolation for levels in talent and constellation levels
* use datamine translations
* fix regex
---------
Co-authored-by: frzyc
---
.../CharacterDisplay/Tabs/TabTalent.tsx | 29 ++++++++++---------
libs/dm/src/mapping/index.ts | 1 +
libs/gi-dm-localization/project.json | 3 +-
.../executors/gen-locale/lib/Data/sheet.ts | 20 +++++++++++++
.../src/executors/gen-locale/lib/parseUtil.ts | 26 +++++++++++++++++
5 files changed, 65 insertions(+), 14 deletions(-)
diff --git a/apps/frontend/src/app/PageCharacter/CharacterDisplay/Tabs/TabTalent.tsx b/apps/frontend/src/app/PageCharacter/CharacterDisplay/Tabs/TabTalent.tsx
index defdcf6f3d..dc2f18eb6f 100644
--- a/apps/frontend/src/app/PageCharacter/CharacterDisplay/Tabs/TabTalent.tsx
+++ b/apps/frontend/src/app/PageCharacter/CharacterDisplay/Tabs/TabTalent.tsx
@@ -10,6 +10,7 @@ import {
useTheme,
} from '@mui/material'
import { useCallback, useContext, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
import CardDark from '../../../Components/Card/CardDark'
import CardLight from '../../../Components/Card/CardLight'
import ConditionalWrapper from '../../../Components/ConditionalWrapper'
@@ -33,22 +34,23 @@ const talentSpacing = {
}
export default function CharacterTalentPane() {
+ const { t } = useTranslation('sheet_gen')
const { character, characterSheet } = useContext(CharacterContext)
const { data } = useContext(DataContext)
const characterDispatch = useCharacterReducer(character.key)
const skillBurstList = [
- ['auto', 'Normal/Charged Attack'],
- ['skill', 'Elemental Skill'],
- ['burst', 'Elemental Burst'],
+ ['auto', t('talents.auto')],
+ ['skill', t('talents.skill')],
+ ['burst', t('talents.burst')],
] as [TalentSheetElementKey, string][]
const passivesList: [
tKey: TalentSheetElementKey,
tText: string,
asc: number
][] = [
- ['passive1', 'Unlocked at Ascension 1', 1],
- ['passive2', 'Unlocked at Ascension 4', 4],
- ['passive3', 'Unlocked by Default', 0],
+ ['passive1', t('unlockPassive1'), 1],
+ ['passive2', t('unlockPassive2'), 4],
+ ['passive3', t('unlockPassive3'), 0],
]
const ascension = data.get(input.asc).value
const constellation = data.get(input.constellation).value
@@ -60,7 +62,7 @@ export default function CharacterTalentPane() {
range(1, maxConstellationCount).map((i) => (
characterDispatch({
constellation: i === constellation ? i - 1 : i,
@@ -68,12 +70,12 @@ export default function CharacterTalentPane() {
}
/>
)),
- [constellation, characterDispatch]
+ [t, constellation, characterDispatch]
)
const constellationHeader = (
@@ -88,7 +90,7 @@ export default function CharacterTalentPane() {
})
}
>
- Constellation Lv. {i}
+ {t(`constellationLvl`, { level: i })}
))}
@@ -126,7 +128,7 @@ export default function CharacterTalentPane() {
)}
@@ -210,6 +212,7 @@ function SkillDisplayCard({
subtitle,
onClickTitle,
}: SkillDisplayCardProps) {
+ const { t } = useTranslation('sheet_gen')
const {
character: { talent },
characterSheet,
@@ -242,7 +245,7 @@ function SkillDisplayCard({
header = (
@@ -253,7 +256,7 @@ function SkillDisplayCard({
disabled={talent[talentKey] === i}
onClick={() => setTalentLevel(talentKey, i)}
>
- Talent Lv. {i + levelBoost}
+ {t('talentLvl', { level: i + levelBoost })}
))}
diff --git a/libs/dm/src/mapping/index.ts b/libs/dm/src/mapping/index.ts
index c8c5823994..46f9b3f8ec 100644
--- a/libs/dm/src/mapping/index.ts
+++ b/libs/dm/src/mapping/index.ts
@@ -11,6 +11,7 @@ export const tagColor = {
FF9999FF: 'pyro',
FFACFFFF: 'electro',
'99FF88FF': 'dendro',
+ '00FFFFFF': 'strong',
} as const
export type ColorTag = (typeof tagColor)[keyof typeof tagColor]
diff --git a/libs/gi-dm-localization/project.json b/libs/gi-dm-localization/project.json
index 06751511c0..3faeb0e7f8 100644
--- a/libs/gi-dm-localization/project.json
+++ b/libs/gi-dm-localization/project.json
@@ -8,7 +8,8 @@
"executor": "@genshin-optimizer/gi-dm-localization:gen-locale",
"outputs": ["{projectRoot}/assets/locales"]
},
- "lint": {}
+ "lint": {},
+ "test": {}
},
"tags": []
}
diff --git a/libs/gi-dm-localization/src/executors/gen-locale/lib/Data/sheet.ts b/libs/gi-dm-localization/src/executors/gen-locale/lib/Data/sheet.ts
index 3993e2fae6..86ddee7d88 100644
--- a/libs/gi-dm-localization/src/executors/gen-locale/lib/Data/sheet.ts
+++ b/libs/gi-dm-localization/src/executors/gen-locale/lib/Data/sheet.ts
@@ -105,5 +105,25 @@ const data = {
res: {},
misc: {},
},
+ // Constellation Lv. {{level}}
+ constellationLvl: [892900816, 'constellation'],
+ // Talent Lv. {{level}}
+ talentLvl: [1647967600, 'talent'],
+ talents: {
+ // Normal Attack
+ auto: 1171619685, // or 1653327868
+ // Elemental Skill
+ skill: 3477257188, // or 4260972229
+ // Elemental Burst
+ burst: 3250738285, // 2453877364 3626565793 3152729845
+ // Altenate Sprint
+ altSprint: [3378550992, 'altSprint'], // mona's desc
+ },
+ // Unlocks at Character Ascension Phase 1
+ unlockPassive1: [941237898, 'passive1'],
+ // Unlocks at Character Ascension Phase 4
+ unlockPassive2: [941237898, 'passive4'],
+ // Passive Talent
+ unlockPassive3: 2602723764,
} as const
export default data
diff --git a/libs/gi-dm-localization/src/executors/gen-locale/lib/parseUtil.ts b/libs/gi-dm-localization/src/executors/gen-locale/lib/parseUtil.ts
index 2eebd0cc20..5291624f6e 100644
--- a/libs/gi-dm-localization/src/executors/gen-locale/lib/parseUtil.ts
+++ b/libs/gi-dm-localization/src/executors/gen-locale/lib/parseUtil.ts
@@ -175,4 +175,30 @@ export const parsingFunctions: {
plungeLow: (lang, string) => plungeUtil(lang, string, true),
plungeHigh: (lang, string) => plungeUtil(lang, string, false),
string: (lang, string) => string,
+ constellation: (lang, string) => constellation(string),
+ talent: (lang, string) => talent(string),
+ altSprint: (lang, string) => altSprint(string),
+ passive1: (lang, string) => passive1(string),
+ passive4: (lang, string) => passive4(string),
+}
+
+export function constellation(string: string) {
+ return string.replace('{0}', '{{level}}')
+}
+
+export function talent(string: string) {
+ return string.replace('+{0}', '{{level}}')
+}
+
+export function altSprint(string: string) {
+ const re = new RegExp(/(.+)<\/strong>/)
+ const match = re.exec(string)
+ return match?.[1] ?? ''
+}
+
+export function passive1(string: string) {
+ return string.replace('{0}', '1')
+}
+export function passive4(string: string) {
+ return string.replace('{0}', '4')
}
From 95a03bc57594ccdb01d3406988f5c9f16341cc46 Mon Sep 17 00:00:00 2001
From: frzyc
Date: Fri, 29 Dec 2023 00:57:27 -0500
Subject: [PATCH 13/17] minor scanner changes (#1390)
---
apps/frontend/src/app/PageArtifact/ArtifactEditor.tsx | 2 +-
libs/gi-art-scanner/src/lib/processImg.ts | 10 +---------
2 files changed, 2 insertions(+), 10 deletions(-)
diff --git a/apps/frontend/src/app/PageArtifact/ArtifactEditor.tsx b/apps/frontend/src/app/PageArtifact/ArtifactEditor.tsx
index 006cd3d22e..6d69ed28cf 100644
--- a/apps/frontend/src/app/PageArtifact/ArtifactEditor.tsx
+++ b/apps/frontend/src/app/PageArtifact/ArtifactEditor.tsx
@@ -145,7 +145,7 @@ export default function ArtifactEditor({
disableSlot = false,
}: ArtifactEditorProps) {
const queueRef = useRef(
- new ScanningQueue(textsFromImage, process.env.NODE_ENV === 'development')
+ new ScanningQueue(textsFromImage, shouldShowDevComponents)
)
const queue = queueRef.current
const { t } = useTranslation('artifact')
diff --git a/libs/gi-art-scanner/src/lib/processImg.ts b/libs/gi-art-scanner/src/lib/processImg.ts
index 9d1411356f..f4ec2f9c03 100644
--- a/libs/gi-art-scanner/src/lib/processImg.ts
+++ b/libs/gi-art-scanner/src/lib/processImg.ts
@@ -271,15 +271,7 @@ export async function processEntry(
const [whiteTexts, substatTexts, artifactSetTexts, equippedTexts] =
await Promise.all([
// slotkey, mainStatValue, level
- textsFromImage(bwHeader, {
- // only the left half is worth scanning
- rectangle: {
- top: 0,
- left: 0,
- width: Math.floor(bwHeader.width * 0.7),
- height: bwHeader.height,
- },
- }),
+ textsFromImage(bwHeader),
// substats
textsFromImage(substatsCardCropped),
// artifact set, look for greenish texts
From 8376e9e2ebd7379c4a9226149fc5427173535433 Mon Sep 17 00:00:00 2001
From: frzyc
Date: Fri, 29 Dec 2023 00:58:31 -0500
Subject: [PATCH 14/17] more GO-next work (#1385)
* update to latest
* additional Character/weapon/artifact UI
* format
* update MUI cache provider
* update
* update WeaponCard
---
.gitignore | 3 +
.prettierignore | 3 +
...extjs-npm-5.15.0-74d10e1494-1bf462479b.zip | Bin 0 -> 218304 bytes
.../Components/Artifact/ArtifactTooltip.tsx | 32 +-
apps/gi-backend/src/app/graphql_gen.ts | 198 ++++++++++++-
apps/gi-backend/src/app/schema_gen.graphql | 198 ++++++++++++-
.../src/app/weapon/weapon.entity.ts | 18 +-
.../src/app/weapon/weapon.resolver.ts | 12 +-
apps/gi-frontend-next/next.config.js | 3 +
.../character/components/CharacterList.tsx | 31 +-
.../src/app/[locale]/character/page.tsx | 25 +-
.../components/Header/MobileHeader.tsx | 2 +-
.../[locale]/components/Header/TabsData.tsx | 22 +-
.../app/[locale]/components/Header/index.tsx | 4 +-
.../ThemeRegistry/EmotionCache.tsx | 99 -------
.../ThemeRegistry/ThemeRegistry.tsx | 11 +-
.../layoutWrappers/ThemeRegistry/theme.ts | 1 +
.../weapon/components/AddWeaponButton.tsx | 21 +-
apps/gi-frontend-next/src/auth/jwt.ts | 2 +-
.../src/components/FieldDisplay.tsx | 4 +-
.../src/client/Artifact/ArtifactCardPico.tsx | 69 +++++
.../client/Artifact/ArtifactCardPicoBlank.tsx | 33 +++
.../src/client/Artifact/ArtifactTooltip.tsx | 96 ++++++
libs/gi-ui-next/src/client/Artifact/index.ts | 2 +
libs/gi-ui-next/src/client/ArtifactCard.tsx | 5 +-
.../ArtifactEditor/ArtifactSlotDropdown.tsx | 4 +-
libs/gi-ui-next/src/client/CharacterCard.tsx | 278 ++++--------------
libs/gi-ui-next/src/client/CloseButton.tsx | 10 +-
.../src/client/GenshinUserDataWrapper.tsx | 5 +-
.../src/client/LocationAutocomplete.tsx | 4 +-
libs/gi-ui-next/src/client/LocationName.tsx | 4 +-
.../src/client/{ => Weapon}/WeaponCard.tsx | 132 +++++----
.../src/client/Weapon/WeaponCardPico.tsx | 125 ++++++++
.../src/client/Weapon/WeaponNameTooltip.tsx | 40 +++
libs/gi-ui-next/src/client/Weapon/index.ts | 2 +
libs/gi-ui-next/src/client/index.ts | 3 +-
libs/gi-ui/src/Artifact/IconStatDisplay.tsx | 36 +++
libs/gi-ui/src/Artifact/index.ts | 1 +
libs/gi-ui/src/Translate.tsx | 1 +
.../src/components/BootstrapTooltip.tsx | 1 +
.../src/components/Card/CardThemed.tsx | 1 +
libs/ui-common/src/components/ColorText.tsx | 1 +
.../src/components/DropdownButton.tsx | 7 +-
.../src/components/GeneralAutocomplete.tsx | 6 +-
.../ui-common/src/components/ModalWrapper.tsx | 1 +
libs/ui-common/src/components/SqBadge.tsx | 1 +
libs/ui-common/src/components/TextButton.tsx | 1 +
libs/ui-common/src/theme/index.ts | 1 +
package.json | 1 +
yarn.lock | 22 ++
50 files changed, 1099 insertions(+), 483 deletions(-)
create mode 100644 .yarn/cache/@mui-material-nextjs-npm-5.15.0-74d10e1494-1bf462479b.zip
delete mode 100644 apps/gi-frontend-next/src/app/[locale]/layoutWrappers/ThemeRegistry/EmotionCache.tsx
create mode 100644 libs/gi-ui-next/src/client/Artifact/ArtifactCardPico.tsx
create mode 100644 libs/gi-ui-next/src/client/Artifact/ArtifactCardPicoBlank.tsx
create mode 100644 libs/gi-ui-next/src/client/Artifact/ArtifactTooltip.tsx
create mode 100644 libs/gi-ui-next/src/client/Artifact/index.ts
rename libs/gi-ui-next/src/client/{ => Weapon}/WeaponCard.tsx (72%)
create mode 100644 libs/gi-ui-next/src/client/Weapon/WeaponCardPico.tsx
create mode 100644 libs/gi-ui-next/src/client/Weapon/WeaponNameTooltip.tsx
create mode 100644 libs/gi-ui-next/src/client/Weapon/index.ts
create mode 100644 libs/gi-ui/src/Artifact/IconStatDisplay.tsx
diff --git a/.gitignore b/.gitignore
index a5b75ccd53..f56e7fb896 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,3 +60,6 @@ Thumbs.db
# Next.js
.next
+
+# nx
+.nx/cache
diff --git a/.prettierignore b/.prettierignore
index 5f08e61f1f..2d093240bc 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -14,3 +14,6 @@
# generated from backend
apps/gi-backend/src/app/graphql_gen.ts
apps/gi-backend/src/app/schema_gen.graphql
+
+# nx
+/.nx/cache
diff --git a/.yarn/cache/@mui-material-nextjs-npm-5.15.0-74d10e1494-1bf462479b.zip b/.yarn/cache/@mui-material-nextjs-npm-5.15.0-74d10e1494-1bf462479b.zip
new file mode 100644
index 0000000000000000000000000000000000000000..0c57b50d745d00a214dfdaa92cdeae229b03ccba
GIT binary patch
literal 218304
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