aboutsummaryrefslogtreecommitdiff
path: root/internal/validate
diff options
context:
space:
mode:
Diffstat (limited to 'internal/validate')
-rw-r--r--internal/validate/checks.go67
-rw-r--r--internal/validate/regression_test.go45
-rw-r--r--internal/validate/shipped_data_test.go20
-rw-r--r--internal/validate/validate.go79
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)