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) } }