Generate racial radial rows from race UsableFeat, replacing the hand-kept global.json list #94

Closed
opened 2026-08-03 17:48:50 +00:00 by archvillainette · 0 comments
Owner

Problem Statement

Racial spell-like abilities (drow darkness, tiefling darkness, aasimar daylight…) only reach the in-game radial when the feat has an OnMenu row in every cls_feat_<class>.2da. Racial feats are granted by race, never by a class, so nothing puts them there automatically — today they are hand-written as ~23 rows in sow-topdata data/classes/feats/global.json, prepended into every class table.

That hand list is brittle. Adding a racial ability means editing two places — the race feats table (for possession) and global.json (for the radial) — and the two drift: the current build already contained 24 usable racial feats in the race tables but only 23 hand rows in global.json, so one activatable feat was silently missing from the radial.

Solution

Generate the racial radial rows at build time from the single source that already marks them: the UsableFeat column in the race feats tables (race_feat_*.2da). Every feat flagged UsableFeat=1 in any race table gets one cls_feat row injected into every class table, so marking a feat usable in the race table is the only edit needed — the radial row follows automatically. The hand-maintained racial block in global.json is deleted.

User Stories

  1. As a content designer, I want to mark a racial feat UsableFeat=1 in its race table and have it appear on the radial for every class, so that I never edit global.json for racial abilities.
  2. As a content designer, I want adding a race or a class to require no reconciliation of a global radial list, so that the two can never drift out of sync.
  3. As a player, I want every activatable racial ability my race grants to show on my radial regardless of my class, so that none are silently missing.
  4. As a maintainer, I want the generated rows to be byte-identical to the hand rows that were verified in-game (List=3, GrantedOnLevel=99, OnMenu=1), so that the switch to generation changes nothing a player sees.
  5. As a maintainer, I want a passive racial feat (no UsableFeat) to stay off the radial, so that only activatable abilities get buttons.
  6. As a maintainer, I want a racial feat usable for several races to produce exactly one row per class table, so that there are no duplicate radial entries.
  7. As a maintainer, I want generation to be deterministic across builds, so that the 2DA output and row numbering do not churn between runs.
  8. As a maintainer, I want a leftover hand row in global.json to be a no-op rather than a duplicate, so that the sow-topdata cleanup can land independently of this tool change.

Implementation Decisions

  • Source of truth = race feats tables only. Datasets whose OutputName is race_feat_*.2da, rows with UsableFeat=1. Not feat.2da — a global feat flag would inject unrelated class abilities (e.g. a shadowdancer ability) into every class radial via multiclass.
  • Generated row shape: List=3, GrantedOnLevel=99, OnMenu=1. List=3 keeps the feat off every level-up selection list; GrantedOnLevel=99 is above the level cap so no class ever actually grants it (the field is uint8_t nLevelGranted; the class auto-grants only at reachable levels); OnMenu=1 renders the button once the creature possesses the feat. Possession stays the job of chargen / the login racial-feat sync (sow-codebase#359). -1 was rejected: it is the selectable-list sentinel (pairs with List=0/1) and would leak racial feats into bonus-feat pickers.
  • Reuse the existing injection machinery. The generated rows are appended to the globalRules already processed by expandClassesFeatRows — same dedup against present rows, same feat-existence check, same label lookup. They differ only in that they are applied unconditionally, not gated by useConfiguredInjections (i.e. regardless of whether a class dataset carries its own global.json), because a leftover hand row must dedup to a no-op during the migration window.
  • Computed once per build from the collected datasets, threaded through resolveNativeDataset into expandClassesFeatRows. Output sorted by feat key for determinism.
  • Consumer change (separate repo): delete the racial block from sow-topdata data/classes/feats/global.json, keeping the non-racial rows. Belongs on the open sow-topdata PR for the racial-radial effort, not here.

Testing Decisions

  • Good test = observable output, not internals. Given a set of race feats tables, assert the exact set of generated rules (feat id + 3/99/1), that non-usable rows are excluded, that a cross-race duplicate collapses to one, that non-race_feat_ datasets are ignored, and that order is deterministic.
  • Unit test on the rule generator directly (no build fixture) — the whole decision lives there; the injection/dedup path is already covered by the existing cls_feat global-injection build tests, which continue to pass with the new parameter.
  • Prior art: the GlobalFeats / global.json injection tests in internal/topdata/topdata_test.go.
  • End-to-end sanity performed: with the racial block removed from global.json, a real build-topdata run still emits TieflingDarkness 3 99 1 once per class table across all 21, and 24 racial rows total — matching the in-game-verified behaviour and catching the one feat the hand list had dropped.

Out of Scope

  • Possession / granting the feats to characters — that is sow-codebase#359 (chargen + login racial-feat sync).
  • Making inert racial spell-like abilities actually cast — sow-codebase#305.
  • Any change to how non-racial cls_feat injections (combat modes, class-skill masterfeats) work.

Further Notes

  • The UsableFeat column was previously a passthrough the tool ignored (0 references). This gives it a job; it stays the authoritative marker.
  • Blast radius is limited to cls_feat_*.2da generation. Other 2DAs are untouched.
## Problem Statement Racial spell-like abilities (drow darkness, tiefling darkness, aasimar daylight…) only reach the in-game radial when the feat has an `OnMenu` row in **every** `cls_feat_<class>.2da`. Racial feats are granted by race, never by a class, so nothing puts them there automatically — today they are hand-written as ~23 rows in `sow-topdata` `data/classes/feats/global.json`, prepended into every class table. That hand list is brittle. Adding a racial ability means editing two places — the race feats table (for possession) and `global.json` (for the radial) — and the two drift: the current build already contained **24** usable racial feats in the race tables but only **23** hand rows in `global.json`, so one activatable feat was silently missing from the radial. ## Solution Generate the racial radial rows at build time from the single source that already marks them: the `UsableFeat` column in the race feats tables (`race_feat_*.2da`). Every feat flagged `UsableFeat=1` in any race table gets one `cls_feat` row injected into every class table, so marking a feat usable in the race table is the only edit needed — the radial row follows automatically. The hand-maintained racial block in `global.json` is deleted. ## User Stories 1. As a content designer, I want to mark a racial feat `UsableFeat=1` in its race table and have it appear on the radial for every class, so that I never edit `global.json` for racial abilities. 2. As a content designer, I want adding a race or a class to require no reconciliation of a global radial list, so that the two can never drift out of sync. 3. As a player, I want every activatable racial ability my race grants to show on my radial regardless of my class, so that none are silently missing. 4. As a maintainer, I want the generated rows to be byte-identical to the hand rows that were verified in-game (`List=3, GrantedOnLevel=99, OnMenu=1`), so that the switch to generation changes nothing a player sees. 5. As a maintainer, I want a passive racial feat (no `UsableFeat`) to stay off the radial, so that only activatable abilities get buttons. 6. As a maintainer, I want a racial feat usable for several races to produce exactly one row per class table, so that there are no duplicate radial entries. 7. As a maintainer, I want generation to be deterministic across builds, so that the 2DA output and row numbering do not churn between runs. 8. As a maintainer, I want a leftover hand row in `global.json` to be a no-op rather than a duplicate, so that the `sow-topdata` cleanup can land independently of this tool change. ## Implementation Decisions - **Source of truth = race feats tables only.** Datasets whose `OutputName` is `race_feat_*.2da`, rows with `UsableFeat=1`. Not `feat.2da` — a global feat flag would inject unrelated class abilities (e.g. a shadowdancer ability) into every class radial via multiclass. - **Generated row shape:** `List=3, GrantedOnLevel=99, OnMenu=1`. `List=3` keeps the feat off every level-up selection list; `GrantedOnLevel=99` is above the level cap so no class ever actually grants it (the field is `uint8_t nLevelGranted`; the class auto-grants only at reachable levels); `OnMenu=1` renders the button once the creature possesses the feat. Possession stays the job of chargen / the login racial-feat sync (`sow-codebase#359`). `-1` was rejected: it is the *selectable-list* sentinel (pairs with `List=0/1`) and would leak racial feats into bonus-feat pickers. - **Reuse the existing injection machinery.** The generated rows are appended to the `globalRules` already processed by `expandClassesFeatRows` — same dedup against present rows, same feat-existence check, same label lookup. They differ only in that they are applied **unconditionally**, not gated by `useConfiguredInjections` (i.e. regardless of whether a class dataset carries its own `global.json`), because a leftover hand row must dedup to a no-op during the migration window. - **Computed once per build** from the collected datasets, threaded through `resolveNativeDataset` into `expandClassesFeatRows`. Output sorted by feat key for determinism. - **Consumer change (separate repo):** delete the racial block from `sow-topdata` `data/classes/feats/global.json`, keeping the non-racial rows. Belongs on the open `sow-topdata` PR for the racial-radial effort, not here. ## Testing Decisions - **Good test = observable output, not internals.** Given a set of race feats tables, assert the exact set of generated rules (feat id + `3/99/1`), that non-usable rows are excluded, that a cross-race duplicate collapses to one, that non-`race_feat_` datasets are ignored, and that order is deterministic. - **Unit test** on the rule generator directly (no build fixture) — the whole decision lives there; the injection/dedup path is already covered by the existing `cls_feat` global-injection build tests, which continue to pass with the new parameter. - **Prior art:** the `GlobalFeats` / `global.json` injection tests in `internal/topdata/topdata_test.go`. - **End-to-end sanity performed:** with the racial block removed from `global.json`, a real `build-topdata` run still emits `TieflingDarkness 3 99 1` once per class table across all 21, and 24 racial rows total — matching the in-game-verified behaviour and catching the one feat the hand list had dropped. ## Out of Scope - Possession / granting the feats to characters — that is `sow-codebase#359` (chargen + login racial-feat sync). - Making inert racial spell-like abilities actually cast — `sow-codebase#305`. - Any change to how non-racial `cls_feat` injections (combat modes, class-skill masterfeats) work. ## Further Notes - The `UsableFeat` column was previously a passthrough the tool ignored (0 references). This gives it a job; it stays the authoritative marker. - Blast radius is limited to `cls_feat_*.2da` generation. Other 2DAs are untouched.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: ShadowsOverWestgate/sow-tools#94