340 lines
11 KiB
JavaScript
340 lines
11 KiB
JavaScript
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,
|
||
},
|
||
};
|
||
}
|