package world import ( "fmt" "log" "math" "os" "path/filepath" "strconv" "strings" "sync" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" ) type World struct { dataDir string mu sync.Mutex roomPathIndex map[int]string groundItems map[int][]*groundEntry seeded map[int]bool objStates map[string]*ObjState objMoves []ObjMove hazardMu sync.Mutex hazardPathIndex map[string]string hazardDefs map[string]*HazardDef } func New(dataDir string) *World { return &World{ dataDir: dataDir, roomPathIndex: behavior.BuildRoomIndex(filepath.Join(dataDir, "rooms")), groundItems: make(map[int][]*groundEntry), seeded: make(map[int]bool), objStates: make(map[string]*ObjState), hazardDefs: make(map[string]*HazardDef), } } func (w *World) LoadRoom(id int) (*Room, error) { w.mu.Lock() path, ok := w.roomPathIndex[id] w.mu.Unlock() if !ok { return nil, fmt.Errorf("read room %d: no such room", id) } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read room %d: %w", id, err) } var room Room if err := yaml.Unmarshal(data, &room); err != nil { return nil, fmt.Errorf("parse room %d: %w", id, err) } // Room ID comes from the filename (room.ID = id below); a mismatched // declared id: is silently ignored, so surface it rather than leave the // author believing their written id is in use. if room.ID != 0 && room.ID != id { log.Printf("room %d: file %s declares id %d but is loaded as room %d; the filename is authoritative and the declared id is ignored", id, path, room.ID, id) } room.ID = id if room.Exits == nil { room.Exits = make(map[ExitDir]ExitDef) } if room.ItemSpawns == nil { room.ItemSpawns = make([]SpawnDef, 0) } if room.Mobs == nil { room.Mobs = make([]RoomMob, 0) } return &room, nil } func (w *World) ResolveExit(input string) ExitDir { if dir, ok := ExitAliases[strings.ToLower(input)]; ok { return dir } canon := ExitDir(strings.ToLower(input)) switch canon { case North, South, East, West, Northeast, Northwest, Southeast, Southwest, Up, Down: return canon } return "" } func (w *World) SeedGroundItems(roomID int) { w.mu.Lock() if w.seeded[roomID] { w.mu.Unlock() return } w.mu.Unlock() room, err := w.LoadRoom(roomID) if err != nil { // Do NOT mark a room seeded before it loads: a dangling exit (or a // not-yet-created room) would otherwise permanently disable its item // spawns even after the room is fixed/created. return } w.mu.Lock() defer w.mu.Unlock() // Re-check under the lock: a concurrent seed may have won the race while // this call was loading. if w.seeded[roomID] { return } w.seeded[roomID] = true for _, s := range room.ItemSpawns { merged := false for _, e := range w.groundItems[roomID] { if e.quantity > 0 && strings.EqualFold(e.itemID, s.ID) && e.quality == 0 && e.maxQuality == 0 && (e.reserveTimer <= 0 || e.reservedFor == "") && e.despawnTimer <= 0 { e.quantity += s.Quantity e.respawnQty += s.Quantity e.isSpawn = true e.respawnDelay = spawnRespawnDelay(s.RespawnTicks) merged = true break } } if !merged { e := &groundEntry{ itemID: s.ID, quantity: s.Quantity, isSpawn: true, respawnDelay: spawnRespawnDelay(s.RespawnTicks), respawnQty: s.Quantity, } w.groundItems[roomID] = append(w.groundItems[roomID], e) } } } // 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() ids := make(map[int]bool, len(w.roomPathIndex)) for id := range w.roomPathIndex { ids[id] = true } return ids } func (w *World) RebuildRoomIndex(dataDir string) { w.mu.Lock() defer w.mu.Unlock() w.roomPathIndex = behavior.BuildRoomIndex(filepath.Join(dataDir, "rooms")) } func (w *World) AddRoomPath(id int, path string) { w.mu.Lock() defer w.mu.Unlock() w.roomPathIndex[id] = path } func (w *World) GetRoomPath(id int) (string, bool) { w.mu.Lock() defer w.mu.Unlock() path, ok := w.roomPathIndex[id] return path, ok } func (w *World) ClearHazardCache() { w.hazardMu.Lock() defer w.hazardMu.Unlock() w.hazardPathIndex = nil w.hazardDefs = make(map[string]*HazardDef) } func (w *World) Tick() { w.mu.Lock() defer w.mu.Unlock() w.tickGroundItems() w.tickObjStatesLocked() } func (w *World) ClearRoomState(roomID int) { w.mu.Lock() defer w.mu.Unlock() delete(w.groundItems, roomID) delete(w.seeded, roomID) prefix := fmt.Sprintf("%d:", roomID) for k := range w.objStates { if strings.HasPrefix(k, prefix) { delete(w.objStates, k) } } } // RekeyRooms remaps room-keyed runtime state per idMap (oldID -> newID): // ground items, seeded markers, and object-state keys ("roomID:..."). // Uses delete-all-then-set-all so a swap cannot clobber an entry mid-rekey. func (w *World) RekeyRooms(idMap map[int]int) { if len(idMap) == 0 { return } w.mu.Lock() defer w.mu.Unlock() movedGround := make(map[int][]*groundEntry, len(w.groundItems)) for roomID, entries := range w.groundItems { if newID, ok := idMap[roomID]; ok && newID != roomID { movedGround[newID] = entries delete(w.groundItems, roomID) } } for newID, entries := range movedGround { w.groundItems[newID] = entries } movedSeeded := make(map[int]bool, len(w.seeded)) for roomID := range w.seeded { if newID, ok := idMap[roomID]; ok && newID != roomID { movedSeeded[newID] = true delete(w.seeded, roomID) } } for newID := range movedSeeded { w.seeded[newID] = true } type objKeyMove struct { oldKey, newKey string st *ObjState newID int } var moves []objKeyMove for k, st := range w.objStates { roomStr, _, ok := strings.Cut(k, ":") if !ok { continue } roomID, err := strconv.Atoi(roomStr) if err != nil { continue } newID, ok := idMap[roomID] if !ok || newID == roomID { continue } newKey := fmt.Sprintf("%d:%s", newID, k[len(roomStr)+1:]) moves = append(moves, objKeyMove{k, newKey, st, newID}) } for _, m := range moves { delete(w.objStates, m.oldKey) } for _, m := range moves { // Keep the package invariant mapKey == ObjStateKey(st.RoomID, ...): // object lookups filter on st.RoomID while GetObjState looks up the // key, so a rekey that leaves RoomID stale makes the object vanish // 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 } } w.objStates[m.newKey] = m.st } }