package admin import ( "log" "net/http" "os" "path/filepath" "sort" "strconv" "thehouseoficarus/internal/world" ) func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) return } zStr := r.URL.Query().Get("z") z, err := strconv.Atoi(zStr) if err != nil { z = 0 } dir := r.URL.Query().Get("dir") seed := s.cfg.StartingRoom if dir != "" { if low, ok := findLowestLoadableRoom(s, dir); ok { seed = low } else { writeJSON(w, map[string]any{ "rooms": []any{}, "links": []any{}, "upLinks": []any{}, "downLinks": []any{}, "dir": dir, "seed": 0, "disconnected": []any{}, "bounds": map[string]int{"minX": 0, "maxX": 0, "minY": 0, "maxY": 0}, }) return } } g := world.BuildGrid(seed, func(id int) (*world.Room, bool) { room, err := s.world.LoadRoom(id) if err != nil { log.Printf("map: BuildGrid failed to load room %d: %v", id, err) return nil, false } return room, true }, nil, nil, func(gc world.GridConflict) { if gc.Kind == "overlap" { log.Printf("map: grid overlap: room %d wants cell %v already held by %d (via %d->%s)", gc.Target, gc.Want, gc.Occupier, gc.From, gc.Dir) } }) // Discover all rooms reachable from the seed (including ones dropped by grid overlaps). reachable := map[int]bool{seed: true} queue := []int{seed} for len(queue) > 0 { id := queue[0] queue = queue[1:] room, err := s.world.LoadRoom(id) if err != nil { continue } for _, exit := range room.Exits { if exit.Room > 0 && !reachable[exit.Room] { reachable[exit.Room] = true queue = append(queue, exit.Room) } } } // Place any reachable room missing from the grid at a nearby free cell. for id := range reachable { if _, ok := g.Coord[id]; ok { continue } room, err := s.world.LoadRoom(id) if err != nil { continue } for _, exit := range room.Exits { if exit.Room <= 0 { continue } nc, ok := g.Coord[exit.Room] if !ok { continue } if tryPlaceAdjacent(&g, id, nc) { goto placed } } tryPlaceRoom(&g, id, [3]int{}, false, [3]int{0, 0, z}) placed: } // Place rooms that have a one-way exit into the already-placed set. // These rooms are not reachable from the seed via outgoing exits but // their one-way link points into the connected component, so they // should appear on the map as connected (not disconnected). { roomIndex := s.world.RoomIndex() sortedIDs := make([]int, 0, len(roomIndex)) for id := range roomIndex { sortedIDs = append(sortedIDs, id) } sort.Ints(sortedIDs) type revEdge struct { src int dir world.ExitDir } rev := make(map[int][]revEdge) for _, id := range sortedIDs { room, err := s.world.LoadRoom(id) if err != nil { continue } for _, dir := range world.ExitOrder { exit, ok := room.Exits[dir] if !ok || exit.Room <= 0 || exit.Room == id { continue } rev[exit.Room] = append(rev[exit.Room], revEdge{src: id, dir: dir}) } } queue := make([]int, 0, len(g.Coord)) for rid := range g.Coord { queue = append(queue, rid) } sort.Ints(queue) for len(queue) > 0 { target := queue[0] queue = queue[1:] for _, e := range rev[target] { if _, placed := g.Coord[e.src]; placed || e.src == target { continue } tc := g.Coord[target] delta := world.DirectionDeltas3D[world.OppositeExit[e.dir]] want := [3]int{tc[0] + delta[0], tc[1] + delta[1], tc[2] + delta[2]} if tryPlaceRoom(&g, e.src, want, true, tc) { if d, ok := g.Dist[target]; ok { g.Dist[e.src] = d + 1 } queue = append(queue, e.src) } } } } type RoomEntry struct { ID int `json:"id"` X int `json:"x"` Y int `json:"y"` Name string `json:"name"` Color string `json:"color"` } type LinkEntry struct { From int `json:"from"` To int `json:"to"` Dir string `json:"dir"` Bidirectional bool `json:"bidirectional"` Hidden bool `json:"hidden"` AlwaysBlocked bool `json:"always_blocked"` } type UDLink struct { From int `json:"from"` To int `json:"to"` } roomData := make(map[int]*world.Room) var rooms []RoomEntry minX, maxX := 0, 0 minY, maxY := 0, 0 first := true for rid, coord := range g.Coord { if coord[2] != z { continue } r, err := s.world.LoadRoom(rid) if err != nil { continue } roomData[rid] = r rooms = append(rooms, RoomEntry{ ID: rid, X: coord[0], Y: coord[1], Name: r.Name, Color: r.Color, }) if first { minX, maxX = coord[0], coord[0] minY, maxY = coord[1], coord[1] first = false } else { if coord[0] < minX { minX = coord[0] } if coord[0] > maxX { maxX = coord[0] } if coord[1] < minY { minY = coord[1] } if coord[1] > maxY { maxY = coord[1] } } } var links []LinkEntry var upLinks []UDLink var downLinks []UDLink seenLinks := make(map[string]bool) for rid, room := range roomData { c := g.Coord[rid] for dir, exit := range room.Exits { target := exit.Room if target <= 0 { continue } targetCoord, ok := g.Coord[target] if !ok { continue } if targetCoord[2] > c[2] { upLinks = append(upLinks, UDLink{From: rid, To: target}) continue } if targetCoord[2] < c[2] { downLinks = append(downLinks, UDLink{From: rid, To: target}) continue } if dir == world.Up || dir == world.Down { continue } if _, onZ := roomData[target]; !onZ { continue } bidirectional := false if targetRoom, ok := roomData[target]; ok { if oppExit, ok := targetRoom.Exits[world.OppositeExit[dir]]; ok && oppExit.Room == rid && !oppExit.Hidden && !oppExit.AlwaysBlocked { bidirectional = true } } key := linkKey(rid, target) if bidirectional && seenLinks[key] { continue } seenLinks[key] = true links = append(links, LinkEntry{ From: rid, To: target, Dir: string(dir), Bidirectional: bidirectional, Hidden: exit.Hidden, AlwaysBlocked: exit.AlwaysBlocked, }) } } writeJSON(w, map[string]any{ "rooms": rooms, "links": links, "upLinks": upLinks, "downLinks": downLinks, "dir": getRoomDir(s, seed), "seed": seed, "disconnected": findDisconnectedRooms(s, dir, seed, g), "bounds": map[string]int{ "minX": minX, "maxX": maxX, "minY": minY, "maxY": maxY, }, }) } func tryPlaceAdjacent(g *world.RoomGrid, roomID int, center [3]int) bool { for dx := -1; dx <= 1; dx++ { for dy := -1; dy <= 1; dy++ { if dx == 0 && dy == 0 { continue } cell := [3]int{center[0] + dx, center[1] + dy, center[2]} if _, used := g.RoomAt[cell]; !used { g.Coord[roomID] = cell g.RoomAt[cell] = roomID return true } } } return false } func tryPlaceRoom(g *world.RoomGrid, roomID int, want [3]int, allowWant bool, center [3]int) bool { if allowWant { if _, used := g.RoomAt[want]; !used { g.Coord[roomID] = want g.RoomAt[want] = roomID return true } } if tryPlaceAdjacent(g, roomID, center) { return true } for sx := -10; sx <= 10; sx++ { for sy := -10; sy <= 10; sy++ { cell := [3]int{center[0] + sx, center[1] + sy, center[2]} if _, used := g.RoomAt[cell]; !used { g.Coord[roomID] = cell g.RoomAt[cell] = roomID return true } } } return false } func getRoomDir(s *AdminServer, roomID int) string { path, ok := s.world.GetRoomPath(roomID) if !ok { return "" } rel, err := filepath.Rel(filepath.Join(s.dataDir, "rooms"), filepath.Dir(path)) if err != nil || rel == "." { return "" } return rel } func linkKey(a, b int) string { if a < b { return strconv.Itoa(a) + "-" + strconv.Itoa(b) } return strconv.Itoa(b) + "-" + strconv.Itoa(a) } func findLowestLoadableRoom(s *AdminServer, dir string) (int, bool) { base := filepath.Join(s.dataDir, "rooms", dir) entries, err := os.ReadDir(base) if err != nil { return 0, false } var ids []int for _, e := range entries { if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { continue } name := e.Name() id, err := strconv.Atoi(name[:len(name)-5]) if err != nil || id <= 0 { continue } ids = append(ids, id) } sort.Ints(ids) for _, id := range ids { if _, err := s.world.LoadRoom(id); err == nil { return id, true } log.Printf("map: seed candidate room %d in dir %s is not loadable, skipping", id, dir) } return 0, false } func findDisconnectedRooms(s *AdminServer, dir string, seed int, g world.RoomGrid) []map[string]any { var result []map[string]any scanDir := dir if scanDir == "" { scanDir = getRoomDir(s, seed) } allIDs := listRoomIDsInDir(s, scanDir) for _, id := range allIDs { if _, inGrid := g.Coord[id]; inGrid { continue } room, err := s.world.LoadRoom(id) if err != nil { continue } result = append(result, map[string]any{ "id": id, "name": room.Name, }) } return result } func listRoomIDsInDir(s *AdminServer, dir string) []int { base := filepath.Join(s.dataDir, "rooms") walkRoot := base if dir != "" { walkRoot = filepath.Join(base, dir) } var ids []int filepath.WalkDir(walkRoot, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() || filepath.Ext(d.Name()) != ".yaml" { return nil } name := d.Name() id, convErr := strconv.Atoi(name[:len(name)-5]) if convErr == nil && id > 0 { ids = append(ids, id) } return nil }) return ids } func (s *AdminServer) handleNextRoomID(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) return } fromStr := r.URL.Query().Get("from") fromID := 0 if fromStr != "" { fromID, _ = strconv.Atoi(fromStr) } id, _, err := s.nextRoomID(fromID) if err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } writeJSON(w, map[string]any{"id": id}) }