Available Feats Support
This commit is contained in:
@@ -0,0 +1,200 @@
|
|||||||
|
# Class Progression Feat Projections Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make class progression feat grouping configurable from wiki YAML so the module template can render granted feats and level-gated available feats separately.
|
||||||
|
|
||||||
|
**Architecture:** Extend the `class_progression` data provider config with named class-feat projection fields. The toolkit evaluates each configured projection against class feat rows and groups resolved feat links onto progression rows by level, while the module wiki config declares the project-specific `List` rules and the class template owns output wording.
|
||||||
|
|
||||||
|
**Tech Stack:** Go `sow-toolkit`, native topdata wiki YAML, HTML wiki templates, Go tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- `internal/topdata/wiki_data_providers.go`: parse and validate data-provider projection configuration and pass it into class progression row assembly.
|
||||||
|
- `internal/topdata/wiki_tables.go`: share class progression row assembly with configured class feat projections and keep default compatibility behavior.
|
||||||
|
- `internal/topdata/wiki_native_test.go`: regression coverage for provider projection parsing, filtering, grouping, ordering, invalid config, and rendered template fields.
|
||||||
|
- `../module/topdata/wiki/data.yaml`: declare module-owned `GrantedFeats` and `AvailableFeats` projections for class progression.
|
||||||
|
- `../module/topdata/wiki/templates/pages/classes.html`: render bonus feats inline and use projected available feats in the Notes cell.
|
||||||
|
- `../module/topdata/wiki/README.md`: document configurable data-provider projections for template authors.
|
||||||
|
|
||||||
|
### Task 1: Add Configured Class Feat Projection Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/topdata/wiki_native_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing provider projection tests**
|
||||||
|
|
||||||
|
Add tests that build `wikiDataProviderDefinition` from YAML such as:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
providers:
|
||||||
|
ClassProgression:
|
||||||
|
source: class_progression
|
||||||
|
levels: 1-4
|
||||||
|
class_feats:
|
||||||
|
fields:
|
||||||
|
GrantedFeats:
|
||||||
|
when: "{{GrantedOnLevel > 0 && List == 3}}"
|
||||||
|
AvailableFeats:
|
||||||
|
when: "{{GrantedOnLevel > 0 && List != 3}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Assert the parsed provider can render a progression row where `GrantedFeats`
|
||||||
|
contains `List == 3` feat links, `AvailableFeats` contains positive-level
|
||||||
|
non-`3` feat links, matched link order follows fixture source row order, and
|
||||||
|
`BonusFeat` is still present.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add failing invalid configuration coverage**
|
||||||
|
|
||||||
|
Add a test that loads a provider field with an empty projection name or empty
|
||||||
|
`when` expression and expects an error containing `data.yaml`, the provider
|
||||||
|
name, and the projection field context.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run focused tests to verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/topdata -run 'TestWiki.*ClassProgression.*Projection|TestWikiData.*Provider.*Projection' -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `wikiDataProviderDefinition` does not yet accept
|
||||||
|
`class_feats.fields` and progression rows do not expose `AvailableFeats`.
|
||||||
|
|
||||||
|
### Task 2: Implement Generic Provider Projections
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/topdata/wiki_data_providers.go`
|
||||||
|
- Modify: `internal/topdata/wiki_tables.go`
|
||||||
|
- Test: `internal/topdata/wiki_native_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend provider config types and validation**
|
||||||
|
|
||||||
|
Add typed YAML support for:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type wikiDataProviderClassFeatConfig struct {
|
||||||
|
Fields map[string]wikiDataProviderProjectionField `json:"fields" yaml:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wikiDataProviderProjectionField struct {
|
||||||
|
When string `json:"when" yaml:"when"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate each configured projection field name and `when` expression before
|
||||||
|
provider execution.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Carry projections into progression row assembly**
|
||||||
|
|
||||||
|
Change the provider path so `ClassProgression` passes provider-owned class feat
|
||||||
|
projection fields into progression row assembly. Preserve table-based
|
||||||
|
`class_progression` callers by giving them the current default `GrantedFeats`
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Evaluate and group projections**
|
||||||
|
|
||||||
|
In progression row assembly, resolve class feat links once per matching row,
|
||||||
|
evaluate configured `when` expressions against class feat rows, and append
|
||||||
|
matched links to the configured progression field for each positive
|
||||||
|
`GrantedOnLevel` value in source row order.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused tests to verify GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/topdata -run 'TestWiki.*ClassProgression.*Projection|TestWikiData.*Provider.*Projection' -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Configure Module Wiki Output
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `../module/topdata/wiki/data.yaml`
|
||||||
|
- Modify: `../module/topdata/wiki/templates/pages/classes.html`
|
||||||
|
- Modify: `../module/topdata/wiki/README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Declare module projections**
|
||||||
|
|
||||||
|
Update `ClassProgression` in `../module/topdata/wiki/data.yaml` so module YAML
|
||||||
|
owns `GrantedFeats` and `AvailableFeats` selection:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ClassProgression:
|
||||||
|
source: class_progression
|
||||||
|
levels: 1-12
|
||||||
|
class_feats:
|
||||||
|
fields:
|
||||||
|
GrantedFeats:
|
||||||
|
when: "{{GrantedOnLevel > 0 && List == 3}}"
|
||||||
|
AvailableFeats:
|
||||||
|
when: "{{GrantedOnLevel > 0 && List != 3}}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Render projected fields in the class template**
|
||||||
|
|
||||||
|
Keep bonus feats in the Feats cell with a conditional comma. Render
|
||||||
|
`AvailableFeats` in the Notes cell with module-owned “becomes available”
|
||||||
|
wording using supported template loop/filter syntax.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Document the projection surface**
|
||||||
|
|
||||||
|
Add a concise `data.yaml` note to the wiki README explaining that data-provider
|
||||||
|
projection fields can expose configured per-level class feat lists to page
|
||||||
|
templates and that wording stays in templates.
|
||||||
|
|
||||||
|
### Task 4: Verify The Cross-Repo Wiki Path
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify: `internal/topdata/...`
|
||||||
|
- Verify: `../module/topdata/wiki/...`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run toolkit regression tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/topdata -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the local toolkit**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-tool.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit `0` and refresh `tools/sow-toolkit` when source changes require
|
||||||
|
it.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build module wiki with the local toolkit**
|
||||||
|
|
||||||
|
Run from `../module`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./build-wiki.sh --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: exit `0` and regenerate class wiki pages under `.cache/wiki/pages/`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Inspect fighter output**
|
||||||
|
|
||||||
|
Check generated fighter progression output for inline bonus feat text in Feats
|
||||||
|
cells and `becomes available` notes for level-gated selectable feat links.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run diff checks**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
in both toolkit and module repos.
|
||||||
@@ -15,6 +15,15 @@ type wikiDataProvidersDocument struct {
|
|||||||
type wikiDataProviderDefinition struct {
|
type wikiDataProviderDefinition struct {
|
||||||
Source string `json:"source" yaml:"source"`
|
Source string `json:"source" yaml:"source"`
|
||||||
Levels string `json:"levels" yaml:"levels"`
|
Levels string `json:"levels" yaml:"levels"`
|
||||||
|
ClassFeats wikiDataProviderClassFeatConfig `json:"class_feats" yaml:"class_feats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wikiDataProviderClassFeatConfig struct {
|
||||||
|
Fields map[string]wikiDataProviderProjectionField `json:"fields" yaml:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wikiDataProviderProjectionField struct {
|
||||||
|
When string `json:"when" yaml:"when"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition, error) {
|
func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition, error) {
|
||||||
@@ -37,7 +46,19 @@ func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition,
|
|||||||
return nil, fmt.Errorf("wiki data providers %s: provider %s missing source", path, name)
|
return nil, fmt.Errorf("wiki data providers %s: provider %s missing source", path, name)
|
||||||
}
|
}
|
||||||
switch strings.TrimSpace(def.Source) {
|
switch strings.TrimSpace(def.Source) {
|
||||||
case "class_progression", "class_feats", "class_skills", "class_summary", "race_feats", "race_name_forms":
|
case "class_progression":
|
||||||
|
for field, projection := range def.ClassFeats.Fields {
|
||||||
|
if strings.TrimSpace(field) == "" {
|
||||||
|
return nil, fmt.Errorf("wiki data providers %s: provider %s has class feat projection without field name", path, name)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(projection.When) == "" {
|
||||||
|
return nil, fmt.Errorf("wiki data providers %s: provider %s class feat projection %s missing when", path, name, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "class_feats", "class_skills", "class_summary", "race_feats", "race_name_forms":
|
||||||
|
if len(def.ClassFeats.Fields) > 0 {
|
||||||
|
return nil, fmt.Errorf("wiki data providers %s: provider %s class feat projections require source class_progression", path, name)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("wiki data providers %s: provider %s has unknown source %q", path, name, def.Source)
|
return nil, fmt.Errorf("wiki data providers %s: provider %s has unknown source %q", path, name, def.Source)
|
||||||
}
|
}
|
||||||
@@ -58,7 +79,7 @@ func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage)
|
|||||||
if page.Category != "classes" {
|
if page.Category != "classes" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return ctx.classProgressionRows(wikiTableDefinition{Rows: wikiTableRowsConfig{Levels: def.Levels}}, page.Row)
|
return ctx.classProgressionRows(def.Levels, def.ClassFeats.Fields, page.Row)
|
||||||
case "class_feats":
|
case "class_feats":
|
||||||
if page.Category != "classes" {
|
if page.Category != "classes" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -311,6 +311,87 @@ func TestWikiTemplateEachBlockRendersExplicitClassProgressionTable(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWikiTemplateClassProgressionProviderProjectsConfiguredClassFeats(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dataPath := filepath.Join(root, "data.yaml")
|
||||||
|
writeFile(t, dataPath, `
|
||||||
|
providers:
|
||||||
|
ClassProgression:
|
||||||
|
source: class_progression
|
||||||
|
levels: 1-4
|
||||||
|
class_feats:
|
||||||
|
fields:
|
||||||
|
GrantedFeats:
|
||||||
|
when: "{{GrantedOnLevel > 0 && List == 3}}"
|
||||||
|
AvailableFeats:
|
||||||
|
when: "{{GrantedOnLevel > 0 && List != 3}}"
|
||||||
|
`+"\n")
|
||||||
|
providers, err := loadWikiDataProviders(dataPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load data providers: %v", err)
|
||||||
|
}
|
||||||
|
ctx := testWikiTableContext(t, "")
|
||||||
|
ctx.wikiDataPath = dataPath
|
||||||
|
ctx.wikiDataProviders = providers
|
||||||
|
ctx.featRows["feat:weapon_focus"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Weapon Focus"}}}
|
||||||
|
ctx.classFeatTables["classes/feats:fighter"] = wikiTable{OutputStem: "cls_feat_fight", Rows: []map[string]any{
|
||||||
|
{"FeatIndex": map[string]any{"id": "feat:literate"}, "GrantedOnLevel": "1", "List": "3"},
|
||||||
|
{"FeatIndex": map[string]any{"id": "feat:weapon_specialization"}, "GrantedOnLevel": "4", "List": "1"},
|
||||||
|
{"FeatIndex": map[string]any{"id": "feat:weapon_focus"}, "GrantedOnLevel": "4", "List": "1"},
|
||||||
|
{"FeatIndex": map[string]any{"id": "feat:weapon_proficiency_simple"}, "GrantedOnLevel": "1", "List": "3"},
|
||||||
|
}}
|
||||||
|
page := wikiTemplatePage{
|
||||||
|
PageID: "classes:fighter",
|
||||||
|
Category: "classes",
|
||||||
|
Key: "classes:fighter",
|
||||||
|
Title: "Fighter",
|
||||||
|
Row: map[string]any{
|
||||||
|
"AttackBonusTable": "cls_atk_1",
|
||||||
|
"BonusFeatsTable": map[string]any{"table": "classes/bonusfeats:fighter"},
|
||||||
|
"FeatsTable": map[string]any{"table": "classes/feats:fighter"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassProgression}}
|
||||||
|
{{Level}}|{{GrantedFeats|join:", "|wiki}}|{{AvailableFeats|join:", "|wiki}}|{{BonusFeat}}
|
||||||
|
{{/each}}`, page)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render template: %v", err)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{
|
||||||
|
`1|[[feat/literate|Literate]], [[feat/simple-weapon-proficiency|Simple Weapon Proficiency]]||1`,
|
||||||
|
`4||[[feat/weapon-specialization|Weapon Specialization]], [[feat/weapon-focus|Weapon Focus]]|1`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, expected) {
|
||||||
|
t.Fatalf("expected %q in rendered progression:\n%s", expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWikiDataProvidersRejectEmptyClassFeatProjectionCondition(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dataPath := filepath.Join(root, "data.yaml")
|
||||||
|
writeFile(t, dataPath, `
|
||||||
|
providers:
|
||||||
|
ClassProgression:
|
||||||
|
source: class_progression
|
||||||
|
class_feats:
|
||||||
|
fields:
|
||||||
|
AvailableFeats:
|
||||||
|
when: ""
|
||||||
|
`+"\n")
|
||||||
|
|
||||||
|
_, err := loadWikiDataProviders(dataPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid class feat projection condition")
|
||||||
|
}
|
||||||
|
for _, expected := range []string{"data.yaml", "ClassProgression", "AvailableFeats"} {
|
||||||
|
if !strings.Contains(err.Error(), expected) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", expected, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWikiTemplateEachBlockRendersClassSkillsAsIndividualListItems(t *testing.T) {
|
func TestWikiTemplateEachBlockRendersClassSkillsAsIndividualListItems(t *testing.T) {
|
||||||
ctx := &wikiContext{
|
ctx := &wikiContext{
|
||||||
wikiDataProviders: map[string]wikiDataProviderDefinition{
|
wikiDataProviders: map[string]wikiDataProviderDefinition{
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ func (ctx *wikiContext) wikiTableSourceRows(def wikiTableDefinition, page wikiTe
|
|||||||
if page.Category != "classes" {
|
if page.Category != "classes" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return ctx.classProgressionRows(def, page.Row)
|
return ctx.classProgressionRows(def.Rows.Levels, nil, page.Row)
|
||||||
case "class_feats":
|
case "class_feats":
|
||||||
if page.Category != "classes" {
|
if page.Category != "classes" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -303,8 +303,8 @@ func (ctx *wikiContext) wikiTableSourceRows(def wikiTableDefinition, page wikiTe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctx *wikiContext) classProgressionRows(def wikiTableDefinition, classRow map[string]any) ([]map[string]any, error) {
|
func (ctx *wikiContext) classProgressionRows(levels string, projections map[string]wikiDataProviderProjectionField, classRow map[string]any) ([]map[string]any, error) {
|
||||||
start, end, err := parseLevelRange(def.Rows.Levels)
|
start, end, err := parseLevelRange(levels)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -317,7 +317,15 @@ func (ctx *wikiContext) classProgressionRows(def wikiTableDefinition, classRow m
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
granted := map[int][]string{}
|
if len(projections) == 0 {
|
||||||
|
projections = map[string]wikiDataProviderProjectionField{
|
||||||
|
"GrantedFeats": {When: "{{GrantedOnLevel > 0}}"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
classFeatFields := map[string]map[int][]string{}
|
||||||
|
for field := range projections {
|
||||||
|
classFeatFields[field] = map[int][]string{}
|
||||||
|
}
|
||||||
if table := ctx.tableForValue(fieldValue(classRow, "FeatsTable"), ctx.classFeatTables); table != nil {
|
if table := ctx.tableForValue(fieldValue(classRow, "FeatsTable"), ctx.classFeatTables); table != nil {
|
||||||
for _, featRow := range table.Rows {
|
for _, featRow := range table.Rows {
|
||||||
level, err := strconv.Atoi(stringValue(featRow, "GrantedOnLevel"))
|
level, err := strconv.Atoi(stringValue(featRow, "GrantedOnLevel"))
|
||||||
@@ -325,8 +333,18 @@ func (ctx *wikiContext) classProgressionRows(def wikiTableDefinition, classRow m
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName)
|
name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName)
|
||||||
if name != "" {
|
if name == "" {
|
||||||
granted[level] = append(granted[level], name)
|
continue
|
||||||
|
}
|
||||||
|
renderCtx := wikiTableRenderContext{TableName: "class_progression", Path: ctx.wikiDataPath}
|
||||||
|
for field, projection := range projections {
|
||||||
|
value, err := ctx.evalWikiTableTemplate(projection.When, featRow, renderCtx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("class progression field %s condition %q: %w", field, projection.When, err)
|
||||||
|
}
|
||||||
|
if truthyWikiTableValue(value) {
|
||||||
|
classFeatFields[field][level] = append(classFeatFields[field][level], name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,7 +369,9 @@ func (ctx *wikiContext) classProgressionRows(def wikiTableDefinition, classRow m
|
|||||||
row[key] = value
|
row[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row["GrantedFeats"] = granted[level]
|
for field, values := range classFeatFields {
|
||||||
|
row[field] = values[level]
|
||||||
|
}
|
||||||
row["BonusFeat"] = bonusByLevel[level]
|
row["BonusFeat"] = bonusByLevel[level]
|
||||||
row["HPMin"] = level
|
row["HPMin"] = level
|
||||||
if hitDie > 0 {
|
if hitDie > 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user