diablo2-web/scripts/build-skill-catalog.ts

604 lines
26 KiB
TypeScript
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.

/**
* Generates src/data/skills-catalog.ts with full Blizzard 1.13c ground-truth
* skill properties, English and Chinese localized descriptions, 5-band damage progression,
* mana formulas, and synergy networks.
*/
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { decodeTbl } from '../src/formats/tbl.ts'
import { loadActTables } from '../src/game/acts.ts'
import { CANONICAL_SKILL_TREE } from '../src/game/skill-tree.ts'
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const CLASS_NAMES_ZH: Record<string, string> = {
ama: '亚马逊',
sor: '法师',
nec: '死灵法师',
pal: '圣骑士',
bar: '野蛮人',
dru: '德鲁伊',
ass: '刺客',
}
const TAB_NAMES_ZH: Record<string, readonly string[]> = {
ama: ['弓与弩技能', '被动与魔法技能', '标枪与长矛技能'],
sor: ['火焰法术', '闪电法术', '冰冷法术'],
nec: ['诅咒', '毒素与白骨法术', '召唤法术'],
pal: ['作战技能', '攻击灵气', '防御灵气'],
bar: ['战斗技能', '战斗专家', '呐喊'],
dru: ['召唤技能', '变形技能', '元素技能'],
ass: ['陷阱', '暗影武艺', '武学艺术'],
}
const EXPANSION_SKILL_NAMES_ZH: Record<number, string> = {
// Druid
221: '乌鸦', 222: '毒藤', 223: '狼人变化', 224: '变形术', 225: '火风暴',
226: '橡木智者', 227: '召唤恶狼', 228: '熊人变化', 229: '熔岩巨石', 230: '极地风暴',
231: '食尸藤', 232: '狂犬病', 233: '太阳藤', 234: '裂地', 235: '旋风甲',
236: '狼獾之心', 237: '召唤狂狼', 238: '狂怒', 239: '撞击', 240: '龙卷风',
241: '枯萎藤', 242: '火爪', 243: '震波', 244: '火山', 245: '暴风',
246: '灵之守护', 247: '召唤灰熊', 248: '狂怒', 249: '末日浩劫', 250: '毁天灭地',
// Assassin
251: '火爆', 252: '爪支配', 253: '心灵震爆', 254: '虎击', 255: '龙爪',
256: '电能网', 257: '刃之小刀', 258: '加速', 259: '双手挥舞', 260: '眼镜蛇打击',
261: '闪电手雷', 262: '火之复生', 263: '武器格挡', 264: '魔影斗篷', 265: '飞龙在天',
266: '雷电爪', 267: '消解', 268: '狂怒刃', 269: '复仇之火', 270: '影分身',
271: '寒冰刃', 272: '龙尾', 273: '雷光守卫', 274: '火焰复苏', 275: '刃之护盾',
276: '毒牙', 277: '暗影大师', 278: '飞龙升天', 279: '死亡守卫', 280: '凤凰攻击',
}
const EXPANSION_SKILL_DESC_ZH: Record<number, { short: string; long: string }> = {
221: { short: '召唤乌鸦啄瞎敌人', long: '召唤乌鸦啄击敌人眼睛,降低其视野' },
222: { short: '召唤毒藤在地下散播毒素', long: '毒藤在地下穿行,让接触的敌人中毒' },
223: { short: '变身为狼人形态', long: '提升攻击速度、准确率和移动速度' },
224: { short: '提升变身形态的生命与持续时间', long: '被动提升狼人和熊人形态的最大生命值和变形持续时间' },
225: { short: '向前方喷射混乱的地表烈火', long: '在地面释放多道混乱游走的烈焰裂隙' },
226: { short: '召唤提升全队最大生命值的自然之灵', long: '光环增加附近所有队友的最大生命值' },
227: { short: '召唤恶狼协助战斗', long: '召唤狼群撕咬敌人,提升自身命中' },
228: { short: '变身为巨熊形态', long: '大幅提升防御力、生命值和打击力量' },
229: { short: '发射滚动的熔岩火球击退敌人', long: '火石沿直线滚向敌人,造成物理和火焰双重爆炸伤害' },
230: { short: '引导一道连续的严寒冻气', long: '持续喷射极地寒冰冻结并减速前方敌人' },
231: { short: '召唤食尸藤吞噬尸体回复生命', long: '在地下吞噬倒下的怪物尸体,为德鲁伊回复生命' },
232: { short: '狼人撕咬敌人传播剧毒传染病', long: '撕咬使目标感染狂犬病毒,毒素会快速传染给周围怪物' },
233: { short: '召唤太阳藤吞噬尸体回复法力', long: '吞噬死去的怪物尸体,为德鲁伊持续回复法力值' },
234: { short: '撕裂大地喷发地热蒸汽与熔火', long: '在地面裂隙中喷发出炽热地火' },
235: { short: '召唤环绕身躯的旋风护盾吸收元素伤害', long: '吸收即将到来的火焰、冰冷和闪电元素伤害' },
236: { short: '召唤提升全队物理伤害与准确率的灵体', long: '狼獾图腾提升周围所有同伴的物理伤害与攻击命中' },
237: { short: '召唤更加凶残狂暴的狂狼', long: '狂狼可以吃下死者尸体进入狂乱嗜血状态' },
238: { short: '狼人连续疯狂撕咬周围的所有敌人', long: '对周围复数敌人发动疾风骤雨般的连续强力撕咬' },
239: { short: '熊人沉重重击击晕并重创敌人', long: '熊掌沉重拍击地面,将敌人震晕并击退' },
240: { short: '释放多道不可预测的毁灭龙卷风', long: '向前方释放穿透撕裂敌人的强力物理龙卷风' },
241: { short: '召唤藤蔓缠绕吸收毒素', long: '从敌人体内吸取并中和毒素' },
242: { short: '变身形态下的烈焰利爪攻击', long: '在狼人或熊人形态下发动附加高额火焰伤害的利爪攻击' },
243: { short: '熊人咆哮震出大范围冲击波击晕敌人', long: '熊吼发出锥形震波,大范围击晕所有前方敌人' },
244: { short: '在地面引发微型火山喷发火球', long: '在指定位置升起火山,持续喷吐炽热石块砸向周围敌人' },
245: { short: '在身周召唤严寒暴风席卷敌人', long: '环绕德鲁伊旋转的极寒风暴,持续对近身敌人造成冰冷伤害' },
246: { short: '召唤带有荆棘反伤效果的自然之灵', long: '反弹受到的物理伤害给近战攻击者' },
247: { short: '召唤威猛无比的森林灰熊', long: '召唤强大的巨熊,具备极高的生命值与重击压制力' },
248: { short: '狼人狂暴撕咬', long: '连续撕咬目标' },
249: { short: '天降冰霜陨石撕碎战场', long: '从天空降下极寒冰彗星轰炸全场' },
250: { short: '天降烈火流星轰炸战场', long: '持续在德鲁伊身边下落炽热陨石与熔岩碎片' },
251: { short: '投掷小型火焰爆炸炸弹', long: '投掷可以弹跳并爆炸的手雷,造成小范围火焰伤害' },
252: { short: '提升爪类武器的伤害、命中与暴击', long: '被动提升使用爪类武器时的战斗效能' },
253: { short: '释放精神冲击击退并转换敌人', long: '震慑目标心智,使其陷入昏迷甚至暂时为你作战' },
254: { short: '武术聚气:积聚毁灭性的物理近战能量', long: '最多积聚3段气劲,释放时造成毁天灭地的巨大物理爆发' },
255: { short: '连续飞腿踢击单个目标', long: '随着技能等级提高,发动2次至5次超快速的连续旋风踢' },
256: { short: '在地面投下一道电能火花陷阱网', long: '电能网在地面停留,对踩上的敌人造成持续电击' },
257: { short: '掷出飞刀穿透沿途敌人', long: '投射旋转利刃切入敌群' },
258: { short: '短时间内大幅提升攻击与移动速度', long: '进入加速状态,脚步如飞,出招极快' },
259: { short: '双持爪具同时挥击', long: '双手爪具同时砍向敌人' },
260: { short: '武术聚气:吸取敌人的生命与法力', long: '3段聚气分别吸取生命、法力,以及两者兼顾' },
261: { short: '投掷充能弹陷阱手雷', long: '落地后释放大量蜿蜒前行的充能电火花' },
262: { short: '埋设火焰浪潮喷射陷阱', long: '当敌人接近时自动向前方喷射烈焰波浪' },
263: { short: '双持爪类武器时格挡敌人的攻击', long: '甚至可以格挡法术飞弹与远程攻击' },
264: { short: '唤来暗影帷幕降低敌人视野与防御', long: '使全场陷入致盲暗影,极大降低怪物的防御力' },
265: { short: '瞬间传送到目标身旁发动强力重踢', long: '跨越障碍瞬移至敌人面前施展终结飞踢' },
266: { short: '武术聚气:积聚连锁闪电能量', long: '3段聚气释放电球、充能弹以及连锁闪电' },
267: { short: '提高所有元素抗性与物理伤害减免', long: '身躯半虚无化,减少受到的诅咒持续时间和所有伤害' },
268: { short: '向周围四方狂暴发射飞舞的利刃', long: '引导飞刀风暴绞杀四周一切敌人' },
269: { short: '释放持续追踪燃烧的火焰之墙', long: '在地面制造一道燃烧的火墙' },
270: { short: '召唤刺客的暗影斗士同伴', long: '暗影斗士会模仿刺客当前快捷栏上的技能进行战斗' },
271: { short: '武术聚气:积聚严寒冰霜风暴', long: '3段聚气分别冻结单体、冻结周围以及引发冰爆' },
272: { short: '终结飞踢引发巨大烈焰冲击波', long: '释放聚气造成巨额火焰爆破并击退所有近身敌人' },
273: { short: '安放强大的雷光守卫陷阱', long: '刺客核心陷阱,持续发射高伤害的穿透穿刺穿透雷光束' },
274: { short: '安放火焰复苏地雷陷阱', long: '持续向周围四方喷射火焰新星' },
275: { short: '召唤围绕身旁的利刃旋涡护甲', long: '环绕身躯的飞刀切碎任何企图近身的敌人' },
276: { short: '给爪具和武器涂抹致命剧毒', long: '短时间内使所有武器攻击附带极高毒素伤害' },
277: { short: '召唤拥有全部刺客技能的高阶暗影大师', long: '暗影大师具备极强的抗性与AI,能自主施展刺客全套技能' },
278: { short: '跳跃至空中施展从天而降的终结重踏', long: '跃向半空踢翻目标并释放积聚的武学能量' },
279: { short: '安放引爆附近死尸的死亡守卫陷阱', long: '引爆死去的怪物尸体造成尸爆,并能发射闪电' },
280: { short: '武术终极聚气:召唤凤凰的烈焰、闪电与冰霜', long: '1段聚气陨石,2段聚气连锁闪电,3段聚气极寒冰晶' },
}
interface SynergyParsed {
providerName: string
providerId: number
percentPerLevel: number
type: 'damage' | 'elemental' | 'duration'
}
const EXPANSION_EXTRA_STRINGS_ZH: Record<string, string> = {
ESkillHawk: '乌鸦:',
ESkillWolves: '狼:',
EskillLifeSteal: '生命偷取:',
EskillManaSteal: '法力偷取:',
Eskillpowerup1: '聚气 1 段 -',
Eskillpowerup2: '聚气 2 段 -',
Eskillpowerup3: '聚气 3 段 -',
Eskillpuddgmper: '% 伤害',
Eskillkickdamage: '踢击伤害:',
EskillKickSing: '踢',
EskillKickPlur: '踢',
ESkillSpikes: '地刺:',
Eskillfistsoffire1: '火焰伤害:',
Eskillfistsoffire2: '火焰伤害范围:',
ESkillShoots: '发射',
ESkillTimes: '次',
Eskillpudmana: '% 生命与法力偷取',
Eskillpudlife: '% 生命偷取',
EskillPerBlade: '每枚利刃',
Eskillthunder1: '闪电伤害:',
Eskillthunder2: '闪电新星伤害:',
Eskillthunder3: '充能弹伤害:',
Eskillbladesofice1: '冰冷伤害:',
Eskillbladesofice2: '冰冷伤害范围:',
Eskillbladesofice3: '冻结时间:',
Eskillphoenix1: '陨石伤害:',
Eskillphoenix2: '连锁闪电伤害:',
Eskillphoenix3: '混沌冰球伤害:',
}
function parseSynergyFormula(formula: string, params: Record<string, number>, nameToId: Map<string, number>): SynergyParsed[] {
if (!formula || typeof formula !== 'string') return []
const result: SynergyParsed[] = []
// Pattern: skill('Skill Name'.blvl) * par8 or (skill('A'.blvl) + skill('B'.blvl)) * par8
// Or: skill('A'.blvl)*par8 + skill('B'.blvl)*par7
const re = /skill\(['"]([^'"]+)['"]\.blvl\)(?:\s*\*\s*([a-zA-Z0-9_]+))?/g
let match: RegExpExecArray | null
while ((match = re.exec(formula)) !== null) {
const providerName = match[1]!
const paramKey = match[2] || 'par8'
let percent = 0
if (/^\d+$/.test(paramKey)) {
percent = Number(paramKey)
} else {
percent = params[paramKey] ?? 0
}
const providerId = nameToId.get(providerName.toLowerCase()) ?? 0
if (providerId > 0) {
result.push({
providerName,
providerId,
percentPerLevel: percent,
type: 'elemental',
})
}
}
// Also check if formula had form `(skill('A'.blvl)+skill('B'.blvl))*parX`
if (result.length === 0) {
const outerMatch = /\(([^)]+)\)\s*\*\s*([a-zA-Z0-9_]+)/.exec(formula)
if (outerMatch) {
const inner = outerMatch[1]!
const paramKey = outerMatch[2]!
const percent = /^\d+$/.test(paramKey) ? Number(paramKey) : (params[paramKey] ?? 0)
const innerRe = /skill\(['"]([^'"]+)['"]\.blvl\)/g
let m: RegExpExecArray | null
while ((m = innerRe.exec(inner)) !== null) {
const providerName = m[1]!
const providerId = nameToId.get(providerName.toLowerCase()) ?? 0
if (providerId > 0) {
result.push({
providerName,
providerId,
percentPerLevel: percent,
type: 'elemental',
})
}
}
}
}
return result
}
async function main() {
console.log('Mounting MPQ archives...')
const archives = new MountedArchives()
for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
archives.add(m, await MpqArchive.open(await fileSource(`samples/d2/${m}`)))
}
const tables = await loadActTables(archives)
const skillsTable = tables.skills!
const skillDescTable = tables.skilldesc!
const monstatsTable = tables.monstats!
const engTbl = decodeTbl(await archives.read('data\\local\\LNG\\ENG\\string.tbl'))
const engExpTbl = decodeTbl(await archives.read('data\\local\\LNG\\ENG\\expansionstring.tbl'))
const engPatchTbl = decodeTbl(await archives.read('data\\local\\LNG\\ENG\\patchstring.tbl'))
const chiTbl = decodeTbl(await archives.read('data\\local\\LNG\\CHI\\string.tbl'), 'utf-8')
const chiPatchTbl = decodeTbl(await archives.read('data\\local\\LNG\\CHI\\patchstring.tbl'), 'utf-8')
function getStrings(key: string): { en: string; zh: string } {
if (!key) return { en: '', zh: '' }
const en = engPatchTbl.dict.get(key) || engExpTbl.dict.get(key) || engTbl.dict.get(key) || key
const zh = EXPANSION_EXTRA_STRINGS_ZH[key] || chiPatchTbl.dict.get(key) || chiTbl.dict.get(key) || en
return { en: en.trim(), zh: zh.trim() }
}
// Load scratch/skills_extracted.json
const extractedRaw = JSON.parse(readFileSync('scratch/skills_extracted.json', 'utf8'))
const extractedById = new Map<number, any>()
for (const item of extractedRaw) {
extractedById.set(item.id, item)
}
// Build name to ID map
const nameToId = new Map<string, number>()
for (const node of CANONICAL_SKILL_TREE) {
nameToId.set(node.name.toLowerCase(), node.id)
}
const catalog: any[] = []
const skillsById: Record<number, any> = {}
for (const node of CANONICAL_SKILL_TREE) {
const id = node.id
const ext = extractedById.get(id) || {}
const cls = node.classCode
const tabIndex = node.tabIndex
const tabNameEn = node.tabName
const tabNameZh = TAB_NAMES_ZH[cls]?.[tabIndex] || tabNameEn
// Look up SkillDesc row
const skillRow = skillsTable.rows.find(r => Number(r[1]) === id)
const descName = skillRow ? skillRow[3] : ''
const descRow = skillDescTable.rows.find(d => d[0]?.toLowerCase() === descName?.toLowerCase())
const nameKey = descRow ? descRow[7] : ''
const shortKey = descRow ? descRow[8] : ''
const longKey = descRow ? descRow[9] : ''
const nameObj = getStrings(nameKey)
let nameEn = nameObj.en || node.name
let nameZh = nameObj.zh || nameEn
if (EXPANSION_SKILL_NAMES_ZH[id]) {
nameZh = EXPANSION_SKILL_NAMES_ZH[id]!
}
const shortObj = getStrings(shortKey)
let shortEn = shortObj.en
let shortZh = shortObj.zh
if (EXPANSION_SKILL_DESC_ZH[id]) {
shortZh = EXPANSION_SKILL_DESC_ZH[id]!.short
}
const longObj = getStrings(longKey)
let longEn = longObj.en
let longZh = longObj.zh
if (EXPANSION_SKILL_DESC_ZH[id]) {
longZh = EXPANSION_SKILL_DESC_ZH[id]!.long
}
// Combine params
const params: Record<string, number> = {}
if (node.params) {
for (const [k, v] of Object.entries(node.params)) {
if (typeof v === 'number') params[k] = v
}
}
if (ext.Param && Array.isArray(ext.Param)) {
ext.Param.forEach((val: any, idx: number) => {
const num = Number(val)
if (!isNaN(num) && num !== 0) {
params[`par${idx + 1}`] = num
}
})
}
// Parse synergies received
const synList: SynergyParsed[] = []
if (node.synergyFormulas.elemental) {
synList.push(...parseSynergyFormula(node.synergyFormulas.elemental, params, nameToId))
}
if (node.synergyFormulas.physical) {
synList.push(...parseSynergyFormula(node.synergyFormulas.physical, params, nameToId))
}
if (node.synergyFormulas.duration) {
synList.push(...parseSynergyFormula(node.synergyFormulas.duration, params, nameToId))
}
// Determine masteries that apply
const masteries: number[] = []
if (cls === 'sor') {
if (ext.EType === 'fire' && id !== 61) masteries.push(61) // Fire Mastery
if (ext.EType === 'ltng' && id !== 63) masteries.push(63) // Lightning Mastery
if (ext.EType === 'cold' && id !== 65) masteries.push(65) // Cold Mastery
} else if (cls === 'pal') {
if (id === 112) masteries.push(113) // Concentration applies to Blessed Hammer
} else if (cls === 'ass') {
if (['Tiger Strike', 'Dragon Talon', 'Fists of Fire', 'Dragon Claw', 'Cobra Strike', 'Claws of Thunder', 'Dragon Tail', 'Blades of Ice', 'Dragon Flight'].includes(node.name)) {
masteries.push(252) // Claw Mastery
}
}
// Extract descdam
const descdam = descRow ? (descRow[skillDescTable.header.indexOf('descdam')] || '') : ''
// Extract descLines (descline1..6)
const descLines: any[] = []
if (descRow) {
for (let i = 1; i <= 6; i++) {
const lStr = descRow[skillDescTable.header.indexOf(`descline${i}`)]
const lNum = Number(lStr)
if (lNum && lNum > 0 && lNum !== 1) {
const textaKey = descRow[skillDescTable.header.indexOf(`desctexta${i}`)] || ''
const textbKey = descRow[skillDescTable.header.indexOf(`desctextb${i}`)] || ''
const calca = descRow[skillDescTable.header.indexOf(`desccalca${i}`)] || ''
const calcb = descRow[skillDescTable.header.indexOf(`desccalcb${i}`)] || ''
const aObj = getStrings(textaKey)
const bObj = getStrings(textbKey)
descLines.push({
lineType: lNum,
textaKey: textaKey || undefined,
textbKey: textbKey || undefined,
textaEn: aObj.en || undefined,
textaZh: aObj.zh || undefined,
textbEn: bObj.en || undefined,
textbZh: bObj.zh || undefined,
calca: calca || undefined,
calcb: calcb || undefined,
})
}
}
}
// Extract dsc2Lines (dsc2line1..4)
const dsc2Lines: any[] = []
if (descRow) {
for (let i = 1; i <= 4; i++) {
const lStr = descRow[skillDescTable.header.indexOf(`dsc2line${i}`)]
const lNum = Number(lStr)
if (lNum && lNum > 0 && lNum !== 1) {
const textaKey = descRow[skillDescTable.header.indexOf(`dsc2texta${i}`)] || ''
const textbKey = descRow[skillDescTable.header.indexOf(`dsc2textb${i}`)] || ''
const calca = descRow[skillDescTable.header.indexOf(`dsc2calca${i}`)] || ''
const calcb = descRow[skillDescTable.header.indexOf(`dsc2calcb${i}`)] || ''
const aObj = getStrings(textaKey)
const bObj = getStrings(textbKey)
dsc2Lines.push({
lineType: lNum,
textaKey: textaKey || undefined,
textbKey: textbKey || undefined,
textaEn: aObj.en || undefined,
textaZh: aObj.zh || undefined,
textbEn: bObj.en || undefined,
textbZh: bObj.zh || undefined,
calca: calca || undefined,
calcb: calcb || undefined,
})
}
}
}
// Check minion summoning stats from MonStats.txt
const summonCode = skillRow ? skillRow[skillsTable.header.indexOf('summon')] : ''
let minionStats: any = undefined
if (summonCode && summonCode !== '') {
const mRow = monstatsTable.rows.find(r => r[0]?.toLowerCase() === summonCode.toLowerCase())
if (mRow) {
minionStats = {
code: summonCode,
minHP: Number(mRow[monstatsTable.header.indexOf('minHP')]) || 0,
maxHP: Number(mRow[monstatsTable.header.indexOf('maxHP')]) || 0,
minDam: Number(mRow[monstatsTable.header.indexOf('A1MinD')]) || 0,
maxDam: Number(mRow[monstatsTable.header.indexOf('A1MaxD')]) || 0,
}
}
}
const entry = {
id,
name: nameEn,
nameZh,
classCode: cls,
classNameZh: CLASS_NAMES_ZH[cls],
tabIndex,
tabName: tabNameEn,
tabNameZh,
row: node.row,
col: node.col,
reqlevel: node.reqlevel,
reqskills: node.reqskills,
dependents: node.dependents,
// Mana
mana: ext.mana ?? 0,
lvlmana: ext.lvlmana ?? 0,
minmana: ext.minmana ?? 0,
manashift: ext.manashift ?? 8,
delay: ext.delay ?? 0,
// Damage & mechanics
descdam,
hitshift: ext.HitShift ?? 8,
srcDamage: ext.SrcDam ?? 0,
minDam: ext.MinDam ?? 0,
maxDam: ext.MaxDam ?? 0,
minLevDam: ext.MinLevDam ?? [0, 0, 0, 0, 0],
maxLevDam: ext.MaxLevDam ?? [0, 0, 0, 0, 0],
eType: ext.EType ?? '',
eMin: ext.EMin ?? 0,
eMax: ext.EMax ?? 0,
eMinLev: ext.EMinLev ?? [0, 0, 0, 0, 0],
eMaxLev: ext.EMaxLev ?? [0, 0, 0, 0, 0],
eLen: ext.ELen ?? 0,
eLevLen: ext.ELevLen ?? [0, 0, 0],
params,
synergyFormulas: node.synergyFormulas,
synergiesReceived: synList,
synergiesGiven: [] as any[],
masteries,
descLines,
dsc2Lines,
minionStats,
shortDesc: shortEn,
shortDescZh: shortZh,
longDesc: longEn,
longDescZh: longZh,
icon: `/skills/icon_${id}.png`,
iconActive: `/skills/icon_${id}_active.png`,
}
catalog.push(entry)
skillsById[id] = entry
}
// Populate synergies given
for (const node of catalog) {
for (const syn of node.synergiesReceived) {
if (skillsById[syn.providerId]) {
skillsById[syn.providerId].synergiesGiven.push({
targetId: node.id,
targetName: node.name,
targetNameZh: node.nameZh,
percentPerLevel: syn.percentPerLevel,
})
}
}
}
console.log(`Successfully compiled ${catalog.length} skills with synergy networks! Writing file...`)
const tsContent = `// Auto-generated Diablo II 1.13c 210-Skill Catalog
// Ground truth parsed from Skills.txt, SkillDesc.txt, string.tbl, and MPQs.
import type { CharacterClassCode } from '../game/classes.ts'
export interface SynergyBonusInfo {
readonly providerId: number
readonly providerName: string
readonly percentPerLevel: number
readonly type: 'damage' | 'elemental' | 'duration'
}
export interface SynergyGivenInfo {
readonly targetId: number
readonly targetName: string
readonly targetNameZh: string
readonly percentPerLevel: number
}
export interface DescLineDefinition {
readonly lineType: number
readonly textaKey?: string | undefined
readonly textbKey?: string | undefined
readonly textaEn?: string | undefined
readonly textaZh?: string | undefined
readonly textbEn?: string | undefined
readonly textbZh?: string | undefined
readonly calca?: string | undefined
readonly calcb?: string | undefined
}
export interface MinionBaseStats {
readonly code: string
readonly minHP: number
readonly maxHP: number
readonly minDam: number
readonly maxDam: number
}
export interface SkillCatalogEntry {
readonly id: number
readonly name: string
readonly nameZh: string
readonly classCode: CharacterClassCode
readonly classNameZh: string
readonly tabIndex: number
readonly tabName: string
readonly tabNameZh: string
readonly row: number
readonly col: number
readonly reqlevel: number
readonly reqskills: readonly number[]
readonly dependents: readonly number[]
readonly mana: number
readonly lvlmana: number
readonly minmana: number
readonly manashift: number
readonly delay: number
readonly descdam?: string | undefined
readonly hitshift: number
readonly srcDamage: number
readonly minDam: number
readonly maxDam: number
readonly minLevDam: readonly number[]
readonly maxLevDam: readonly number[]
readonly eType: string
readonly eMin: number
readonly eMax: number
readonly eMinLev: readonly number[]
readonly eMaxLev: readonly number[]
readonly eLen: number
readonly eLevLen: readonly number[]
readonly params: Readonly<Record<string, number>>
readonly synergyFormulas: {
readonly physical?: string | undefined
readonly elemental?: string | undefined
readonly duration?: string | undefined
}
readonly synergiesReceived: readonly SynergyBonusInfo[]
readonly synergiesGiven: readonly SynergyGivenInfo[]
readonly masteries: readonly number[]
readonly descLines?: readonly DescLineDefinition[] | undefined
readonly dsc2Lines?: readonly DescLineDefinition[] | undefined
readonly minionStats?: MinionBaseStats | undefined
readonly shortDesc: string
readonly shortDescZh: string
readonly longDesc: string
readonly longDescZh: string
readonly icon: string
readonly iconActive: string
}
export const SKILLS_CATALOG: readonly SkillCatalogEntry[] = Object.freeze(${JSON.stringify(catalog, null, 2)})
export const SKILLS_BY_ID: Readonly<Record<number, SkillCatalogEntry>> = Object.freeze(
SKILLS_CATALOG.reduce<Record<number, SkillCatalogEntry>>((acc, cur) => {
acc[cur.id] = cur
return acc
}, {})
)
export const SKILLS_BY_CLASS: Readonly<Record<CharacterClassCode, readonly SkillCatalogEntry[]>> = Object.freeze({
ama: SKILLS_CATALOG.filter(s => s.classCode === 'ama'),
sor: SKILLS_CATALOG.filter(s => s.classCode === 'sor'),
nec: SKILLS_CATALOG.filter(s => s.classCode === 'nec'),
pal: SKILLS_CATALOG.filter(s => s.classCode === 'pal'),
bar: SKILLS_CATALOG.filter(s => s.classCode === 'bar'),
dru: SKILLS_CATALOG.filter(s => s.classCode === 'dru'),
ass: SKILLS_CATALOG.filter(s => s.classCode === 'ass'),
})
`
writeFileSync(join(process.cwd(), 'src', 'data', 'skills-catalog.ts'), tsContent, 'utf-8')
console.log('Wrote src/data/skills-catalog.ts successfully!')
}
main().catch(err => {
console.error(err)
process.exit(1)
})