diff options
| author | historia <[not public]> | 2026-09-18 01:50:17 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 01:50:17 -0400 |
| commit | 87d08f0151e643951769e323995f1c5ed89ee200 (patch) | |
| tree | dc42a0b30ebb5a00dd7ba505cc4eaa44f4f308bc /internal/validate | |
| parent | 32a3c59cebf2a16277e54d715a67adfdbe00b456 (diff) | |
| download | thehouseoficarus-87d08f0151e643951769e323995f1c5ed89ee200.tar.gz | |
fix: harden economy recovery, shutdown concurrency, world validation, and inventory-loss paths
Diffstat (limited to 'internal/validate')
| -rw-r--r-- | internal/validate/checks.go | 67 | ||||
| -rw-r--r-- | internal/validate/regression_test.go | 45 | ||||
| -rw-r--r-- | internal/validate/shipped_data_test.go | 20 | ||||
| -rw-r--r-- | internal/validate/validate.go | 79 |
4 files changed, 211 insertions, 0 deletions
diff --git a/internal/validate/checks.go b/internal/validate/checks.go index 9d09eef..5eb4288 100644 --- a/internal/validate/checks.go +++ b/internal/validate/checks.go @@ -736,6 +736,7 @@ func validateDropTables(s Source) []Issue { var issues []Issue dropIDs := dropTableIDSet(s.DataDir) itemIDs := s.Items.IDSet() + issues = append(issues, validateDropTableCycles(s, dropIDs)...) for id := range dropIDs { dt, err := behavior.LoadDropTable(s.DataDir, id) @@ -838,6 +839,72 @@ func validateDropTables(s Source) []Issue { return issues } +// validateDropTableCycles reports every table that participates in a +// reference cycle. ResolveDrop recurses at runtime, so a self- or +// mutual-reference would otherwise overflow the goroutine stack and kill the +// process; a cycle is therefore a hard startup error. +func validateDropTableCycles(s Source, dropIDs map[string]bool) []Issue { + edges := make(map[string][]string, len(dropIDs)) + for id := range dropIDs { + dt, err := behavior.LoadDropTable(s.DataDir, id) + if err != nil { + continue // reported by the main pass + } + for _, d := range dt.Drops { + if d.Table != "" && dropIDs[d.Table] { + edges[id] = append(edges[id], d.Table) + } + } + } + const ( + white = 0 + gray = 1 + black = 2 + ) + color := make(map[string]int, len(dropIDs)) + reported := make(map[string]bool) + var stack []string + var issues []Issue + var visit func(id string) + visit = func(id string) { + color[id] = gray + stack = append(stack, id) + for _, next := range edges[id] { + switch color[next] { + case gray: + start := 0 + for i, m := range stack { + if m == next { + start = i + break + } + } + for _, m := range stack[start:] { + if reported[m] { + continue + } + reported[m] = true + issues = append(issues, Issue{ + Level: "ERROR", + Type: "integrity", + Message: fmt.Sprintf("Drop table %q participates in a reference cycle; drop-table recursion would overflow the stack", m), + }) + } + case white: + visit(next) + } + } + stack = stack[:len(stack)-1] + color[id] = black + } + for id := range dropIDs { + if color[id] == white { + visit(id) + } + } + return issues +} + func validateCourses(s Source) []Issue { var issues []Issue roomIndex := s.World.RoomIndex() diff --git a/internal/validate/regression_test.go b/internal/validate/regression_test.go new file mode 100644 index 0000000..53475bd --- /dev/null +++ b/internal/validate/regression_test.go @@ -0,0 +1,45 @@ +package validate + +import ( + "path/filepath" + "testing" + + "thehouseoficarus/internal/behavior" +) + +// TestDropTableCycleIsAnError: a self/mutual drop-table reference must be a +// startup ERROR, because ResolveDrop recurses and would otherwise overflow the +// goroutine stack at runtime. +func TestDropTableCycleIsAnError(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "drops", "a.yaml"), "id: a\ndrops:\n - table: b\n weight: 1\n") + writeFile(t, filepath.Join(dir, "drops", "b.yaml"), "id: b\ndrops:\n - table: a\n weight: 1\n") + behavior.ClearDropIndex() + defer behavior.ClearDropIndex() + + issues := validateDropTableCycles(Source{DataDir: dir}, dropTableIDSet(dir)) + if len(issues) == 0 { + t.Fatal("expected a cycle error for a <-> b") + } + for _, issue := range issues { + if issue.Level != "ERROR" { + t.Fatalf("cycle issue must be ERROR: %+v", issue) + } + } +} + +// TestNonNumericRoomFilenameIsAnError: the room index only accepts numeric +// stems, so a backup file must be flagged instead of silently ignored. +func TestNonNumericRoomFilenameIsAnError(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "rooms", "1001.yaml"), "id: 1001\nname: real\n") + writeFile(t, filepath.Join(dir, "rooms", "1001_copy.yaml"), "id: 1001\nname: copy\n") + + issues := validateRoomFilenames(dir) + if len(issues) != 1 { + t.Fatalf("expected exactly one filename issue, got %+v", issues) + } + if issues[0].Level != "ERROR" { + t.Fatalf("expected ERROR, got %+v", issues[0]) + } +} diff --git a/internal/validate/shipped_data_test.go b/internal/validate/shipped_data_test.go new file mode 100644 index 0000000..9c5bef1 --- /dev/null +++ b/internal/validate/shipped_data_test.go @@ -0,0 +1,20 @@ +package validate + +import ( + "path/filepath" + "testing" +) + +// TestShippedDataHasNoUnknownFields guards the authored content tree against +// misspelled or stale YAML keys: every data file is decoded with +// KnownFields(true), so an unread key (like the old exit-level +// set_player_flags or the legacy triggers key) fails CI instead of being +// silently discarded at load time. +func TestShippedDataHasNoUnknownFields(t *testing.T) { + dataDir := filepath.Join("..", "..", "data") + for _, issue := range validateKnownFields(dataDir) { + if issue.Level == "ERROR" { + t.Errorf("%s", issue.Message) + } + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 735c9ef..8ca4555 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -6,13 +6,18 @@ package validate import ( + "bytes" "fmt" "os" "path/filepath" "sort" + "strconv" "strings" + "gopkg.in/yaml.v3" + "thehouseoficarus/internal/behavior" + "thehouseoficarus/internal/cardart" "thehouseoficarus/internal/item" "thehouseoficarus/internal/object" "thehouseoficarus/internal/world" @@ -67,6 +72,9 @@ func Run(s Source) []Issue { issues = append(issues, validateIDs(s.DataDir, dir.sub, dir.label)...) } + issues = append(issues, validateKnownFields(s.DataDir)...) + issues = append(issues, validateRoomFilenames(s.DataDir)...) + issues = append(issues, validateRooms(s)...) issues = append(issues, validateMobs(s)...) issues = append(issues, validateHazards(s)...) @@ -84,6 +92,77 @@ func Run(s Source) []Issue { return issues } +// validateRoomFilenames flags room files whose name is not a plain integer: +// the room index only accepts numeric stems, so a file like +// "1001_copy.yaml" would otherwise be silently ignored (and, before the +// strconv fix, could shadow the real room 1001). +func validateRoomFilenames(dataDir string) []Issue { + dir := filepath.Join(dataDir, "rooms") + var issues []Issue + _ = behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { + if _, err := strconv.Atoi(id); err != nil { + rel := strings.TrimPrefix(path, dataDir+"/") + issues = append(issues, Issue{ + Level: "ERROR", + Type: "integrity", + Message: fmt.Sprintf("room %s: filename is not a numeric room ID and the file is ignored", rel), + }) + } + return nil + }) + return issues +} + +// validateKnownFields decodes every authored data file with KnownFields(true) +// so a misspelled or stale YAML key is a startup ERROR instead of being +// silently discarded by yaml.Unmarshal. Only package-owned content types are +// covered (world/object/item/behavior); game-owned definitions are checked by +// their own loaders. +func validateKnownFields(dataDir string) []Issue { + targets := []struct { + subDir string + label string + newDef func() any + }{ + {"rooms", "room", func() any { return new(world.Room) }}, + {"mobs", "mob", func() any { return new(world.MobDef) }}, + {"objects", "object", func() any { return new(object.ObjectDef) }}, + {"items", "item", func() any { return new(item.ItemDef) }}, + {"drops", "drop table", func() any { return new(behavior.DropTableDef) }}, + {"hazards", "hazard", func() any { return new(world.HazardDef) }}, + } + var issues []Issue + for _, t := range targets { + dir := filepath.Join(dataDir, t.subDir) + _ = behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error { + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(t.newDef()); err != nil { + rel := strings.TrimPrefix(path, dataDir+"/") + issues = append(issues, Issue{ + Level: "ERROR", + Type: "unknown-field", + Message: fmt.Sprintf("%s %s: %v", t.label, rel, err), + }) + } + return nil + }) + } + // cards.yaml lives at the data root rather than a subdirectory. + if data, err := os.ReadFile(filepath.Join(dataDir, "cards.yaml")); err == nil { + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(new(cardart.Store)); err != nil { + issues = append(issues, Issue{ + Level: "ERROR", + Type: "unknown-field", + Message: fmt.Sprintf("cards.yaml: %v", err), + }) + } + } + return issues +} + func validateIDs(dataDir, subDir, label string) []Issue { dir := filepath.Join(dataDir, subDir) dups := behavior.CheckDuplicateIDs(dir) |
