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) } } // TestSetObjPropsMatchHomeRoomNotCurrentRoom: content edits pass the declaring // room, so property setters must match on HomeRoomID rather than the object's // current RoomID (which differs while it has wandered away). func TestSetObjPropsMatchHomeRoomNotCurrentRoom(t *testing.T) { w := New(t.TempDir()) w.objStates[w.ObjStateKey(2, "anvil", 0)] = &ObjState{ RoomID: 2, HomeRoomID: 1, DefID: "anvil", Index: 0, } w.SetObjName(1, "anvil", "iron anvil") w.SetObjAliases(1, "anvil", []string{"workbench"}) w.SetObjWander(1, "anvil", []int{2, 3}, 4) w.SetObjDepleteTimer(1, "anvil", 7) st := w.GetObjState(2, "anvil", 0) if st.Name != "iron anvil" { t.Fatalf("name not applied: %+v", st) } if len(st.Aliases) != 1 || st.Aliases[0] != "workbench" { t.Fatalf("aliases not applied: %+v", st) } if len(st.WanderRooms) != 2 || st.WanderInterval != 4 { t.Fatalf("wander not applied: %+v", st) } if st.SharedMax != 7 { t.Fatalf("deplete timer not applied: %+v", st) } } // TestTickObjWanderSkipsOccupiedDestination: a wandering instance whose // destination key is already occupied must not evict the occupant for the // process lifetime. func TestTickObjWanderSkipsOccupiedDestination(t *testing.T) { w := New(t.TempDir()) w.AddRoomPath(1, "room-1") w.AddRoomPath(2, "room-2") w.objStates[w.ObjStateKey(2, "anvil", 0)] = &ObjState{ RoomID: 2, HomeRoomID: 2, DefID: "anvil", Index: 0, } w.objStates[w.ObjStateKey(1, "anvil", 0)] = &ObjState{ RoomID: 1, HomeRoomID: 1, DefID: "anvil", Index: 0, WanderRooms: []int{2}, WanderInterval: 1, } w.TickObjWander() if w.GetObjState(2, "anvil", 0) == nil { t.Fatal("destination occupant was evicted by a colliding wanderer") } if w.GetObjState(1, "anvil", 0) == nil { t.Fatal("wanderer was lost instead of skipping the occupied destination") } }