From 194164a77eb4f2479b94c19e6f0e1278ab608781 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 16:13:49 -0400 Subject: fix: more casino, economy, gameplay, security, and world data hardening gaps --- internal/world/mob.go | 5 +++++ internal/world/p2_hardening_test.go | 33 +++++++++++++++++++++++++++++++++ internal/world/room.go | 23 +++++++++++++++++++++++ internal/world/world.go | 10 +++++++++- 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 internal/world/p2_hardening_test.go (limited to 'internal/world') diff --git a/internal/world/mob.go b/internal/world/mob.go index b004643..631e3a3 100644 --- a/internal/world/mob.go +++ b/internal/world/mob.go @@ -2,6 +2,7 @@ package world import ( "fmt" + "log" "math/rand" "os" "path/filepath" @@ -441,6 +442,10 @@ func (s *MobStore) LoadDef(id string) (*MobDef, error) { if err := yaml.Unmarshal(data, &def); err != nil { return nil, fmt.Errorf("parse mob %s: %w", id, err) } + if def.ID != "" && def.ID != id { + log.Printf("mob %s: file %s declares id %q but is loaded as %q; the filename is authoritative and the declared id is ignored", + id, path, def.ID, id) + } def.ID = id s.mu.Lock() diff --git a/internal/world/p2_hardening_test.go b/internal/world/p2_hardening_test.go new file mode 100644 index 0000000..01f8a04 --- /dev/null +++ b/internal/world/p2_hardening_test.go @@ -0,0 +1,33 @@ +package world + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +// TestRoomObjectRejectsUnknownField: RoomObject.UnmarshalYAML uses +// Node.Decode, which does not inherit KnownFields(true), so without an +// explicit check a misspelled field inside a room's objects: entry is +// silently discarded even though the top-level room file is strict. +func TestRoomObjectRejectsUnknownField(t *testing.T) { + var ro RoomObject + if err := yaml.Unmarshal([]byte("name: anvil\nin_room_description: typo\n"), &ro); err == nil { + t.Fatal("accepted an unknown room object field") + } + // A legitimate local object still parses. + if err := yaml.Unmarshal([]byte("name: anvil\ninroom_description: A heavy anvil.\n"), &ro); err != nil { + t.Fatalf("rejected a valid local object: %v", err) + } +} + +// TestSeedGroundItemsDoesNotMarkUnloadedRoom: a room that fails to load must +// not be marked seeded, or its item_spawns stay disabled for the rest of the +// process even after the room file is fixed/created. +func TestSeedGroundItemsDoesNotMarkUnloadedRoom(t *testing.T) { + w := New(t.TempDir()) + w.SeedGroundItems(999) + if w.seeded[999] { + t.Fatal("a room that failed to load was marked seeded; its spawns would never appear") + } +} diff --git a/internal/world/room.go b/internal/world/room.go index f4390da..3783a73 100644 --- a/internal/world/room.go +++ b/internal/world/room.go @@ -1,6 +1,7 @@ package world import ( + "fmt" "strings" "gopkg.in/yaml.v3" @@ -122,6 +123,28 @@ type RoomObject struct { } func (ro *RoomObject) UnmarshalYAML(value *yaml.Node) error { + // yaml.v3's Node.Decode builds a fresh decoder that does NOT inherit + // KnownFields(true), so a misspelled key inside a room's objects: entry + // (inroom_description, onlook, ...) would be silently discarded even + // though the top-level room file is decoded strictly. Reject unknown + // keys here so the startup validator's "unknown field is an ERROR" + // guarantee extends to the region authors edit most. + if value.Kind != yaml.MappingNode { + return fmt.Errorf("room object must be a mapping") + } + allowed := map[string]bool{ + "id": true, "wander_rooms": true, "wander_interval": true, "hidden": true, + "name": true, "aliases": true, "color": true, "inroom_description": true, + "removal_item": true, "description": true, "on_use": true, "steal": true, + "gather": true, "talk": true, "safespot": true, "on_look": true, + } + for i := 0; i+1 < len(value.Content); i += 2 { + key := value.Content[i].Value + if !allowed[key] { + return fmt.Errorf("unknown room object field %q", key) + } + } + type rawRef struct { ID string `yaml:"id"` WanderRooms []int `yaml:"wander_rooms"` diff --git a/internal/world/world.go b/internal/world/world.go index 7475ce0..a084f63 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -91,16 +91,24 @@ func (w *World) SeedGroundItems(roomID int) { w.mu.Unlock() return } - w.seeded[roomID] = true w.mu.Unlock() room, err := w.LoadRoom(roomID) if err != nil { + // Do NOT mark a room seeded before it loads: a dangling exit (or a + // not-yet-created room) would otherwise permanently disable its item + // spawns even after the room is fixed/created. return } w.mu.Lock() defer w.mu.Unlock() + // Re-check under the lock: a concurrent seed may have won the race while + // this call was loading. + if w.seeded[roomID] { + return + } + w.seeded[roomID] = true for _, s := range room.ItemSpawns { merged := false -- cgit v1.2.3