aboutsummaryrefslogtreecommitdiff
path: root/internal/validate
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 17:48:26 -0400
committerhistoria <[not public]>2026-09-18 17:48:26 -0400
commit8fe9e3c3fef0049df82281a62afe35dda516dfe2 (patch)
treedd269f2c0710f334e19f00a191260ebc46284a90 /internal/validate
parent194164a77eb4f2479b94c19e6f0e1278ab608781 (diff)
downloadthehouseoficarus-8fe9e3c3fef0049df82281a62afe35dda516dfe2.tar.gz
fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps
Diffstat (limited to 'internal/validate')
-rw-r--r--internal/validate/checks.go64
-rw-r--r--internal/validate/shipped_data_test.go2
-rw-r--r--internal/validate/validate.go37
3 files changed, 86 insertions, 17 deletions
diff --git a/internal/validate/checks.go b/internal/validate/checks.go
index bfa8342..aee46f2 100644
--- a/internal/validate/checks.go
+++ b/internal/validate/checks.go
@@ -531,6 +531,10 @@ func validateMobs(s Source) []Issue {
}
}
+ if def.Talk != nil {
+ issues = append(issues, validateTalkConfig(fmt.Sprintf("Mob %q: talk", id), def.Talk, itemIDs, roomIndex, mobIDs, dropIDs)...)
+ }
+
if def.Combat != nil && def.Combat.FinishingBlow != "" && !itemIDs[def.Combat.FinishingBlow] {
issues = append(issues, Issue{
Level: "ERROR",
@@ -709,6 +713,7 @@ func validateObjects(s Source) []Issue {
func validateItems(s Source) []Issue {
var issues []Issue
itemIDs := s.Items.IDSet()
+ objIDs := s.Objects.IDSet()
dropIDs := dropTableIDSet(s.DataDir)
roomIndex := s.World.RoomIndex()
mobIDs := s.Mobs.AllDefIDs()
@@ -744,6 +749,17 @@ func validateItems(s Source) []Issue {
id, def.FarmProduct),
})
}
+
+ // The runtime derives "<farm_patch_type>_patch" and requires that
+ // object to exist; a type with no matching patch can never be planted.
+ if def.FarmPatchType != "" && !objIDs[def.FarmPatchType+"_patch"] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (seed): farm_patch_type %q has no matching %q object",
+ id, def.FarmPatchType, def.FarmPatchType+"_patch"),
+ })
+ }
}
return issues
@@ -1117,6 +1133,16 @@ func validateCourses(s Source) []Issue {
})
}
}
+ for _, rid := range cv.OnFailRooms {
+ if _, ok := roomIndex[rid]; !ok {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Course %q: obstacle on_fail room %d does not exist (a failed obstacle would teleport the player into a void)",
+ cv.ID, rid),
+ })
+ }
+ }
}
return issues
@@ -1268,7 +1294,7 @@ func validateRoomWiring(s Source) []Issue {
// does not exist: the game assigns the id unconditionally, so the player is
// saved into a void with no exits.
func validateModuleDestinations(s Source) []Issue {
- if len(s.ModuleDestinations) == 0 {
+ if len(s.ModuleDestinations) == 0 && len(s.TransportModules) == 0 {
return nil
}
roomIndex := s.World.RoomIndex()
@@ -1289,6 +1315,24 @@ func validateModuleDestinations(s Source) []Issue {
Message: fmt.Sprintf("Module %q: destination room %d does not exist", id, dest),
})
}
+ // A transport module whose destination was omitted or <= 0 is not in
+ // ModuleDestinations (the source only records positive destinations), so
+ // report it explicitly: the runtime assigns the id unconditionally.
+ tids := make([]string, 0, len(s.TransportModules))
+ for id := range s.TransportModules {
+ tids = append(tids, id)
+ }
+ sort.Strings(tids)
+ for _, id := range tids {
+ if dest, ok := s.ModuleDestinations[id]; ok && dest > 0 {
+ continue
+ }
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Module %q: transport module has no valid destination room (the player would be saved into room 0)", id),
+ })
+ }
return issues
}
@@ -1691,14 +1735,14 @@ func validateStep(prefix string, step *behavior.Step, itemIDs map[string]bool, r
}
if step.Condition != nil {
- issues = append(issues, validateCondition(prefix, step.Condition, roomIndex)...)
+ issues = append(issues, validateCondition(prefix, step.Condition, roomIndex, itemIDs)...)
}
return issues
}
// validateCondition checks a Condition tree's Room references (structural room
// IDs) and recurses into all_of/any_of.
-func validateCondition(prefix string, c *behavior.Condition, roomIndex map[int]bool) []Issue {
+func validateCondition(prefix string, c *behavior.Condition, roomIndex map[int]bool, itemIDs map[string]bool) []Issue {
if c == nil {
return nil
}
@@ -1713,11 +1757,19 @@ func validateCondition(prefix string, c *behavior.Condition, roomIndex map[int]b
})
}
}
+ if c.HasItem != "" && !itemIDs[c.HasItem] {
+ issues = append(issues, Issue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: condition has_item references nonexistent item %q (the gate can never pass)",
+ prefix, c.HasItem),
+ })
+ }
for i := range c.AllOf {
- issues = append(issues, validateCondition(prefix, &c.AllOf[i], roomIndex)...)
+ issues = append(issues, validateCondition(prefix, &c.AllOf[i], roomIndex, itemIDs)...)
}
for i := range c.AnyOf {
- issues = append(issues, validateCondition(prefix, &c.AnyOf[i], roomIndex)...)
+ issues = append(issues, validateCondition(prefix, &c.AnyOf[i], roomIndex, itemIDs)...)
}
return issues
}
@@ -1755,7 +1807,7 @@ func validateTriggerBlock(prefix string, block []behavior.Trigger, itemIDs map[s
})
}
if t.Condition != nil {
- issues = append(issues, validateCondition(entryPrefix, t.Condition, roomIndex)...)
+ issues = append(issues, validateCondition(entryPrefix, t.Condition, roomIndex, itemIDs)...)
}
for si := range t.Steps {
diff --git a/internal/validate/shipped_data_test.go b/internal/validate/shipped_data_test.go
index 9c5bef1..f03b1f4 100644
--- a/internal/validate/shipped_data_test.go
+++ b/internal/validate/shipped_data_test.go
@@ -12,7 +12,7 @@ import (
// silently discarded at load time.
func TestShippedDataHasNoUnknownFields(t *testing.T) {
dataDir := filepath.Join("..", "..", "data")
- for _, issue := range validateKnownFields(dataDir) {
+ for _, issue := range validateKnownFields(dataDir, nil) {
if issue.Level == "ERROR" {
t.Errorf("%s", issue.Message)
}
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
index 6a05eb6..d77eafa 100644
--- a/internal/validate/validate.go
+++ b/internal/validate/validate.go
@@ -35,6 +35,18 @@ type CourseView struct {
ID string
StartRoom int
ObstacleRooms []int
+ // OnFailRooms are the authored on_fail destinations (>0). The runtime
+ // teleports to them unconditionally on an obstacle failure.
+ OnFailRooms []int
+}
+
+// KnownFieldTarget lets the game package contribute its own content types to
+// the KnownFields decode pass; the validate package cannot import game
+// without a cycle.
+type KnownFieldTarget struct {
+ SubDir string
+ Label string
+ NewDef func() any
}
// TechView is the subset of a tech definition needed for validation.
@@ -64,6 +76,13 @@ type Source struct {
// ModuleDestinations maps module id to destination room for transport
// modules. A destination that is not a real room soft-locks the player.
ModuleDestinations map[string]int
+ // TransportModules is the set of transport-category module IDs, so a
+ // transport module whose destination is omitted or <= 0 is still
+ // reported (the runtime saves the player into room 0).
+ TransportModules map[string]bool
+ // KnownFieldTargets are game-owned content types (techs, modules,
+ // courses, help) included in the strict KnownFields decode pass.
+ KnownFieldTargets []KnownFieldTarget
}
// Run executes all checks and returns the collected issues.
@@ -79,7 +98,7 @@ func Run(s Source) []Issue {
issues = append(issues, validateIDs(s.DataDir, dir.sub, dir.label)...)
}
- issues = append(issues, validateKnownFields(s.DataDir)...)
+ issues = append(issues, validateKnownFields(s.DataDir, s.KnownFieldTargets)...)
issues = append(issues, validateRoomFilenames(s.DataDir)...)
issues = append(issues, validateRooms(s)...)
@@ -126,31 +145,29 @@ func validateRoomFilenames(dataDir string) []Issue {
// silently discarded by yaml.Unmarshal. Only package-owned content types are
// covered (world/object/item/behavior); game-owned definitions are checked by
// their own loaders.
-func validateKnownFields(dataDir string) []Issue {
- targets := []struct {
- subDir string
- label string
- newDef func() any
- }{
+func validateKnownFields(dataDir string, extra []KnownFieldTarget) []Issue {
+ targets := []KnownFieldTarget{
{"rooms", "room", func() any { return new(world.Room) }},
{"mobs", "mob", func() any { return new(world.MobDef) }},
{"objects", "object", func() any { return new(object.ObjectDef) }},
{"items", "item", func() any { return new(item.ItemDef) }},
{"drops", "drop table", func() any { return new(behavior.DropTableDef) }},
{"hazards", "hazard", func() any { return new(world.HazardDef) }},
+ {"triggers", "trigger", func() any { return new(behavior.Trigger) }},
}
+ targets = append(targets, extra...)
var issues []Issue
for _, t := range targets {
- dir := filepath.Join(dataDir, t.subDir)
+ dir := filepath.Join(dataDir, t.SubDir)
_ = behavior.WalkYAMLDir(dir, func(path, id string, data []byte) error {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
- if err := dec.Decode(t.newDef()); err != nil {
+ if err := dec.Decode(t.NewDef()); err != nil {
rel := strings.TrimPrefix(path, dataDir+"/")
issues = append(issues, Issue{
Level: "ERROR",
Type: "unknown-field",
- Message: fmt.Sprintf("%s %s: %v", t.label, rel, err),
+ Message: fmt.Sprintf("%s %s: %v", t.Label, rel, err),
})
}
return nil