Files
genshin-assistant/scripts/generate-genshin-data.cjs
T
2026-07-07 22:02:24 +02:00

340 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('node:fs');
const path = require('node:path');
const genshin = require('genshin-db');
const opts = { matchCategories: true, verboseCategories: true, resultLanguage: 'English', queryLanguages: ['English'] };
function asArray(value) {
return Array.isArray(value) ? value : [];
}
const characters = asArray(genshin.characters('names', opts))
.map((entry) => ({
id: entry.id,
name: entry.name,
rarity: entry.rarity,
element: entry.elementText,
weapon: entry.weaponText,
}))
.filter((entry) => entry.name)
.sort((a, b) => a.name.localeCompare(b.name));
const artifacts = asArray(genshin.artifacts('names', opts))
.map((entry) => ({
id: entry.id,
name: entry.name,
rarityList: entry.rarityList ?? [],
pieces: [entry.flower, entry.plume, entry.sands, entry.goblet, entry.circlet]
.filter(Boolean)
.map((piece) => ({ name: piece.name, relicType: piece.relicType })),
}))
.filter((entry) => entry.name)
.sort((a, b) => a.name.localeCompare(b.name));
const artifactPieces = artifacts
.flatMap((set) =>
set.pieces.map((piece) => ({
name: piece.name,
setName: set.name,
slot: slotFromRelicType(piece.relicType),
relicType: piece.relicType,
})),
)
.sort((a, b) => a.name.localeCompare(b.name));
const slotByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.slot]));
const setByPiece = Object.fromEntries(artifactPieces.map((piece) => [piece.name, piece.setName]));
const setToPieces = Object.fromEntries(
artifacts.map((set) => [set.name, artifactPieces.filter((piece) => piece.setName === set.name).map((piece) => piece.name)]),
);
const mainStats = [
'Elemental Mastery',
'Energy Recharge',
'CRIT Rate',
'CRIT DMG',
'Healing Bonus',
'ATK%',
'HP%',
'DEF%',
'ATK',
'HP',
'DEF',
'Hydro DMG Bonus',
'Pyro DMG Bonus',
'Electro DMG Bonus',
'Cryo DMG Bonus',
'Dendro DMG Bonus',
'Anemo DMG Bonus',
'Geo DMG Bonus',
'Physical DMG Bonus',
];
const substats = ['CRIT DMG', 'CRIT Rate', 'Energy Recharge', 'Elemental Mastery', 'ATK', 'ATK%', 'HP', 'HP%', 'DEF', 'DEF%'];
const mainStatsBySlot = {
'Flower of Life': ['HP'],
'Plume of Death': ['ATK'],
'Sands of Eon': ['HP%', 'ATK%', 'DEF%', 'Energy Recharge', 'Elemental Mastery'],
'Goblet of Eonothem': [
'HP%',
'ATK%',
'DEF%',
'Elemental Mastery',
'Hydro DMG Bonus',
'Pyro DMG Bonus',
'Electro DMG Bonus',
'Cryo DMG Bonus',
'Dendro DMG Bonus',
'Anemo DMG Bonus',
'Geo DMG Bonus',
'Physical DMG Bonus',
],
'Circlet of Logos': ['HP%', 'ATK%', 'DEF%', 'Elemental Mastery', 'CRIT Rate', 'CRIT DMG', 'Healing Bonus'],
};
const mainStatValueReferences = {
'Flower of Life': [{ stat: 'HP', base: 717, max: 4780 }],
'Plume of Death': [{ stat: 'ATK', base: 47, max: 311 }],
'Sands of Eon': [
{ stat: 'HP%', base: 7.0, max: 46.6 },
{ stat: 'ATK%', base: 7.0, max: 46.6 },
{ stat: 'DEF%', base: 8.7, max: 58.3 },
{ stat: 'Energy Recharge', base: 7.8, max: 51.8 },
{ stat: 'Elemental Mastery', base: 28, max: 187 },
],
'Goblet of Eonothem': [
{ stat: 'HP%', base: 7.0, max: 46.6 },
{ stat: 'ATK%', base: 7.0, max: 46.6 },
{ stat: 'DEF%', base: 8.7, max: 58.3 },
{ stat: 'Elemental Mastery', base: 28, max: 187 },
{ stat: 'Hydro DMG Bonus', base: 7.0, max: 46.6 },
{ stat: 'Pyro DMG Bonus', base: 7.0, max: 46.6 },
{ stat: 'Electro DMG Bonus', base: 7.0, max: 46.6 },
{ stat: 'Cryo DMG Bonus', base: 7.0, max: 46.6 },
{ stat: 'Dendro DMG Bonus', base: 7.0, max: 46.6 },
{ stat: 'Anemo DMG Bonus', base: 7.0, max: 46.6 },
{ stat: 'Geo DMG Bonus', base: 7.0, max: 46.6 },
{ stat: 'Physical DMG Bonus', base: 8.7, max: 58.3 },
],
'Circlet of Logos': [
{ stat: 'HP%', base: 7.0, max: 46.6 },
{ stat: 'ATK%', base: 7.0, max: 46.6 },
{ stat: 'DEF%', base: 8.7, max: 58.3 },
{ stat: 'Elemental Mastery', base: 28, max: 187 },
{ stat: 'CRIT Rate', base: 4.7, max: 31.1 },
{ stat: 'CRIT DMG', base: 9.3, max: 62.2 },
{ stat: 'Healing Bonus', base: 5.4, max: 35.9 },
],
};
const data = {
schemaVersion: 2,
generatedAt: new Date().toISOString(),
source: {
package: 'genshin-db',
version: require('genshin-db/package.json').version,
resultLanguage: opts.resultLanguage,
},
sourceVersions: {
genshinDb: require('genshin-db/package.json').version,
resultLanguage: opts.resultLanguage,
},
sourceVersion: `genshin-db@${require('genshin-db/package.json').version}`,
characters,
artifactSets: artifacts,
artifactPieces,
slotByPiece,
setByPiece,
slots: ['Flower of Life', 'Plume of Death', 'Sands of Eon', 'Goblet of Eonothem', 'Circlet of Logos'],
mainStats,
mainStatsBySlot,
mainStatValueReferences,
substats,
stats: {
main: mainStats,
mainBySlot: mainStatsBySlot,
sub: substats,
},
aliases: {
stats: {
'Crit Damage': 'CRIT DMG',
'Critical Damage': 'CRIT DMG',
'Crit Rate': 'CRIT Rate',
'Critical Rate': 'CRIT Rate',
'Energy Recharge %': 'Energy Recharge',
'Elemental Master': 'Elemental Mastery',
'Elemental Masterie': 'Elemental Mastery',
'Heal Bonus': 'Healing Bonus',
},
textReplacements: {
'CIT DMG': 'CRIT DMG',
'CRIT DMG+I': 'CRIT DMG+1',
'CRIT Rate+Z': 'CRIT Rate+2',
'Energv Recharge': 'Energy Recharge',
'Elemental Masterv': 'Elemental Mastery',
'Equipped;': 'Equipped:',
},
slotAliases: {
'Sands of Eon Vi': 'Sands of Eon',
'Sands of Eon V': 'Sands of Eon',
'Sands of Eon 2': 'Sands of Eon',
'Flower of Life 2': 'Flower of Life',
'Flower of Lif': 'Flower of Life',
'Goblet of Eonotherm': 'Goblet of Eonothem',
'Goblet of Eonothemn': 'Goblet of Eonothem',
'Circlet of Logas': 'Circlet of Logos',
'Circlet of Loges': 'Circlet of Logos',
},
setAliases: {
'Viridescent Venere': 'Viridescent Venerer',
'Maiden Beloved:': 'Maiden Beloved',
'Gladiators Finale': "Gladiator's Finale",
},
pieceAliases: {
'A Note in Springs Leich': "A Note in Spring's Leich",
'Viridescent Vencrers Vessel': "Viridescent Venerer's Vessel",
'Holy Crown of the Believer ': 'Holy Crown of the Believer',
},
characterAliases: {
'Citlall': 'Citlali',
'Sandrone ': 'Sandrone',
'Qiqi ': 'Qiqi',
},
},
lookup: {
normalizedKeys: {
sets: normalizedMap(artifacts.map((set) => set.name)),
pieces: normalizedMap(artifactPieces.map((piece) => piece.name)),
slots: normalizedMap(['Flower of Life', 'Plume of Death', 'Sands of Eon', 'Goblet of Eonothem', 'Circlet of Logos']),
stats: normalizedMap([...mainStats, ...substats]),
characters: normalizedMap(characters.map((character) => character.name)),
},
goodKeys: {
sets: Object.fromEntries(artifacts.map((set) => [set.name, goodKey(set.name)])),
pieces: Object.fromEntries(artifactPieces.map((piece) => [piece.name, goodKey(piece.name)])),
stats: Object.fromEntries([...mainStats, ...substats].map((stat) => [stat, goodStatKey(stat)])),
characters: Object.fromEntries(characters.map((character) => [character.name, goodKey(character.name)])),
},
setToPieces,
validation: validateLookupPackage({ artifacts, artifactPieces, characters, mainStats, substats, setToPieces }),
},
uiProfiles: {
artifactDetailEn: {
language: 'English',
supportedResolutions: ['1920x1080', '2560x1440', '3840x2160'],
detailPanel: { x: 0.5, y: 0.05, width: 0.45, height: 0.9 },
note: 'Relative profile used as scanner contract; runtime crops may tune offsets from review samples.',
},
},
};
const outPath = path.join(process.cwd(), 'src', 'data', 'genshinGameData.json');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, JSON.stringify(data, null, 2) + '\n');
console.log(`generated ${outPath}`);
console.log(`${characters.length} characters, ${artifacts.length} artifact sets`);
console.log(`${artifactPieces.length} artifact pieces, ${mainStats.length} main stats, ${substats.length} substats`);
function slotFromRelicType(relicType) {
switch (relicType) {
case 'EQUIP_BRACER':
return 'Flower of Life';
case 'EQUIP_NECKLACE':
return 'Plume of Death';
case 'EQUIP_SHOES':
return 'Sands of Eon';
case 'EQUIP_RING':
return 'Goblet of Eonothem';
case 'EQUIP_DRESS':
return 'Circlet of Logos';
default:
return '';
}
}
function normalizeLookupKey(value) {
return String(value)
.toLowerCase()
.normalize('NFKD')
.replace(/[']/g, '')
.replace(/[^a-z0-9]+/g, '');
}
function normalizedMap(values) {
return Object.fromEntries(values.filter(Boolean).map((value) => [normalizeLookupKey(value), value]));
}
function goodKey(value) {
return String(value)
.replace(/[']/g, '')
.replace(/[^A-Za-z0-9]+(.)/g, (_match, next) => String(next).toUpperCase())
.replace(/^[a-z]/, (first) => first.toUpperCase())
.replace(/[^A-Za-z0-9]/g, '');
}
function goodStatKey(stat) {
switch (stat) {
case 'HP':
return 'hp';
case 'HP%':
return 'hp_';
case 'ATK':
return 'atk';
case 'ATK%':
return 'atk_';
case 'DEF':
return 'def';
case 'DEF%':
return 'def_';
case 'Elemental Mastery':
return 'eleMas';
case 'Energy Recharge':
return 'enerRech_';
case 'CRIT Rate':
return 'critRate_';
case 'CRIT DMG':
return 'critDMG_';
case 'Healing Bonus':
return 'heal_';
case 'Physical DMG Bonus':
return 'physical_dmg_';
default:
return stat.toLowerCase().replace(' dmg bonus', '_dmg_').replace(/\s+/g, '');
}
}
function validateLookupPackage({ artifacts, artifactPieces, characters, mainStats, substats, setToPieces }) {
const errors = [];
const warnings = [];
const setNames = new Set(artifacts.map((set) => set.name));
const slotNames = new Set(['Flower of Life', 'Plume of Death', 'Sands of Eon', 'Goblet of Eonothem', 'Circlet of Logos']);
const goodSetKeys = new Set();
for (const set of artifacts) {
const key = goodKey(set.name);
if (goodSetKeys.has(key)) errors.push(`Duplicate GOOD set key: ${key}`);
goodSetKeys.add(key);
if ((setToPieces[set.name] ?? []).length === 0) warnings.push(`Set has no pieces: ${set.name}`);
}
for (const piece of artifactPieces) {
if (!setNames.has(piece.setName)) errors.push(`Piece ${piece.name} references missing set ${piece.setName}`);
if (!slotNames.has(piece.slot)) errors.push(`Piece ${piece.name} references missing slot ${piece.slot}`);
}
if (!characters.length) warnings.push('No characters generated.');
if (!mainStats.length || !substats.length) errors.push('Stats were not generated.');
return {
valid: errors.length === 0,
errors,
warnings,
summary: {
artifactSets: artifacts.length,
artifactPieces: artifactPieces.length,
characters: characters.length,
stats: mainStats.length + substats.length,
},
};
}