From cc33072eced3d5460512c09c42ec4531c807d7af Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Sat, 19 Sep 2026 01:55:34 -0400 Subject: fix: casino stake preservation, world state reconciliation, casino game registration --- internal/world/fix_regressions_test.go | 153 +++++++++++++++++++++++++++ internal/world/objects.go | 182 ++++++++++++++++++++++++++++----- internal/world/world.go | 22 +++- 3 files changed, 328 insertions(+), 29 deletions(-) create mode 100644 internal/world/fix_regressions_test.go (limited to 'internal/world') diff --git a/internal/world/fix_regressions_test.go b/internal/world/fix_regressions_test.go new file mode 100644 index 0000000..f2ef795 --- /dev/null +++ b/internal/world/fix_regressions_test.go @@ -0,0 +1,153 @@ +package world + +import "testing" + +// TestEnsureObjectStatesDoesNotDuplicateWanderer: a wandering object leaves its +// home room's key behind. Re-entering the home room must recognize the state by +// HomeRoomID and not create a second, independent instance (which would make the +// object visible/gatherable in two rooms and eventually silently discard one +// state when they collide). +func TestEnsureObjectStatesDoesNotDuplicateWanderer(t *testing.T) { + w := New(t.TempDir()) + w.AddRoomPath(1, "room-1") + w.AddRoomPath(2, "room-2") + w.EnsureObjectStates(1, []string{"fishing_spot"}) + st := w.GetObjState(1, "fishing_spot", 0) + if st == nil { + t.Fatal("setup: object state was not created") + } + st.WanderRooms = []int{2} + st.WanderInterval = 1 + w.TickObjWander() + + if w.GetObjState(2, "fishing_spot", 0) == nil { + t.Fatal("setup: the object did not wander to room 2") + } + + // Re-enter the home room: it must not spawn a duplicate. + w.EnsureObjectStates(1, []string{"fishing_spot"}) + if got := len(w.AllObjInstances(1)); got != 0 { + t.Fatalf("home room shows %d instances while the object wanders; want 0", got) + } + if got := len(w.AllObjInstances(2)); got != 1 { + t.Fatalf("wandered room shows %d instances; want 1", got) + } +} + +// TestRemoveObjInstanceIndexRemovesOnlyNamed: a room may declare the same object +// twice. Destroying one instance must remove exactly that index, not every +// state for the defID (which erased an untouched sibling). +func TestRemoveObjInstanceIndexRemovesOnlyNamed(t *testing.T) { + w := New(t.TempDir()) + w.EnsureObjectStates(1, []string{"safespot", "safespot"}) + if got := len(w.AllObjInstances(1)); got != 2 { + t.Fatalf("setup: expected two instances, got %d", got) + } + + w.RemoveObjInstanceIndex(1, "safespot", 1) + got := w.AllObjInstances(1) + if len(got) != 1 { + t.Fatalf("removing index 1 left %d instances; want 1", len(got)) + } + if got[0].Index != 0 { + t.Fatalf("removed the wrong instance: surviving index=%d, want 0", got[0].Index) + } +} + +// TestReconcileRoomStateAddsPrunesAndPreserves: a live content edit must add +// newly declared objects/spawns and drop removed ones, while preserving +// dynamic instances (fires) and player-dropped ground items, and must not +// double a spawn on a second reconcile. +func TestReconcileRoomStateAddsPrunesAndPreserves(t *testing.T) { + w := New(t.TempDir()) + w.EnsureObjectStates(1, []string{"anvil", "anvil"}) + w.AddObjInstance(1, "fire", 5) // dynamic instance, not in the room file + w.AddGroundItem(1, "loot", 1) // player-dropped, not a spawn + w.mu.Lock() + w.seeded[1] = true + w.mu.Unlock() + + room := &Room{ + ID: 1, + Objects: []RoomObject{{ID: "anvil"}}, + ItemSpawns: []SpawnDef{{ID: "ore", Quantity: 3, RespawnTicks: 5}}, + } + w.ReconcileRoomState(1, room) + + anvils, fires := 0, 0 + for _, st := range w.AllObjInstances(1) { + switch st.DefID { + case "anvil": + anvils++ + case "fire": + fires++ + } + } + if anvils != 1 { + t.Fatalf("prune left %d anvils; want 1", anvils) + } + if fires != 1 { + t.Fatalf("reconcile pruned the dynamic fire instance (fires=%d)", fires) + } + ground := w.AccessibleGround(1, "alice") + if ground["ore"] != 3 { + t.Fatalf("new item spawn quantity = %d, want 3", ground["ore"]) + } + if ground["loot"] != 1 { + t.Fatalf("reconcile destroyed a player-dropped item (loot=%d)", ground["loot"]) + } + + // A second reconcile must be idempotent (no doubled spawn). + w.ReconcileRoomState(1, room) + if got := w.AccessibleGround(1, "alice")["ore"]; got != 3 { + t.Fatalf("second reconcile doubled the spawn: ore=%d, want 3", got) + } + + // Removing the spawn from the definition drops its ground entry. + w.ReconcileRoomState(1, &Room{ID: 1, Objects: []RoomObject{{ID: "anvil"}}}) + if got := w.AccessibleGround(1, "alice")["ore"]; got != 0 { + t.Fatalf("removed item spawn still on the ground: ore=%d", got) + } +} + +// TestReconcileRoomStateLeavesVisitingWanderer: reconciling a room must not +// prune an object that merely wandered in from a different home room. +func TestReconcileRoomStateLeavesVisitingWanderer(t *testing.T) { + w := New(t.TempDir()) + w.AddRoomPath(1, "room-1") + w.AddRoomPath(2, "room-2") + w.EnsureObjectStates(1, []string{"fishing_spot"}) + st := w.GetObjState(1, "fishing_spot", 0) + st.WanderRooms = []int{2} + st.WanderInterval = 1 + w.TickObjWander() + if w.GetObjState(2, "fishing_spot", 0) == nil { + t.Fatal("setup: the object did not wander to room 2") + } + + // Room 2 declares nothing; its reconcile must leave the visitor alone. + w.ReconcileRoomState(2, &Room{ID: 2}) + if w.GetObjState(2, "fishing_spot", 0) == nil { + t.Fatal("reconcile pruned a wanderer visiting from another home room") + } +} + +// TestRekeyRoomsRemapsHomeRoomID: identity is anchored to HomeRoomID, so a room +// rekey must remap it alongside RoomID or a wanderer can be duplicated after +// the rename. +func TestRekeyRoomsRemapsHomeRoomID(t *testing.T) { + w := New(t.TempDir()) + w.EnsureObjectStates(1, []string{"fishing_spot"}) + st := w.GetObjState(1, "fishing_spot", 0) + if st.HomeRoomID != 1 { + t.Fatalf("setup: HomeRoomID=%d, want 1", st.HomeRoomID) + } + w.RekeyRooms(map[int]int{1: 3}) + moved := w.GetObjState(3, "fishing_spot", 0) + if moved == nil { + t.Fatal("object state lost after rekey") + } + if moved.HomeRoomID != 3 { + t.Fatalf("HomeRoomID after rekey = %d, want 3", moved.HomeRoomID) + } +} diff --git a/internal/world/objects.go b/internal/world/objects.go index fa17f08..3c004a5 100644 --- a/internal/world/objects.go +++ b/internal/world/objects.go @@ -10,13 +10,23 @@ import ( ) type ObjState struct { - Depleted bool - DepleteTimer float64 - DefID string - Name string - Aliases []string - Index int - RoomID int + Depleted bool + DepleteTimer float64 + DefID string + Name string + Aliases []string + Index int + // RoomID is where the instance currently is; HomeRoomID is the room that + // declares it and never changes as it wanders. Identity (and re-seeding) + // is anchored to HomeRoomID so a wandering object cannot be duplicated by + // re-entering its home room. + RoomID int + HomeRoomID int + // Dynamic marks an instance created at runtime (AddObjInstance) rather + // than declared by the room file. ReconcileRoomState must not prune these: + // a live fire is not in the room's objects list and would be extinguished + // by any content edit. + Dynamic bool JustRespawned bool WanderRooms []int WanderInterval float64 @@ -56,17 +66,37 @@ func (w *World) ensureObjStates(roomID int, defIDs []string) { idx := counts[defID] counts[defID]++ key := w.ObjStateKey(roomID, defID, idx) - if _, exists := w.objStates[key]; !exists { - w.objStates[key] = &ObjState{ - DefID: defID, - Name: defID, - Index: idx, - RoomID: roomID, - } + if _, exists := w.objStates[key]; exists { + continue + } + // The object may simply be away from its home room (wandering): its + // state lives under its current room's key, so re-seeding the home + // room must not create a second, independent instance. + if w.homeObjStateLocked(roomID, defID, idx) != nil { + continue + } + w.objStates[key] = &ObjState{ + DefID: defID, + Name: defID, + Index: idx, + RoomID: roomID, + HomeRoomID: roomID, } } } +// homeObjStateLocked returns the instance whose home room is roomID and whose +// identity is (defID, index), wherever it has currently wandered. It must be +// called with w.mu held. +func (w *World) homeObjStateLocked(roomID int, defID string, index int) *ObjState { + for _, st := range w.objStates { + if st.HomeRoomID == roomID && st.DefID == defID && st.Index == index { + return st + } + } + return nil +} + func (w *World) AddObjInstance(roomID int, defID string, quality float64) { w.mu.Lock() defer w.mu.Unlock() @@ -76,13 +106,18 @@ func (w *World) AddObjInstance(roomID int, defID string, quality float64) { idx := 0 for { key := w.ObjStateKey(roomID, defID, idx) - if _, exists := w.objStates[key]; !exists { + // An index is free only if no instance currently occupies it AND no + // instance from this room has wandered away holding that index; reusing + // a wandered index would collide when the wanderer returns. + if _, exists := w.objStates[key]; !exists && w.homeObjStateLocked(roomID, defID, idx) == nil { w.objStates[key] = &ObjState{ - DefID: defID, - Name: defID, - Index: idx, - RoomID: roomID, - Quality: quality, + DefID: defID, + Name: defID, + Index: idx, + RoomID: roomID, + HomeRoomID: roomID, + Dynamic: true, + Quality: quality, } return } @@ -90,17 +125,108 @@ func (w *World) AddObjInstance(roomID int, defID string, quality float64) { } } -func (w *World) RemoveObjInstance(roomID int, defID string) { +// ReconcileRoomState brings a room's object instances and item-spawn ground +// entries in line with a (possibly just-edited) room definition, without a +// restart. Player-dropped ground items, dynamic object instances (fires), and +// live reservations are preserved. Mob instances live in MobStore and are +// reconciled by the game layer, which owns that store. +func (w *World) ReconcileRoomState(roomID int, room *Room) { w.mu.Lock() defer w.mu.Unlock() - idx := 0 - for { - key := w.ObjStateKey(roomID, defID, idx) - if _, exists := w.objStates[key]; !exists { - return + if w.objStates == nil { + w.objStates = make(map[string]*ObjState) + } + + // Prune room-declared instances the definition no longer declares. Prune + // by home room so a wandered instance is reconciled where it belongs, and + // never prune a dynamic instance. + want := make(map[string]int, len(room.Objects)) + for _, ro := range room.Objects { + want[ro.ID]++ + } + for key, st := range w.objStates { + if st.Dynamic || st.HomeRoomID != roomID { + continue + } + if st.Index >= want[st.DefID] { + delete(w.objStates, key) } + } + + // Re-create declared instances that are missing (including ones that had + // wandered away; ensureObjStates recognises them by HomeRoomID). + defIDs := make([]string, 0, len(room.Objects)) + for _, ro := range room.Objects { + defIDs = append(defIDs, ro.ID) + } + w.ensureObjStates(roomID, defIDs) + + // Ground spawns are only reconciled after the room has been seeded: before + // that, the first entry seeds from the definition anyway, and adding + // entries here would make SeedGroundItems double the quantity. + if w.seeded[roomID] { + w.reconcileGroundSpawnsLocked(roomID, room.ItemSpawns) + } +} + +// reconcileGroundSpawnsLocked adds entries for newly declared item spawns and +// drops entries for spawns the room no longer declares, leaving the live +// quantity of existing spawns (and every dropped item) untouched. It must be +// called with w.mu held. +func (w *World) reconcileGroundSpawnsLocked(roomID int, spawns []SpawnDef) { + declared := make(map[string]bool, len(spawns)) + for _, s := range spawns { + declared[strings.ToLower(s.ID)] = true + } + kept := w.groundItems[roomID][:0] + for _, e := range w.groundItems[roomID] { + if e.isSpawn && !declared[strings.ToLower(e.itemID)] { + continue + } + kept = append(kept, e) + } + w.groundItems[roomID] = kept + for _, s := range spawns { + found := false + for _, e := range w.groundItems[roomID] { + if e.isSpawn && strings.EqualFold(e.itemID, s.ID) { + found = true + break + } + } + if !found { + w.groundItems[roomID] = append(w.groundItems[roomID], &groundEntry{ + itemID: s.ID, + quantity: s.Quantity, + isSpawn: true, + respawnDelay: spawnRespawnDelay(s.RespawnTicks), + respawnQty: s.Quantity, + }) + } + } +} + +// RemoveObjInstanceIndex removes exactly one instance by identity +// (defID, index), looking it up by its current room first (roomID) and +// falling back to its home room in case it has wandered away. A room may +// legitimately declare the same object twice, so the index names which one; +// removing every instance of a defID would destroy a sibling. +func (w *World) RemoveObjInstanceIndex(roomID int, defID string, index int) { + w.mu.Lock() + defer w.mu.Unlock() + if w.objStates == nil { + return + } + key := w.ObjStateKey(roomID, defID, index) + if st, ok := w.objStates[key]; ok && st.DefID == defID && st.Index == index { delete(w.objStates, key) - idx++ + return + } + for k, st := range w.objStates { + if st.DefID == defID && st.Index == index && st.HomeRoomID == roomID { + delete(w.objStates, k) + return + } } } @@ -170,6 +296,8 @@ func (w *World) FindObjInstances(roomID int, name string) []ObjState { Aliases: st.Aliases, Index: st.Index, RoomID: st.RoomID, + HomeRoomID: st.HomeRoomID, + Dynamic: st.Dynamic, Depleted: st.Depleted, DepleteTimer: st.DepleteTimer, SharedMax: st.SharedMax, diff --git a/internal/world/world.go b/internal/world/world.go index cb53b77..7a91894 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -3,6 +3,7 @@ package world import ( "fmt" "log" + "math" "os" "path/filepath" "strconv" @@ -120,7 +121,7 @@ func (w *World) SeedGroundItems(roomID int) { e.quantity += s.Quantity e.respawnQty += s.Quantity e.isSpawn = true - e.respawnDelay = int(s.RespawnTicks) + e.respawnDelay = spawnRespawnDelay(s.RespawnTicks) merged = true break } @@ -130,7 +131,7 @@ func (w *World) SeedGroundItems(roomID int) { itemID: s.ID, quantity: s.Quantity, isSpawn: true, - respawnDelay: int(s.RespawnTicks), + respawnDelay: spawnRespawnDelay(s.RespawnTicks), respawnQty: s.Quantity, } w.groundItems[roomID] = append(w.groundItems[roomID], e) @@ -138,6 +139,20 @@ func (w *World) SeedGroundItems(roomID int) { } } +// spawnRespawnDelay converts a fractional respawn_ticks value to whole ticks. +// Truncating (the old int(v)) turned any 0 < v < 1 into a one-shot spawn that +// never returned; round instead and floor a positive value at one tick. +func spawnRespawnDelay(ticks float64) int { + if ticks <= 0 { + return 0 + } + delay := int(math.Round(ticks)) + if delay < 1 { + delay = 1 + } + return delay +} + func (w *World) RoomIndex() map[int]bool { w.mu.Lock() defer w.mu.Unlock() @@ -255,6 +270,9 @@ func (w *World) RekeyRooms(idMap map[int]int) { // from the renamed room (and TickObjWander then creates a second // entry for the same state). Remap wander rooms like MobStore does. m.st.RoomID = m.newID + if mapped, ok := idMap[m.st.HomeRoomID]; ok && mapped != m.st.HomeRoomID { + m.st.HomeRoomID = mapped + } for i, room := range m.st.WanderRooms { if mapped, ok := idMap[room]; ok && mapped != room { m.st.WanderRooms[i] = mapped -- cgit v1.2.3