Implementing 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 levelValues output
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 src/tli/skills/active-factories.ts
Passive factories src/tli/skills/passive-factories.ts
Factory types & helpers src/tli/skills/types.ts
Active parsers src/scripts/skills/active-parsers.ts
Passive parsers src/scripts/skills/passive-parsers.ts
Parser registry src/scripts/skills/index.ts
Generation script src/scripts/generate-skill-data.ts
HTML data sources .garbage/tlidb/skill/{category}/{Skill_Name}.html
Categories: active , passive , activation_medium
Implementation Checklist
- 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: values[0] = first column after level, values[2] = Descript
-
Input is clean text (HTML already stripped by buildProgressionTableInput )
- 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: dmgPctPerProjectile not just dmgPct
- 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:
-
{name:int}
-
Integer (e.g., "5" → 5)
-
{name:dec}
-
Decimal (e.g., "21.5" → 21.5)
-
{name:dec%}
-
Percentage as decimal (e.g., "96%" → 96, NOT 0.96)
-
{name:int%}
-
Percentage as integer (e.g., "-30%" → -30)
For constant values (same across all levels): use createConstantLevels(value)
- Register Parser
// In index.ts { skillName: "Ice Bond", categories: ["active"], parser: iceBondParser }
- 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 offense is a SkillOffense object, NOT an array. Use offense: { weaponAtkDmgPct: { value: ... } }
Using modType in DmgPct mods Use dmgModType instead of modType
Using HTML regex on clean text Input is already .text().trim()
- no HTML tags
Parser key doesn't match factory key Keys must match exactly: vals.dmgPct needs parser to return { dmgPct: ... }
Forgetting parser registration Add to SKILL_PARSERS array in index.ts
Missing factory Must add factory in *-factories.ts for mods to be applied at runtime
findColumn 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: vals.projectilePerFrostbiteRating is clearer than vals[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