diff options
| author | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
| commit | 153acb7f644d755c45533b7c5311e3ffa47e8973 (patch) | |
| tree | 7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/game/hardening_shipped_data_validation_test.go | |
| parent | 87d08f0151e643951769e323995f1c5ed89ee200 (diff) | |
| download | thehouseoficarus-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.go | 205 |
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) + } +} |
