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) } }