package world import ( "fmt" "log" "math/rand" "sort" "strings" "thehouseoficarus/internal/behavior" ) type ObjState struct { 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 WanderCounter int SharedMax float64 SharedTimer int Quality float64 SafespotLevel int SafespotTicksAtLevel int SafespotOccupants []string } type ObjMove struct { DefID string Name string FromRoom int ToRoom int } func (w *World) ObjStateKey(roomID int, defID string, index int) string { return fmt.Sprintf("%d:%s:%d", roomID, defID, index) } func (w *World) EnsureObjectStates(roomID int, defIDs []string) { w.mu.Lock() defer w.mu.Unlock() w.ensureObjStates(roomID, defIDs) } func (w *World) ensureObjStates(roomID int, defIDs []string) { if w.objStates == nil { w.objStates = make(map[string]*ObjState) } counts := make(map[string]int) for _, defID := range defIDs { idx := counts[defID] counts[defID]++ key := w.ObjStateKey(roomID, defID, idx) 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() if w.objStates == nil { w.objStates = make(map[string]*ObjState) } idx := 0 for { key := w.ObjStateKey(roomID, defID, idx) // 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, HomeRoomID: roomID, Dynamic: true, Quality: quality, } return } idx++ } } // 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() 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) return } for k, st := range w.objStates { if st.DefID == defID && st.Index == index && st.HomeRoomID == roomID { delete(w.objStates, k) return } } } func (w *World) AllFireStates() []*ObjState { w.mu.Lock() defer w.mu.Unlock() var out []*ObjState for _, st := range w.objStates { if st.DefID == "fire" && st.Quality > 0 { out = append(out, st) } } return out } func (w *World) GetObjState(roomID int, defID string, index int) *ObjState { w.mu.Lock() defer w.mu.Unlock() if w.objStates == nil { return nil } return w.objStates[w.ObjStateKey(roomID, defID, index)] } func (w *World) GetObjStateByKey(key string) *ObjState { w.mu.Lock() defer w.mu.Unlock() if w.objStates == nil { return nil } return w.objStates[key] } func (w *World) AllObjInstances(roomID int) []ObjState { w.mu.Lock() defer w.mu.Unlock() var out []ObjState for _, st := range w.objStates { if st.RoomID == roomID { out = append(out, *st) } } sort.Slice(out, func(i, j int) bool { if out[i].DefID != out[j].DefID { return out[i].DefID < out[j].DefID } return out[i].Index < out[j].Index }) return out } func (w *World) FindObjInstances(roomID int, name string) []ObjState { w.mu.Lock() defer w.mu.Unlock() var out []ObjState lower := strings.ToLower(name) for _, st := range w.objStates { if st.RoomID != roomID { continue } if !wordMatchesObj(lower, st.DefID, st.Name, st.Aliases) { continue } out = append(out, ObjState{ DefID: st.DefID, Name: st.Name, Aliases: st.Aliases, Index: st.Index, RoomID: st.RoomID, HomeRoomID: st.HomeRoomID, Dynamic: st.Dynamic, Depleted: st.Depleted, DepleteTimer: st.DepleteTimer, SharedMax: st.SharedMax, SharedTimer: st.SharedTimer, Quality: st.Quality, }) } sort.Slice(out, func(i, j int) bool { if out[i].DefID != out[j].DefID { return out[i].DefID < out[j].DefID } return out[i].Index < out[j].Index }) return out } func wordMatchesObj(lower, defID, objName string, aliases []string) bool { if behavior.WordPrefixMatch(lower, objName) { return true } for _, alias := range aliases { if behavior.WordPrefixMatch(lower, alias) { return true } } inputWords := strings.Fields(strings.ToLower(lower)) if strings.HasPrefix(strings.ToLower(defID), lower) || strings.HasPrefix(lower, strings.ToLower(defID)) { return true } for _, part := range strings.Split(defID, "_") { for _, iw := range inputWords { pl := strings.ToLower(part) if strings.HasPrefix(pl, iw) || strings.HasPrefix(iw, pl) { return true } } } return false } func (w *World) SetObjName(roomID int, defID string, name string) { w.mu.Lock() defer w.mu.Unlock() for _, st := range w.objStates { if st.HomeRoomID == roomID && st.DefID == defID { st.Name = name } } } func (w *World) SetObjAliases(roomID int, defID string, aliases []string) { w.mu.Lock() defer w.mu.Unlock() for _, st := range w.objStates { if st.HomeRoomID == roomID && st.DefID == defID { st.Aliases = aliases } } } func (w *World) SetObjWander(roomID int, defID string, rooms []int, interval float64) { w.mu.Lock() defer w.mu.Unlock() for _, st := range w.objStates { if st.HomeRoomID == roomID && st.DefID == defID { st.WanderRooms = rooms st.WanderInterval = interval } } } func (w *World) SetObjDepleteTimer(roomID int, defID string, max float64) { w.mu.Lock() defer w.mu.Unlock() for _, st := range w.objStates { if st.HomeRoomID == roomID && st.DefID == defID && st.SharedMax == 0 { st.SharedMax = max st.SharedTimer = int(max) } } } func (w *World) AllSharedObjStates() []*ObjState { w.mu.Lock() defer w.mu.Unlock() var out []*ObjState for _, st := range w.objStates { if st.SharedMax > 0 { out = append(out, st) } } return out } func (w *World) SetObjDepleted(roomID int, defID string, index int, delay float64) { w.mu.Lock() defer w.mu.Unlock() key := w.ObjStateKey(roomID, defID, index) if st, ok := w.objStates[key]; ok { st.Depleted = true st.DepleteTimer = delay } } func (w *World) FlushObjRespawns() []ObjState { w.mu.Lock() defer w.mu.Unlock() var out []ObjState for _, st := range w.objStates { if st.JustRespawned { out = append(out, *st) st.JustRespawned = false } } return out } func (w *World) TickObjWander() { w.mu.Lock() defer w.mu.Unlock() var moves []struct { st *ObjState toRoom int } for _, st := range w.objStates { if len(st.WanderRooms) == 0 || st.WanderInterval <= 0 { continue } st.WanderCounter++ if float64(st.WanderCounter) < st.WanderInterval { continue } st.WanderCounter = 0 toRoom := st.WanderRooms[rand.Intn(len(st.WanderRooms))] if toRoom == st.RoomID { continue } // A wander_rooms entry may point at a room that was since deleted. // Moving there would strand the object in a nonexistent room, so // leave it where it is. (roomPathIndex is read under w.mu, which // this method already holds; LoadRoom would deadlock here.) if _, ok := w.roomPathIndex[toRoom]; !ok { continue } moves = append(moves, struct { st *ObjState toRoom int }{st, toRoom}) } for _, m := range moves { oldKey := w.ObjStateKey(m.st.RoomID, m.st.DefID, m.st.Index) fromRoom := m.st.RoomID newKey := w.ObjStateKey(m.toRoom, m.st.DefID, m.st.Index) if existing, ok := w.objStates[newKey]; ok && existing != m.st { log.Printf("skip wander of object %q index %d from room %d to %d: destination key %q is already occupied", m.st.DefID, m.st.Index, fromRoom, m.toRoom, newKey) continue } m.st.RoomID = m.toRoom delete(w.objStates, oldKey) w.objStates[newKey] = m.st w.objMoves = append(w.objMoves, ObjMove{ DefID: m.st.DefID, Name: m.st.Name, FromRoom: fromRoom, ToRoom: m.toRoom, }) } } func (w *World) FlushObjMoves() []ObjMove { w.mu.Lock() defer w.mu.Unlock() out := w.objMoves w.objMoves = nil return out } func (w *World) tickObjStatesLocked() { for _, st := range w.objStates { if st.Depleted && st.DepleteTimer > 0 { st.DepleteTimer-- if st.DepleteTimer <= 0 { st.Depleted = false st.JustRespawned = true if st.SharedMax > 0 { st.SharedTimer = int(st.SharedMax) } } } } }