Explicit masterfeats/feats removal

This commit is contained in:
2026-05-13 19:32:41 +02:00
parent 03e2320788
commit 4b55628ac8
5 changed files with 102 additions and 31 deletions
+13 -7
View File
@@ -42,12 +42,16 @@ One concrete example already observed:
- validation in `internal/topdata/topdata.go` still hard-fails specifically for - validation in `internal/topdata/topdata.go` still hard-fails specifically for
`topdata/data/masterfeats/base.json` if `output` is missing `topdata/data/masterfeats/base.json` if `output` is missing
That mismatch proves the current methodology is stale: That mismatch proved the current methodology was stale:
- the runtime/build side treats `masterfeats` like a generic dataset - the runtime/build side treats `masterfeats` like a generic dataset
- the validator still treats it like a special namespace with explicit required - the validator still treats it like a special namespace with explicit required
metadata metadata
Current status: the output-name mismatch has been fixed for `masterfeats` and
the equivalent `feat` base dataset rule by sharing the derived output-name rule
between discovery and validation.
This same category of problem appears in several other places. This same category of problem appears in several other places.
## Findings From Current Audit ## Findings From Current Audit
@@ -67,17 +71,17 @@ This same category of problem appears in several other places.
Some of these are legitimate dataset-shape differences. Others are stale Some of these are legitimate dataset-shape differences. Others are stale
special-cases that should be generalized. special-cases that should be generalized.
### 2. `masterfeats` still carries outdated explicit validation rules ### 2. `masterfeats` carries dataset-specific invariants
`validateMasterfeatsBaseFile()` currently requires: `validateMasterfeatsBaseFile()` currently enforces:
- `output == masterfeats.2da` - explicit `output == masterfeats.2da` when `output` is authored
- specific required columns - specific required columns
- `masterfeats:` row keys - `masterfeats:` row keys
The key-prefix and column requirements may still be valid dataset invariants. The key-prefix and column requirements may still be valid dataset invariants.
The `output` requirement is not clearly justified anymore when generic dataset The stale requirement that `output` must be authored has been removed; validation
discovery already computes a default output name from location. now shares the same derived default output-name rule as native discovery.
### 3. Migration still seeds explicit dataset exceptions ### 3. Migration still seeds explicit dataset exceptions
@@ -128,7 +132,9 @@ keep inheriting brittle assumptions.
### Work Item 1: Remove outdated `masterfeats.output` hard requirement ### Work Item 1: Remove outdated `masterfeats.output` hard requirement
Investigate and likely change `validateMasterfeatsBaseFile()` so: Status: implemented.
`validateMasterfeatsBaseFile()` now:
- missing `output` is accepted when generic dataset discovery can derive the same - missing `output` is accepted when generic dataset discovery can derive the same
output name deterministically output name deterministically
+3 -1
View File
@@ -44,7 +44,9 @@ Canonical shape:
Rules: Rules:
- `output` must be exactly `masterfeats.2da` - `output` may be omitted; native dataset discovery deterministically derives
`masterfeats.2da` from `topdata/data/masterfeats/base.json`
- if `output` is present, it must be exactly `masterfeats.2da`
- `columns` must include `LABEL`, `STRREF`, and `DESCRIPTION` - `columns` must include `LABEL`, `STRREF`, and `DESCRIPTION`
- every canonical row must have a non-empty `key` - every canonical row must have a non-empty `key`
- row keys must start with `masterfeats:` - row keys must start with `masterfeats:`
+10 -2
View File
@@ -527,7 +527,7 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) {
} }
outputName, _ := baseData["output"].(string) outputName, _ := baseData["output"].(string)
if strings.TrimSpace(outputName) == "" { if strings.TrimSpace(outputName) == "" {
outputName = filepath.Base(path) + ".2da" outputName = nativeDatasetDefaultOutputName(path, nativeDatasetBase)
} }
datasets = append(datasets, nativeDataset{ datasets = append(datasets, nativeDataset{
Kind: nativeDatasetBase, Kind: nativeDatasetBase,
@@ -561,7 +561,7 @@ func discoverNativeDatasets(dataDir string) ([]nativeDataset, error) {
} }
outputName, _ := tableData["output"].(string) outputName, _ := tableData["output"].(string)
if strings.TrimSpace(outputName) == "" { if strings.TrimSpace(outputName) == "" {
outputName = strings.TrimSuffix(name, filepath.Ext(name)) + ".2da" outputName = nativeDatasetDefaultOutputName(filePath, nativeDatasetPlain)
} }
datasets = append(datasets, nativeDataset{ datasets = append(datasets, nativeDataset{
Kind: nativeDatasetPlain, Kind: nativeDatasetPlain,
@@ -4208,6 +4208,14 @@ func outputStem(outputName string) string {
return strings.TrimSuffix(outputName, filepath.Ext(outputName)) return strings.TrimSuffix(outputName, filepath.Ext(outputName))
} }
func nativeDatasetDefaultOutputName(path string, kind nativeDatasetKind) string {
name := filepath.Base(path)
if kind == nativeDatasetPlain {
name = strings.TrimSuffix(name, filepath.Ext(name))
}
return name + ".2da"
}
func loadJSONObject(path string) (map[string]any, error) { func loadJSONObject(path string) (map[string]any, error) {
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
if err != nil { if err != nil {
+7 -21
View File
@@ -456,20 +456,7 @@ func validateDataObject(path string, obj map[string]any, report *ValidationRepor
func validateMasterfeatsBaseFile(path string, obj map[string]any, report *ValidationReport) { func validateMasterfeatsBaseFile(path string, obj map[string]any, report *ValidationReport) {
validateRowsFile(path, obj, report, true) validateRowsFile(path, obj, report, true)
validateDerivedBaseOutput(path, obj, "masterfeats", report)
if output, ok := obj["output"]; !ok {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: "masterfeats base.json must contain output set to masterfeats.2da",
})
} else if outputText, ok := output.(string); !ok || strings.TrimSpace(outputText) != "masterfeats.2da" {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: "masterfeats output must be masterfeats.2da",
})
}
columns := extractValidationColumns(obj) columns := extractValidationColumns(obj)
for _, required := range []string{"LABEL", "STRREF", "DESCRIPTION"} { for _, required := range []string{"LABEL", "STRREF", "DESCRIPTION"} {
@@ -520,18 +507,17 @@ func validateMasterfeatsLockFile(path string, obj map[string]any, report *Valida
func validateFeatBaseFile(path string, obj map[string]any, report *ValidationReport) { func validateFeatBaseFile(path string, obj map[string]any, report *ValidationReport) {
validateRowsFile(path, obj, report, true) validateRowsFile(path, obj, report, true)
validateDerivedBaseOutput(path, obj, "feat", report)
}
func validateDerivedBaseOutput(path string, obj map[string]any, datasetLabel string, report *ValidationReport) {
if output, ok := obj["output"]; !ok { if output, ok := obj["output"]; !ok {
return
} else if outputText, ok := output.(string); !ok || strings.TrimSpace(outputText) != nativeDatasetDefaultOutputName(filepath.Dir(path), nativeDatasetBase) {
report.Diagnostics = append(report.Diagnostics, Diagnostic{ report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError, Severity: SeverityError,
Path: path, Path: path,
Message: "feat base.json must contain output set to feat.2da", Message: fmt.Sprintf("%s output must be %s", datasetLabel, nativeDatasetDefaultOutputName(filepath.Dir(path), nativeDatasetBase)),
})
} else if outputText, ok := output.(string); !ok || strings.TrimSpace(outputText) != "feat.2da" {
report.Diagnostics = append(report.Diagnostics, Diagnostic{
Severity: SeverityError,
Path: path,
Message: "feat output must be feat.2da",
}) })
} }
} }
+69
View File
@@ -46,6 +46,39 @@ func TestValidateProjectAcceptsInlineTLKSchema(t *testing.T) {
} }
} }
func TestValidateAndBuildDerivesFeatOutputWhenOmitted(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "feat"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{
"columns": ["LABEL", "FEAT", "DESCRIPTION"],
"rows": [
{
"id": 0,
"key": "feat:test",
"LABEL": "FEAT_TEST",
"FEAT": {"tlk": {"key": "feat:test.name", "text": "Test Feat"}},
"DESCRIPTION": {"tlk": {"key": "feat:test.description", "text": "Test Description"}}
}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:test":0}`+"\n")
proj := testProject(root)
report := ValidateProject(proj)
if report.HasErrors() {
t.Fatalf("expected omitted feat output to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
}
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
if err != nil {
t.Fatalf("expected omitted feat output to build: %v", err)
}
if _, err := os.Stat(filepath.Join(result.Output2DADir, "feat.2da")); err != nil {
t.Fatalf("expected derived feat.2da output: %v", err)
}
}
func TestValidateProjectRejectsDuplicateJSONKeys(t *testing.T) { func TestValidateProjectRejectsDuplicateJSONKeys(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust")) mkdirAll(t, filepath.Join(root, "topdata", "data", "repadjust"))
@@ -860,6 +893,42 @@ func TestValidateProjectAcceptsCanonicalMasterfeatsContract(t *testing.T) {
} }
} }
func TestValidateAndBuildDerivesMasterfeatsOutputWhenOmitted(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))
writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "base.json"), `{
"columns": ["LABEL", "STRREF", "DESCRIPTION", "ICON"],
"rows": [
{
"id": 0,
"key": "masterfeats:weaponfocus",
"LABEL": "WeaponFocus",
"STRREF": "6490",
"DESCRIPTION": "436",
"ICON": "ife_wepfoc"
}
]
}`+"\n")
writeFile(t, filepath.Join(root, "topdata", "data", "masterfeats", "lock.json"), `{
"masterfeats:weaponfocus": 0
}`+"\n")
proj := testProject(root)
report := ValidateProject(proj)
if report.HasErrors() {
t.Fatalf("expected omitted masterfeats output to validate cleanly, got:\n%s", diagnosticsText(report.Diagnostics))
}
result, err := BuildNativeWithOptions(proj, NativeBuildOptions{BuildWiki: false}, nil)
if err != nil {
t.Fatalf("expected omitted masterfeats output to build: %v", err)
}
if _, err := os.Stat(filepath.Join(result.Output2DADir, "masterfeats.2da")); err != nil {
t.Fatalf("expected derived masterfeats.2da output: %v", err)
}
}
func TestValidateProjectRejectsInvalidMasterfeatsContract(t *testing.T) { func TestValidateProjectRejectsInvalidMasterfeatsContract(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats")) mkdirAll(t, filepath.Join(root, "topdata", "data", "masterfeats"))