aboutsummaryrefslogtreecommitdiff
path: root/internal/game/hardening_shipped_data_validation_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 05:31:09 -0400
committerhistoria <[not public]>2026-09-18 05:31:09 -0400
commit153acb7f644d755c45533b7c5311e3ffa47e8973 (patch)
tree7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/game/hardening_shipped_data_validation_test.go
parent87d08f0151e643951769e323995f1c5ed89ee200 (diff)
downloadthehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/game/hardening_shipped_data_validation_test.go')
-rw-r--r--internal/game/hardening_shipped_data_validation_test.go205
1 files changed, 205 insertions, 0 deletions
diff --git a/internal/game/hardening_shipped_data_validation_test.go b/internal/game/hardening_shipped_data_validation_test.go
new file mode 100644
index 0000000..dee464b
--- /dev/null
+++ b/internal/game/hardening_shipped_data_validation_test.go
@@ -0,0 +1,205 @@
+package game
+
+// Regression: the authored data/ tree must pass the full startup validator in
+// CI, not only when a server boots. Before this test the suite ran just
+// validateKnownFields; validate.Run (the twelve semantic/reference checks
+// behind game.ValidateAndLog) was reachable only from game.New, so broken
+// content (dangling references, bad craft byproducts, unreachable fail
+// branches, invalid coords) reached a running server before CI noticed.
+//
+// The gate fails on every ERROR, and on WARNs that are known-fatal dead data
+// (see fatalShippedDataWarnings). It deliberately does NOT fail on every WARN:
+// production only escalates ERROR (validate.LogIssues) and game.New never
+// refuses to boot on a WARN, and many WARNs are by-design informational (an
+// orphan room while content is authored, "has no name" placeholders, ignored
+// shared-drop-table hints). Failing on all of them made CI stricter than the
+// server and blocked legitimate content edits.
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "thehouseoficarus/internal/config"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/validate"
+ "thehouseoficarus/internal/world"
+)
+
+// fatalShippedDataWarnings lists WARN message substrings that still fail the
+// shipped-data gate. It keeps protection against dead fail data returning: a
+// declared fail product or fail_message on a recipe with no success formula can
+// never be produced or shown (see validate.craftFailureReachable). The second
+// entry keeps protection against a failable recipe that declares no fail
+// product, which consumes materials and yields nothing; every shipped failable
+// recipe returns one of its own ingredients.
+var fatalShippedDataWarnings = []string{
+ "is unreachable",
+ "failure destroys materials with no fail product",
+}
+
+// shippedDataIssueFails reports whether issue must fail the shipped-data gate:
+// every ERROR, plus a WARN matching fatalShippedDataWarnings.
+func shippedDataIssueFails(issue validate.Issue) bool {
+ if issue.Level == "ERROR" {
+ return true
+ }
+ for _, fatal := range fatalShippedDataWarnings {
+ if strings.Contains(issue.Message, fatal) {
+ return true
+ }
+ }
+ return false
+}
+
+// TestShippedDataPassesStartupValidation runs validate.Run over ../../data
+// using a Source equivalent to game.ValidateAndLog's (the same stores, techs
+// and configured root rooms).
+func TestShippedDataPassesStartupValidation(t *testing.T) {
+ dataDir := filepath.Join("..", "..", "data")
+ cfg, err := config.Load(filepath.Join("..", "..", "config.yaml"))
+ if err != nil {
+ t.Fatalf("load config.yaml: %v", err)
+ }
+
+ // A minimal, side-effect-free Game: the stores and config the validator
+ // reads, without game.New's character migration / journal recovery (which
+ // would write to the shipped tree).
+ g := &Game{
+ Deps: Deps{
+ World: world.New(dataDir),
+ ObjectStore: object.NewObjectStore(dataDir),
+ ItemStore: item.NewItemStore(dataDir),
+ MobStore: world.NewMobStore(dataDir),
+ CourseStore: NewCourseStore(dataDir),
+ DataDir: dataDir,
+ },
+ ValidationConfig: cfg.StartupValidation,
+ }
+ g.CourseStore.SetWorld(g.World)
+ g.CourseStore.LoadAll()
+ if err := g.LoadTechs(); err != nil {
+ t.Fatalf("load techs: %v", err)
+ }
+
+ for _, issue := range validate.Run(g.validationSource()) {
+ if shippedDataIssueFails(issue) {
+ t.Errorf("[%s] %s: %s", issue.Level, issue.Type, issue.Message)
+ }
+ }
+}
+
+// TestShippedDataGateCatchesDeadFailDataAndAllowsBenignWarns pins the gate's
+// severity policy: an ERROR or dead craft-fail WARN fails, a benign
+// content WARN (an orphan room) does not.
+func TestShippedDataGateCatchesDeadFailDataAndAllowsBenignWarns(t *testing.T) {
+ errIssue := validate.Issue{Level: "ERROR", Type: "reference", Message: "Room 1: spawns nonexistent item \"ghost\""}
+ if !shippedDataIssueFails(errIssue) {
+ t.Error("an ERROR must fail the shipped-data gate")
+ }
+
+ deadFail := validate.Issue{
+ Level: "WARN",
+ Type: "semantic",
+ Message: `Item "x" (craft: cooking): fail product "burnt_fish" is unreachable — success is always 100% (add a success formula)`,
+ }
+ if !shippedDataIssueFails(deadFail) {
+ t.Error("dead fail data must still fail the shipped-data gate")
+ }
+
+ orphan := validate.Issue{Level: "WARN", Type: "integrity", Message: "Orphan rooms (no exit leads to them): 1099"}
+ if shippedDataIssueFails(orphan) {
+ t.Error("a benign content WARN must not fail the shipped-data gate")
+ }
+}
+
+// TestShippedDataGateTripsOnDeadFailRecipe proves the gate still trips on a
+// real validator WARN emitted for a recipe whose fail branch can never run,
+// not merely on a hand-built Issue.
+func TestShippedDataGateTripsOnDeadFailRecipe(t *testing.T) {
+ dataDir := t.TempDir()
+ writeShippedDataFile(t, filepath.Join(dataDir, "items", "dead_fail.yaml"), `name: dead fail
+craft:
+ - type: cooking
+ fail: dead_fail
+ fail_message: You burn it.
+ ingredients:
+ - items: [dead_fail]
+ quantity: 1
+`)
+
+ src := validate.Source{
+ DataDir: dataDir,
+ Items: item.NewItemStore(dataDir),
+ Objects: object.NewObjectStore(dataDir),
+ Mobs: world.NewMobStore(dataDir),
+ World: world.New(dataDir),
+ }
+
+ found := false
+ for _, issue := range validate.Run(src) {
+ if !strings.Contains(issue.Message, "is unreachable") {
+ continue
+ }
+ found = true
+ if !shippedDataIssueFails(issue) {
+ t.Fatalf("dead-fail WARN must fail the shipped-data gate, got: %+v", issue)
+ }
+ }
+ if !found {
+ t.Fatal("expected a dead-fail WARN for a recipe with an unreachable fail branch")
+ }
+}
+
+// TestShippedSmeltingBarsRecoverMaterialOnFailure verifies fix for the
+// regression where the bars gained a success formula but no fail product: a
+// lost roll consumed every ingredient and returned nothing. A smelt that can
+// fail must return one of its own ingredient ids.
+func TestShippedSmeltingBarsRecoverMaterialOnFailure(t *testing.T) {
+ dataDir := filepath.Join("..", "..", "data")
+ defs, err := item.NewItemStore(dataDir).LoadAll()
+ if err != nil {
+ t.Fatalf("load items: %v", err)
+ }
+
+ smelts := 0
+ for _, def := range defs {
+ for ci := range def.Craft {
+ c := &def.Craft[ci]
+ if c.Subtype != "smelting" || c.Success == nil {
+ continue
+ }
+ smelts++
+ if c.Fail == "" {
+ t.Errorf("Item %q: a failing smelt consumes every ingredient but declares no fail product", def.ID)
+ continue
+ }
+ returned := false
+ for _, e := range c.Ingredients {
+ for _, id := range e.Items {
+ if id == c.Fail {
+ returned = true
+ }
+ }
+ }
+ if !returned {
+ t.Errorf("Item %q: fail product %q is not one of the recipe's own ingredients", def.ID, c.Fail)
+ }
+ }
+ }
+ if smelts == 0 {
+ t.Fatal("expected at least one smelting recipe with a success formula")
+ }
+}
+
+func writeShippedDataFile(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}