Learn-skills.dev implementing-game-skill-parsers
Use when implementing skill data generation from HTML sources for game build planners - guides the parser-factory-generation pattern for extracting level-scaling values for active and passive skills (project)
git clone https://github.com/NeverSight/learn-skills.dev
T=$(mktemp -d) && git clone --depth=1 https://github.com/NeverSight/learn-skills.dev "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/skills-md/aclinia/torchlight-of-building/implementing-game-skill-parsers" ~/.claude/skills/neversight-learn-skills-dev-implementing-game-skill-parsers && rm -rf "$T"
data/skills-md/aclinia/torchlight-of-building/implementing-game-skill-parsers/SKILL.mdImplementing Game Skill Parsers
Overview
Skill data generation follows a parser-factory-generation pattern:
- Parser extracts numeric values from HTML/data sources with named keys
- Factory defines how to build Mod objects using those named values
- Generation script combines parsed values into
outputlevelValues
Critical: Parser keys MUST match factory key usage exactly.
Note: This skill covers active and passive skills only. For support skills, see the
adding-support-mod-parsers skill.
When to Use
- Adding new active or passive skills with level-scaling properties
- Extracting values from game data HTML pages
Project File Locations
| Purpose | File Path |
|---|---|
| Active factories | |
| Passive factories | |
| Factory types & helpers | |
| Active parsers | |
| Passive parsers | |
| Parser registry | |
| Generation script | |
| HTML data sources | |
Categories:
active, passive, activation_medium
Implementation Checklist
1. Identify Data Source
- HTML file at
.garbage/tlidb/skill/{category}/{Skill_Name}.html - Find Progression /40 table - columns are: level, col0, col1, col2 (Descript)
- Column indexing:
= first column after level,values[0]
= Descriptvalues[2] - Input is clean text (HTML already stripped by
)buildProgressionTableInput
2. Define Factory (structure + key names)
// In active-factories.ts or passive-factories.ts import { v } from "./types"; "Ice Bond": (l, vals) => ({ buffMods: [ { type: "DmgPct", value: v(vals.coldDmgPctVsFrostbitten, l), // Define key name here addn: true, dmgModType: "cold", cond: "enemy_frostbitten", }, ], }),
Factory return types:
- Active skills:
{ offense?: SkillOffense; mods?: Mod[]; buffMods?: Mod[] } - Passive skills:
{ mods?: Mod[]; buffMods?: Mod[] }
SkillOffense is a structured interface, NOT an array:
interface SkillOffense { weaponAtkDmgPct?: { value: number }; addedDmgEffPct?: { value: number }; persistentDmg?: { value: number; dmgType: DmgChunkType; duration: number }; spellDmg?: { value: DmgRange; dmgType: DmgChunkType; castTime: number }; // Multi-phase attack skills (e.g., Berserking Blade) sweepWeaponAtkDmgPct?: { value: number }; sweepAddedDmgEffPct?: { value: number }; steepWeaponAtkDmgPct?: { value: number }; steepAddedDmgEffPct?: { value: number }; }
The
v(arr, level) helper safely accesses arr[level - 1] with bounds checking.
Key naming conventions:
- Use descriptive camelCase names
- Include context:
not justdmgPctPerProjectiledmgPct
3. Create Parser (extract values for those keys)
// In active-parsers.ts or passive-parsers.ts import { findColumn, validateAllLevels } from "./progression-table"; import { template } from "./template-compiler"; import type { SupportLevelParser } from "./types"; import { createConstantLevels } from "./utils"; export const iceBondParser: SupportLevelParser = (input) => { const { skillName, progressionTable } = input; // Find column by header (uses substring matching) const descriptCol = findColumn(progressionTable, "descript", skillName); const coldDmgPctVsFrostbitten: Record<number, number> = {}; // Iterate over column rows (level → text) for (const [levelStr, text] of Object.entries(descriptCol.rows)) { const level = Number(levelStr); // Use template() for pattern matching - cleaner than regex const match = template("{value:dec%} additional cold damage").match( text, skillName, ); coldDmgPctVsFrostbitten[level] = match.value; } validateAllLevels(coldDmgPctVsFrostbitten, skillName); // Return named keys matching factory expectations return { coldDmgPctVsFrostbitten }; };
Template syntax for value extraction:
- Integer (e.g., "5" → 5){name:int}
- Decimal (e.g., "21.5" → 21.5){name:dec}
- Percentage as decimal (e.g., "96%" → 96, NOT 0.96){name:dec%}
- Percentage as integer (e.g., "-30%" → -30){name:int%}
For constant values (same across all levels): use
createConstantLevels(value)
4. Register Parser
// In index.ts { skillName: "Ice Bond", categories: ["active"], parser: iceBondParser }
5. Regenerate & Verify
pnpm exec tsx src/scripts/generate_skill_data.ts pnpm test
Check generated output for levels 1, 20, 40 against source HTML.
Example: Complex Skill (Frost Spike)
Parser extracts multiple named values:
export const frostSpikeParser: SupportLevelParser = (input) => { const weaponAtkDmgPct: Record<number, number> = {}; const addedDmgEffPct: Record<number, number> = {}; // ... extract from columns ... return { weaponAtkDmgPct, addedDmgEffPct, convertPhysicalToColdPct: createConstantLevels(convertValue), maxProjectile: createConstantLevels(maxProjValue), projectilePerFrostbiteRating: createConstantLevels(projPerRating), baseProjectile: createConstantLevels(baseProj), dmgPctPerProjectile: createConstantLevels(dmgPerProj), }; };
Factory uses those keys:
"Frost Spike": (l, vals) => ({ offense: { weaponAtkDmgPct: { value: v(vals.weaponAtkDmgPct, l) }, addedDmgEffPct: { value: v(vals.addedDmgEffPct, l) }, }, mods: [ { type: "ConvertDmgPct", value: v(vals.convertPhysicalToColdPct, l), from: "physical", to: "cold" }, { type: "MaxProjectile", value: v(vals.maxProjectile, l), override: true }, { type: "Projectile", value: v(vals.projectilePerFrostbiteRating, l), per: { stackable: "frostbite_rating", amt: 35 } }, { type: "BaseProjectileQuant", value: v(vals.baseProjectile, l) }, { type: "DmgPct", value: v(vals.dmgPctPerProjectile, l), dmgModType: "global", addn: true, per: { stackable: "projectile" } }, ], }),
Generated output:
levelValues: { weaponAtkDmgPct: [1.49, 1.51, 1.54, ...], addedDmgEffPct: [1.49, 1.51, 1.54, ...], convertPhysicalToColdPct: [1, 1, 1, ...], maxProjectile: [5, 5, 5, ...], projectilePerFrostbiteRating: [1, 1, 1, ...], baseProjectile: [2, 2, 2, ...], dmgPctPerProjectile: [0.08, 0.08, 0.08, ...], }
Example: Multi-Phase Attack Skill (Berserking Blade)
For skills with multiple attack phases, use the dedicated offense properties:
"Berserking Blade": (l, vals) => ({ offense: { // Sweep phase stats sweepWeaponAtkDmgPct: { value: v(vals.sweepWeaponAtkDmgPct, l) }, sweepAddedDmgEffPct: { value: v(vals.sweepAddedDmgEffPct, l) }, // Steep strike phase stats steepWeaponAtkDmgPct: { value: v(vals.steepWeaponAtkDmgPct, l) }, steepAddedDmgEffPct: { value: v(vals.steepAddedDmgEffPct, l) }, }, mods: [ { type: "SkillAreaPct", skillAreaModType: "global" as const, value: v(vals.skillAreaBuffPct, l), per: { stackable: "berserking_blade_buff" }, }, { type: "MaxBerserkingBladeStacks", value: v(vals.maxBerserkingBladeStacks, l) }, { type: "SteepStrikeChancePct", value: v(vals.steepStrikeChancePct, l) }, ], }),
Example: Spell Skill (Chain Lightning)
Spell skills use
spellDmg with damage range and cast time:
"Chain Lightning": (l, vals) => ({ offense: { addedDmgEffPct: { value: v(vals.addedDmgEffPct, l) }, spellDmg: { value: { min: v(vals.spellDmgMin, l), max: v(vals.spellDmgMax, l) }, dmgType: "lightning", castTime: v(vals.castTime, l), }, }, mods: [{ type: "Jump", value: v(vals.jump, l) }], }),
Common Mistakes
| Mistake | Fix |
|---|---|
| Using array for offense | is a object, NOT an array. Use |
Using in DmgPct mods | Use instead of |
| Using HTML regex on clean text | Input is already - no HTML tags |
| Parser key doesn't match factory key | Keys must match exactly: needs parser to return |
| Forgetting parser registration | Add to SKILL_PARSERS array in |
| Missing factory | Must add factory in for mods to be applied at runtime |
substring collision | "damage" matches "Effectiveness of added damage" first - use exact matching (see below) |
| Missing levels 21-40 | Many skills only have data for levels 1-20; fill 21-40 with level 20 values |
findColumn Gotcha: Substring Matching
findColumn uses template substring matching. If column headers share substrings, you may get the wrong column:
// PROBLEM: "damage" is a substring of "Effectiveness of added damage" // This returns the WRONG column! const damageCol = findColumn(progressionTable, "damage", skillName); // SOLUTION: Use exact header matching when there's a collision const damageCol = progressionTable.find( (col) => col.header.toLowerCase() === "damage", ); if (!damageCol) { throw new Error(`${skillName}: no "damage" column found`); }
Handling Levels 21-40 with Empty Data
Many skills only have progression data for levels 1-20. Fill levels 21-40 with level 20 values:
// Extract levels 1-20 for (const [levelStr, text] of Object.entries(someCol.rows)) { const level = Number(levelStr); if (level <= 20 && text !== "") { values[level] = parseValue(text); } } // Fill levels 21-40 with level 20 value const level20Value = values[20]; if (level20Value === undefined) { throw new Error(`${skillName}: level 20 value missing`); } for (let level = 21; level <= 40; level++) { values[level] = level20Value; }
Data Flow
HTML Source → buildProgressionTableInput (strips HTML) → Parser (extracts values with named keys) → Generation Script (converts to levelValues arrays) → Output TypeScript file ↓ Runtime: Factory + levelValues → Mod objects
Benefits of Named Keys
- Self-documenting:
is clearer thanvals.projectilePerFrostbiteRatingvals[4] - Order-independent: Parser and factory don't need to agree on array order
- Extensible: Adding new values doesn't shift existing indices
- Type-safe: TypeScript can catch typos in key names