From 8fe9e3c3fef0049df82281a62afe35dda516dfe2 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 17:48:26 -0400 Subject: fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps --- internal/validate/checks.go | 64 ++++++++++++++++++++++++++++++---- internal/validate/shipped_data_test.go | 2 +- internal/validate/validate.go | 37 ++++++++++++++------ 3 files changed, 86 insertions(+), 17 deletions(-) (limited to 'internal/validate') 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 "_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 -- cgit v1.2.3