aboutsummaryrefslogtreecommitdiff
path: root/internal/world
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-19 01:55:34 -0400
committerhistoria <[not public]>2026-09-19 01:55:34 -0400
commitcc33072eced3d5460512c09c42ec4531c807d7af (patch)
treeb1b451fa87fa389cb8343a3645372f3b43f5523e /internal/world
parent2c38386484cdb507c4687caceb37e0b963e33594 (diff)
downloadthehouseoficarus-cc33072eced3d5460512c09c42ec4531c807d7af.tar.gz
fix: casino stake preservation, world state reconciliation, casino game registration
Diffstat (limited to 'internal/world')
-rw-r--r--internal/world/fix_regressions_test.go153
-rw-r--r--internal/world/objects.go182
-rw-r--r--internal/world/world.go22
3 files changed, 328 insertions, 29 deletions
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