aboutsummaryrefslogtreecommitdiff
path: root/internal/validate
diff options
context:
space:
mode:
Diffstat (limited to 'internal/validate')
-rw-r--r--internal/validate/checks.go63
-rw-r--r--internal/validate/correct_audit_fixes_test.go69
2 files changed, 130 insertions, 2 deletions
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index 8fe8e72..c636b4f 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -15,6 +15,7 @@ import (
"thehouseoficarus/internal/color"
"thehouseoficarus/internal/combat"
"thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/object"
"thehouseoficarus/internal/world"
)
@@ -427,6 +428,13 @@ func validateMobCombatStats(id string, c *world.MobCombat) []Issue {
Message: fmt.Sprintf("Mob %q: stats.speed must be > 0 (0 is clipped to 1 tick, so the mob would attack every tick)", id),
})
}
+ if c.Size != "" && object.MobSizeRank[c.Size] == 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Mob %q: size %q is not a known size (small/medium/large/massive); safespot cover would misjudge it", id, c.Size),
+ })
+ }
for _, t := range c.AttackTypes {
switch {
case combat.IsMeleeType(t):
@@ -759,6 +767,37 @@ func validateObjects(s Source) []Issue {
issues = append(issues, validateGather(fmt.Sprintf("Object %q: gather", id), obj.Gather, itemIDs, dropIDs)...)
}
+ if obj.Safespot != nil {
+ if obj.Safespot.MaxBlockSize != "" && object.MobSizeRank[obj.Safespot.MaxBlockSize] == 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Object %q: safespot.max_block_size %q is not a known size (small/medium/large/massive); cover would be disabled", id, obj.Safespot.MaxBlockSize),
+ })
+ }
+ if obj.Safespot.MaxOccupants < 0 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Object %q: safespot.max_occupants must be >= 0", id),
+ })
+ }
+ if obj.Safespot.UnsafeChance < 0 || obj.Safespot.UnsafeChance > 1 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Object %q: safespot.unsafe_chance must be between 0 and 1", id),
+ })
+ }
+ if obj.Safespot.DecayChance < 0 || obj.Safespot.DecayChance > 1 {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Object %q: safespot.decay_chance must be between 0 and 1", id),
+ })
+ }
+ }
+
for i := range obj.Description {
if c := obj.Description[i].Condition; c != nil {
issues = append(issues, validateCondition(fmt.Sprintf("Object %q: description[%d]", id, i), c, roomIndex, itemIDs)...)
@@ -851,6 +890,8 @@ func validateCraftBlocks(s Source) []Issue {
// item id: every runtime consumer matches it against ItemDef.ToolType() via
// hasToolType. Types such as "pickaxe" or "big_net" have no same-named item
// file, so membership is checked against the types the item defs define.
+ // Capture the package-level set before the loop shadows `item`.
+ knownCraftKeys := item.KnownCraftKeys
toolTypes := make(map[string]bool)
for _, def := range allItems {
if t := def.ToolType(); t != "" {
@@ -870,6 +911,24 @@ func validateCraftBlocks(s Source) []Issue {
craftLabel = c.Type + "/" + c.Subtype
}
+ // The runtime selects the action by subtype (or type when no subtype
+ // is set), defaulting to combine. An unknown key leaves the action
+ // set but never advancing, so the player appears stuck.
+ cKey := c.Subtype
+ if cKey == "" {
+ cKey = c.Type
+ }
+ if cKey == "" {
+ cKey = "combine"
+ }
+ if !knownCraftKeys[cKey] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "config",
+ Message: fmt.Sprintf("Item %q (craft: %s): unknown type/subtype %q; the action would never advance", item.ID, craftLabel, cKey),
+ })
+ }
+
// SuccessChance clamps to Cap; an omitted cap makes the recipe
// fail every time (see craftFailureReachable, which would then
// mask it as an intentional always-fail recipe).
@@ -1491,7 +1550,7 @@ func validateRoomGrid(s Source) []Issue {
onConf := func(origin int) func(c world.GridConflict) {
return func(c world.GridConflict) {
switch c.Kind {
- case "twist":
+ case world.GridConflictTwist:
issues = append(issues, Issue{
Level: "ERROR",
Type: "integrity",
@@ -1499,7 +1558,7 @@ func validateRoomGrid(s Source) []Issue {
"Grid twist: room %d (via %d %s) maps to (%d,%d,%d) but was already placed at (%d,%d,%d) [origin %d]",
c.Target, c.From, c.Dir, c.Want[0], c.Want[1], c.Want[2], c.Existing[0], c.Existing[1], c.Existing[2], origin),
})
- case "overlap":
+ case world.GridConflictOverlap:
issues = append(issues, Issue{
Level: "ERROR",
Type: "integrity",
diff --git a/internal/validate/correct_audit_fixes_test.go b/internal/validate/correct_audit_fixes_test.go
index f11dd3f..c07ffcc 100644
--- a/internal/validate/correct_audit_fixes_test.go
+++ b/internal/validate/correct_audit_fixes_test.go
@@ -1,10 +1,15 @@
package validate
import (
+ "os"
+ "path/filepath"
"strings"
"testing"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/object"
+ "thehouseoficarus/internal/world"
)
// Regression: a talk config with nodes but no "start" node is dead content at
@@ -32,6 +37,70 @@ func TestValidateTalkConfigRequiresStartNode(t *testing.T) {
}
}
+// Regression: an unknown craft type/subtype leaves the action set but never
+// advancing, so the player appears stuck; it must be an ERROR.
+func TestValidateCraftBlocksRejectsUnknownType(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "items"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "items", "thing.yaml"),
+ []byte("name: thing\ncraft:\n - type: smiting\n ingredients:\n - items: [ore]\n quantity: 1\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ src := Source{
+ DataDir: dir,
+ Items: item.NewItemStore(dir),
+ Objects: object.NewObjectStore(dir),
+ Mobs: world.NewMobStore(dir),
+ World: world.New(dir),
+ }
+ found := false
+ for _, issue := range validateCraftBlocks(src) {
+ if strings.Contains(issue.Message, "unknown type/subtype") {
+ found = true
+ if issue.Level != "ERROR" {
+ t.Fatalf("unknown craft type must be an ERROR: %+v", issue)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("expected an ERROR for an unknown craft type/subtype")
+ }
+}
+
+// Regression: an unknown safespot max_block_size silently disables cover; it
+// must be an ERROR.
+func TestValidateObjectsRejectsUnknownSafespotSize(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "objects"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "objects", "rock.yaml"),
+ []byte("name: rock\nsafespot:\n max_block_size: huge\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ src := Source{
+ DataDir: dir,
+ Items: item.NewItemStore(dir),
+ Objects: object.NewObjectStore(dir),
+ Mobs: world.NewMobStore(dir),
+ World: world.New(dir),
+ }
+ found := false
+ for _, issue := range validateObjects(src) {
+ if strings.Contains(issue.Message, "max_block_size") {
+ found = true
+ if issue.Level != "ERROR" {
+ t.Fatalf("unknown safespot size must be an ERROR: %+v", issue)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("expected an ERROR for an unknown safespot.max_block_size")
+ }
+}
+
// Regression: a gather drop with weight <= 0 can never be selected (the
// weighted roll has zero total); it must be flagged.
func TestValidateGatherWarnsOnWeightlessDrop(t *testing.T) {