| name | implementing-game-skill-parsers |
| description | 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) |
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
levelValuesoutput
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
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:
values[0]= first column after level,values[2]= Descript - 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:
dmgPctPerProjectilenot justdmgPct
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:
{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)
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 | 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.projectilePerFrostbiteRatingis clearer thanvals[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