aboutsummaryrefslogtreecommitdiff
path: root/internal/game/hardening_shipped_data_validation_test.go
blob: dee464bbc5046ec1d409752319af352f984790ed (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
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)
	}
}