| name | update-skills |
| description | Apply a GW1 skill balance patch to the local skill data using the changeset pipeline. Use when a new game update / balance patch needs to be applied, when the skill-update detector fires, or when the user says "apply the <date> patch" / "update skill data". |
Applying a GW1 Skill Balance Patch
This document is self-contained: everything needed to apply a balance patch correctly is here.
Core rule: the pipeline drafts, a human-reviewable changeset gates, guards refuse rather than guess. Never bypass a guard, never edit the data files by hand.
Background — the data model
Two JSON files hold all skill data, both keyed by the game's numeric skill id (~1485 skills), both tab-indented:
lib/gw/data/skilldata.json → { "$schema": ..., "skilldata": { "<id>": {...} } }. Per skill: id, campaign, profession, attribute, type, is_elite, is_rp, is_pvp, pvp_split, split_id, upkeep, energy, activation, recharge, adrenaline, sacrifice, overcast. Balance patches change the cost fields (energy, activation, recharge, adrenaline, rarely sacrifice/upkeep/overcast). If pvp_split: true, the skill has a separate PvP version whose id is split_id.
lib/gw/data/skilldesc-en.json → { "$schema": ..., "lang": "en", "skilldesc": { "<id>": { id, name, description, concise } } }. description is the full text, concise the short in-game form (often starting (X seconds.)).
Provenance: the files originate from build-wars/gw-skilldata, but that upstream is stale and divergent — never sync from it. The only authoritative source is the official wiki (wiki.guildwars.com). Never hand-map skill names to ids; both practices caused real data corruption in the past.
Background — the wiki source
- Patch notes:
https://wiki.guildwars.com/wiki/Feedback:Game_updates/<YYYYMMDD>. Structure: an === ... Balance Update === (h3) section containing ==== <Profession> ==== (h4) subsections, whose bullets look like * {{skill icon|Skill Name}} (PvE|PvP|BOTH) - <change text>. The variant tag is optional. Bullets without {{skill icon}} are bulk/prose lines (e.g. "Nature Ritual activation time from 3 to 2. Affects: [[...]], [[...]]"). Sections outside Balance Update (AI changes, bug fixes) are intentionally ignored.
- Per-skill pages:
https://wiki.guildwars.com/wiki/<Skill Name>. Each carries a {{Skill infobox}} with the authoritative numeric id, costs, and both description and concise description in wikitext. PvP variants are separate pages named <Skill Name> (PvP). Some pages carry two ids (id = 1951<!-- Luxon -->, 2094<!-- Kurzick -->).
- Index of all updates: queried via the MediaWiki API (
list=allpages, namespace 202, prefix Game updates/) — the human-facing index page builds its list dynamically, so don't scrape it. scripts/lib/wiki.mjs exports listUpdateDates() for this.
- Wikitext→our-style rendering (done by
renderWikiText in scripts/lib/wiki.mjs, needed when hand-authoring overrides): {{gr|a|b}} → a...b (three dots; args may be +-prefixed; trailing |- arg = negative/degeneration), [[A|B]] → B, [s]/[es] markers render the plural (foe[s] → "foes") while [y|ies] → first option, {{gray|...}} → <gray>...</gray>, bold/italic quotes dropped, whitespace collapsed, and the leading type sentence the wiki shows ("Elite Hex Spell. ...") is stripped — our descriptions never include it.
Environment requirements
Node 20+ (native fetch, ES modules), network access to wiki.guildwars.com. No API keys. The scripts retry transient wiki failures, cache pages per process, and report fetch errors in their summaries — a wiki outage degrades loudly, not silently.
Preconditions — check ALL before starting
- Clean data:
git status must show no changes to lib/gw/data/. build-changeset diffs against current data; running it after a partial apply produces a degenerate (no-op) changeset.
- Chronological order: patches must be applied oldest-first. Check
scripts/changesets/ and CHANGELOG.md for what's already applied; check what exists upstream via the wiki index (or prod's skill_update_notifications table). If several patches are pending, do the full workflow including the commit for the oldest, then repeat for the next.
- Right branch: feature branch off
develop, named like feature/june-25-skill-update. PRs always target develop, never main.
Workflow
npm run skills:build-changeset -- --date <YYYYMMDD>
npm run skills:apply-changeset -- scripts/changesets/<date>.json
npm run skills:apply-changeset -- scripts/changesets/<date>.json --audit
npm run skills:verify -- scripts/changesets/<date>.json
NOTE the -- before script arguments — without it npm swallows flags like --audit instead of passing them through.
Hotfix-only updates (no balance section): some game updates contain no skill changes at all (e.g. 20260625, 20260630 — bug/mobile hotfixes). build-changeset then writes an empty changeset ("entries": []). Commit it anyway — it's the audit trail proving the date was checked rather than missed — and note the verification in the CHANGELOG. No data change, no version bump for this alone.
Follow-up / reconciliation changesets: for corrections outside a patch date (a lagged wiki page catching up, reconciling old divergences), hand-author a changeset named <YYYYMMDD>-<topic>.json (e.g. 20260703-june24-followup.json) with accurate from/to values and a notes entry explaining provenance, and apply it via skills:apply-changeset like any other. The guards still protect you; the data files are still never edited by hand.
Step 5 is meaningful only once wiki editors have updated the skill pages — typically a few days after the patch. Run it immediately anyway (expect many "lagged"), and re-run ~3–7 days later. 0 NUMERIC mismatches is the requirement; "lagged" is fine; TEXT diffs need eyeballing (known noise below). The TEXT check compares the multiset of numbers/ranges in our description+concise vs the wiki's — phrasing-agnostic, so it catches missed flat-number changes regardless of wording.
The changeset artifact (what you review in step 2)
scripts/changesets/<date>.json — one entry per touched skill id:
{
"id": 33, "name": "Illusionary Weaponry", "profession": "Mesmer",
"data": { "recharge": { "from": 25, "to": 15, "noteFrom": 25, "wiki": "confirmed" } },
"desc": { "description": { "from": "<old text>", "to": "<new text>" }, "concise": { ... } },
"flags": [],
"notes": ["[base] Reduce recharge from 25 to 15."]
}
data.<field>.wiki is the live-wiki cross-check at build time: confirmed (wiki already shows the new value), lagged (wiki still shows old — fine), mismatch(X) (wiki shows a third value — investigate), n/a (no wiki value), override (came from the overrides file), bulk (from a bulk line). Entries with a non-numeric id like "unresolved:<name>" mean a change line resolved to nothing — apply refuses the whole changeset until fixed.
apply guards every change: the current local value must equal from (or already equal to — idempotent re-run), and the local skill name must match the entry name, so a change can never land on the wrong skill. Any failure → nothing is written at all.
The overrides file (how you resolve flags)
scripts/changesets/<date>.overrides.mjs, auto-loaded by build-changeset. Default-exports an object keyed by skill id:
export default {
1346: {
description: 'Your next 1...3 non-Illusion spells use your Illusion attribute instead of its normal attribute.',
concise: '...',
note: 'reworked: only non-Illusion spells',
},
335: { append: { description: ' This attack also hits 1...2 random adjacent foes.', concise: ' ...' },
note: 'WIKI LAGGED 2026-06-25: clause authored from patch notes; verify later' },
1406: { replaceAll: [['Dazed for 5 seconds', 'Dazed for 10 seconds']], note: '...' },
79: { data: { recharge: 20 }, note: 'recharge 30 -> 20 (no local PvP split)' },
}
Fields: data (numeric, absolute new values), description/concise (full replace), append.{description,concise}, replaceAll: [[from, to], ...] (applied to both text fields), note (mandatory practice — it lands in the changeset for the reviewer). An override clears the entry's manual/unparsed flags automatically. Override text must be the wiki page's updated text rendered to our style (see rendering rules above) unless the wiki page itself is lagged, in which case author from the patch-notes wording and mark the note WIKI LAGGED <date>: verify later.
Resolving flags — the changeset MUST reach 0 flags before apply
| Flag | Meaning | Resolution |
|---|
manual: "..." | Rework / added clause / structural change | Author new text in overrides from the updated wiki page |
partial parse — review | Line describes more changes than were captured | Compare wiki page vs our text; author the missed piece. On 2026-06-24 this caught 5 real misses (all flat-% changes) |
unparsed: "..." | Nothing captured at all | Read the line; usually novel phrasing → override |
ambiguous range "X" xN | The old range appears N>1 times in the text | replaceAll override with enough surrounding context to be unique |
manual (bare "to" range) | "to A..B" with no from-range | Wiki page has the full new text → override |
unknown skill name | Patch-notes name not in local data | Check spelling/renames on the wiki; if genuinely absent locally, document the skip |
ambiguous name → local ids A, B | Duplicate local names (known: Pious Fury (PvP) ids 2146 & 3368) | Determine the correct id from the wiki page's infobox id; override that id directly |
local/wiki id disagree | Our id mapping may be wrong | STOP and investigate — this is the historical corruption mode. Do not apply until understood |
"X" (PvP): no PvP split in local data | Notes say PvP but we have one entry | Usually applies to the single entry; confirm on the wiki, then data override + note |
unhandled balance line — review | Bulk/prose bullet the parser doesn't model | If it changes skill values (like the Nature Ritual bulk line) it must be represented; if prose-only, document the skip |
AoE 100%->75% not representable | See AoE gotcha below | Override with the wiki's updated phrasing |
Edge cases and gotchas (all real, all bit us before)
- Wiki skill pages lag the patch notes by days. Right after a patch, author overrides from the patch-notes wording, mark them
WIKI LAGGED, and rely on the step-5 re-run to confirm.
- Patch notes can also lie the other way — the actively-maintained infobox is the tiebreaker. The Feb 5, 2026 notes announced changes (Predatory Season duration 30...240, Tranquility 30...120) that never landed in game; the infoboxes kept the real values. If a
verify mismatch persists long after a patch (weeks, while the same page absorbed other changes), suspect the notes, check the page's revision history, and reconcile toward the infobox — don't wait forever on "lagged". Open case as of 2026-07-05: Melandru's Shot (853) recharge — notes say 12 (applied locally), infobox still 8.
- The wiki itself drifts after you apply. Editors reword pages days later (e.g. Cleave "foe" → "foes" was edited hours after our follow-up). Periodic
verify TEXT re-runs catch number changes; wording-only drift is cosmetic — fix it in a follow-up changeset when noticed, don't chase it.
- Flat-number / flat-% text changes are NOT auto-applied (e.g. "50% → 100% more adrenaline", "duration 8 → 12 seconds", "Dazed 5 → 10s"). Only cost fields and
A..B → C..D range swaps are automatic. Never assume a text change happened because the numeric one did.
- AoE "damage from 100% to 75%" isn't a numeric field; the wiki encodes it in text as "…and all adjacent/nearby foes take 75% of that damage." Override with that phrasing.
(PvE)/(PvP) variants: a name embedding the suffix is its own skill/page/id. A (PvP)-tagged change targets the base skill's split_id; (BOTH) targets base + split; (BOTH) with no local split correctly applies to the base only.
- Dataset style: three-dot ranges (
5...41), <gray>…</gray> caveats, concise often (X seconds.)-prefixed, no leading type sentence. Match neighboring entries; when in doubt read a few untouched skills.
- Multi-id wiki pages (Kurzick/Luxon variants): the parser strips the HTML comments, but confirm which id a change targets.
- Nature Ritual bulk line resolves each name in its "Affects:" list — check every listed skill landed in the changeset.
- Known pre-existing noise in
verify TEXT diffs: ~10 Nature Ritual spirit-lifespan divergences from the 2026-02-05 patch (e.g. Winnowing 30…150 local vs 30…240 wiki). NOT caused by your patch — don't fix them inside a patch changeset; they need their own reconciliation pass.
lib/constants.ts SKILL_TYPE_BY_ID is misaligned with the data's type numbers (type 20 = Shouts, not Nature Ritual). Never use it to select skills by type; the pipeline never does.
--audit checks the POST-apply state — running it before applying reports failures that just mean "not applied yet".
- Superseded changesets audit-fail forever — that's expected, not corruption. When a follow-up changeset replaces text an earlier changeset applied (e.g.
20260703-june24-followup.json supersedes the Cleave entries in 20260624.json), the older changeset's --audit will permanently fail on those fields. Audit results are only meaningful for the newest changeset touching a skill; before treating an audit failure as corruption, check scripts/changesets/ (and the changesets' notes) for a later superseding entry.
Validation checklist (all must pass before PR)
Shipping
- Commit the data files and
scripts/changesets/<date>.json + .overrides.mjs (the changeset is the review artifact — always committed). Message: feat: update <Month D, YYYY> skill balance data.
- Add a
CHANGELOG.md entry: ## <today's date> heading, **<patch date> Skill Balance Update** subtitle, then player-facing bullets (highlights first, aggregate the rest — copy the tone of the existing July 5 / February 8 entries). Bump the minor version in package.json (feat: → minor, per the release process in CLAUDE.md).
- Generate the full per-skill breakdown for the entry:
npm run skills:render-notes -- scripts/changesets/<date>.json prints collapsible per-profession <details> blocks (rendered as expandable sections on /changes). Paste them below the entry's bullets, and proofread the generated lines — they're cleaned patch-note text, so smooth out anything awkward (e.g. terse bulk-line notes like "Nature Ritual activation → 2"). Only the entry's title bullets need hand-writing; the breakdown is generated.
- PR against
develop.
- ~3–7 days later, re-run
skills:verify and resolve anything still lagged or mismatched.
Relationship to the update detector
A Supabase Edge Function (check-skill-updates, prod project iaqbfleijvzdffahhqtc) runs daily via pg_cron, watches the wiki for new update pages, records rows in skill_update_notifications, advances skill_update_state.last_known_update_date, and pings a webhook. It only detects — it never modifies skill data, needs no maintenance during this workflow, and will NOT re-alert for a date it already announced (so a patch can be pending even with no fresh notification — check the table or the wiki index). Full detector details: docs/skill-update-detector-handoff.md.