package world import ( "fmt" "log" "math" "os" "path/filepath" "strings" "sync" "gopkg.in/yaml.v3" "thehouseoficarus/internal/behavior" ) type World struct { dataDir string mu sync.Mutex roomPathIndex map[int]string // roomIDMismatchLogged dedupes the "declared id ignored" warning, which // would otherwise fire on every prompt/tick for a room file copied from a // template with a stale id:. Guarded by mu. roomIDMismatchLogged map[int]bool 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")), roomIDMismatchLogged: make(map[int]bool), 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 { w.mu.Lock() if w.roomIDMismatchLogged == nil { w.roomIDMismatchLogged = make(map[int]bool) } already := w.roomIDMismatchLogged[id] w.roomIDMismatchLogged[id] = true w.mu.Unlock() if !already { 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 } // Rebuild the object-state map from every state, not only states whose // current-room key is in idMap. An object away from its home room is keyed // by the current room, so keying the walk on the map key leaves its // HomeRoomID and WanderRooms stale; a later entry to the renamed home then // fails homeObjStateLocked and creates a duplicate instance. Rebuilding the // map also preserves the invariant mapKey == ObjStateKey(st.RoomID, ...). rekeyed := make(map[string]*ObjState, len(w.objStates)) for _, st := range w.objStates { if newID, ok := idMap[st.RoomID]; ok && newID != st.RoomID { st.RoomID = newID } if newID, ok := idMap[st.HomeRoomID]; ok && newID != st.HomeRoomID { st.HomeRoomID = newID } for i, room := range st.WanderRooms { if newID, ok := idMap[room]; ok && newID != room { st.WanderRooms[i] = newID } } rekeyed[w.ObjStateKey(st.RoomID, st.DefID, st.Index)] = st } w.objStates = rekeyed }