package services import ( "context" "encoding/json" "fmt" "io" "log/slog" "os" "strconv" "strings" "time" "github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app/store" "go.etcd.io/bbolt" ) // GridUpdate is the client grid update request body. type GridUpdate struct { Grids [][]string `json:"grids"` } // GridRequest is the grid update response. type GridRequest struct { GridRequests []string `json:"gridRequests"` Map int `json:"map"` Coords app.Coord `json:"coords"` } // ExtraData carries season info from the client. type ExtraData struct { Season int } // ClientService handles game client operations. type ClientService struct { st *store.Store mapSvc *MapService // withChars provides locked mutable access to the character map. withChars func(fn func(chars map[string]app.Character)) } // ClientServiceDeps holds dependencies for ClientService. type ClientServiceDeps struct { Store *store.Store MapSvc *MapService WithChars func(fn func(chars map[string]app.Character)) } // NewClientService creates a ClientService with the given dependencies. func NewClientService(d ClientServiceDeps) *ClientService { return &ClientService{ st: d.Store, mapSvc: d.MapSvc, withChars: d.WithChars, } } // Locate returns "mapid;x;y" for a grid, or error if not found. func (s *ClientService) Locate(ctx context.Context, gridID string) (string, error) { var result string err := s.st.View(ctx, func(tx *bbolt.Tx) error { raw := s.st.GetGrid(tx, gridID) if raw == nil { return fmt.Errorf("grid not found") } cur := app.GridData{} if err := json.Unmarshal(raw, &cur); err != nil { return err } result = fmt.Sprintf("%d;%d;%d", cur.Map, cur.Coord.X, cur.Coord.Y) return nil }) return result, err } // GridUpdateResult contains the response and any tile operations to process. type GridUpdateResult struct { Response GridRequest Ops []TileOp } // ProcessGridUpdate handles a client grid update and returns the response. func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate) (*GridUpdateResult, error) { result := &GridUpdateResult{} greq := &result.Response err := s.st.Update(ctx, func(tx *bbolt.Tx) error { grids, err := tx.CreateBucketIfNotExists(store.BucketGrids) if err != nil { return err } tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles) if err != nil { return err } mapB, err := tx.CreateBucketIfNotExists(store.BucketMaps) if err != nil { return err } configb, err := tx.CreateBucketIfNotExists(store.BucketConfig) if err != nil { return err } maps := map[int]struct{ X, Y int }{} for x, row := range grup.Grids { for y, grid := range row { gridRaw := grids.Get([]byte(grid)) if gridRaw != nil { gd := app.GridData{} json.Unmarshal(gridRaw, &gd) maps[gd.Map] = struct{ X, Y int }{gd.Coord.X - x, gd.Coord.Y - y} } } } if len(maps) == 0 { seq, err := mapB.NextSequence() if err != nil { return err } mi := app.MapInfo{ ID: int(seq), Name: strconv.Itoa(int(seq)), Hidden: configb.Get([]byte("defaultHide")) != nil, } raw, _ := json.Marshal(mi) if err = mapB.Put([]byte(strconv.Itoa(int(seq))), raw); err != nil { return err } slog.Info("client created new map", "map_id", seq) for x, row := range grup.Grids { for y, grid := range row { cur := app.GridData{ID: grid, Map: int(seq), Coord: app.Coord{X: x - 1, Y: y - 1}} raw, err := json.Marshal(cur) if err != nil { return err } grids.Put([]byte(grid), raw) greq.GridRequests = append(greq.GridRequests, grid) } } greq.Coords = app.Coord{X: 0, Y: 0} return nil } mapid := -1 offset := struct{ X, Y int }{} for id, off := range maps { mi := app.MapInfo{} mraw := mapB.Get([]byte(strconv.Itoa(id))) if mraw != nil { json.Unmarshal(mraw, &mi) } if mi.Priority { mapid = id offset = off break } if id < mapid || mapid == -1 { mapid = id offset = off } } slog.Debug("client in map", "map_id", mapid) for x, row := range grup.Grids { for y, grid := range row { cur := app.GridData{} if curRaw := grids.Get([]byte(grid)); curRaw != nil { json.Unmarshal(curRaw, &cur) if time.Now().After(cur.NextUpdate) { greq.GridRequests = append(greq.GridRequests, grid) } continue } cur.ID = grid cur.Map = mapid cur.Coord.X = x + offset.X cur.Coord.Y = y + offset.Y raw, err := json.Marshal(cur) if err != nil { return err } grids.Put([]byte(grid), raw) greq.GridRequests = append(greq.GridRequests, grid) } } if len(grup.Grids) >= 2 && len(grup.Grids[1]) >= 2 { if curRaw := grids.Get([]byte(grup.Grids[1][1])); curRaw != nil { cur := app.GridData{} json.Unmarshal(curRaw, &cur) greq.Map = cur.Map greq.Coords = cur.Coord } } if len(maps) > 1 { grids.ForEach(func(k, v []byte) error { gd := app.GridData{} json.Unmarshal(v, &gd) if gd.Map == mapid { return nil } if merge, ok := maps[gd.Map]; ok { var td *app.TileData mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(gd.Map))) if err != nil { return err } zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(0))) if err != nil { return err } tileraw := zoom.Get([]byte(gd.Coord.Name())) if tileraw != nil { json.Unmarshal(tileraw, &td) } gd.Map = mapid gd.Coord.X += offset.X - merge.X gd.Coord.Y += offset.Y - merge.Y raw, _ := json.Marshal(gd) if td != nil { result.Ops = append(result.Ops, TileOp{ MapID: mapid, X: gd.Coord.X, Y: gd.Coord.Y, File: td.File, }) } grids.Put(k, raw) } return nil }) } for mergeid, merge := range maps { if mapid == mergeid { continue } mapB.Delete([]byte(strconv.Itoa(mergeid))) slog.Info("reporting merge", "from", mergeid, "to", mapid) s.mapSvc.ReportMerge(mergeid, mapid, app.Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y}) } return nil }) if err != nil { return nil, err } s.mapSvc.ProcessZoomLevels(ctx, result.Ops) return result, nil } // ProcessGridUpload handles a tile image upload from the client. func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraData string, fileReader io.Reader) error { if extraData != "" { ed := ExtraData{} json.Unmarshal([]byte(extraData), &ed) if ed.Season == 3 { needTile := false s.st.Update(ctx, func(tx *bbolt.Tx) error { raw := s.st.GetGrid(tx, id) if raw == nil { return fmt.Errorf("unknown grid id: %s", id) } cur := app.GridData{} if err := json.Unmarshal(raw, &cur); err != nil { return err } tdRaw := s.st.GetTile(tx, cur.Map, 0, cur.Coord.Name()) if tdRaw == nil { needTile = true return nil } td := app.TileData{} if err := json.Unmarshal(tdRaw, &td); err != nil { return err } if td.File == "" { needTile = true return nil } if time.Now().After(cur.NextUpdate) { cur.NextUpdate = time.Now().Add(app.TileUpdateInterval) } raw, _ = json.Marshal(cur) return s.st.PutGrid(tx, id, raw) }) if !needTile { slog.Debug("ignoring tile upload: winter") return nil } slog.Debug("missing tile, using winter version") } } slog.Debug("processing tile upload", "grid_id", id) updateTile := false cur := app.GridData{} mapid := 0 s.st.Update(ctx, func(tx *bbolt.Tx) error { raw := s.st.GetGrid(tx, id) if raw == nil { return fmt.Errorf("unknown grid id: %s", id) } if err := json.Unmarshal(raw, &cur); err != nil { return err } updateTile = time.Now().After(cur.NextUpdate) mapid = cur.Map if updateTile { cur.NextUpdate = time.Now().Add(app.TileUpdateInterval) } raw, _ = json.Marshal(cur) return s.st.PutGrid(tx, id, raw) }) if updateTile { gridDir := fmt.Sprintf("%s/grids", s.mapSvc.GridStorage()) if err := os.MkdirAll(gridDir, 0755); err != nil { slog.Error("failed to create grids dir", "error", err) return err } f, err := os.Create(fmt.Sprintf("%s/grids/%s.png", s.mapSvc.GridStorage(), cur.ID)) if err != nil { return err } if _, err = io.Copy(f, fileReader); err != nil { f.Close() return err } f.Close() s.mapSvc.SaveTile(ctx, mapid, cur.Coord, 0, fmt.Sprintf("grids/%s.png", cur.ID), time.Now().UnixNano()) c := cur.Coord for z := 1; z <= app.MaxZoomLevel; z++ { c = c.Parent() s.mapSvc.UpdateZoomLevel(ctx, mapid, c, z) } } return nil } // UpdatePositions updates character positions from client data. func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error { craws := map[string]struct { Name string GridID string Coords struct{ X, Y int } Type string }{} if err := json.Unmarshal(data, &craws); err != nil { slog.Error("failed to decode position update", "error", err) return err } gridDataByID := make(map[string]app.GridData) s.st.View(ctx, func(tx *bbolt.Tx) error { for _, craw := range craws { raw := s.st.GetGrid(tx, craw.GridID) if raw != nil { var gd app.GridData if json.Unmarshal(raw, &gd) == nil { gridDataByID[craw.GridID] = gd } } } return nil }) username, _ := ctx.Value(app.ClientUsernameKey).(string) s.withChars(func(chars map[string]app.Character) { for id, craw := range craws { gd, ok := gridDataByID[craw.GridID] if !ok { continue } idnum, _ := strconv.Atoi(id) c := app.Character{ Name: craw.Name, ID: idnum, Map: gd.Map, Position: app.Position{ X: craw.Coords.X + (gd.Coord.X * app.GridSize), Y: craw.Coords.Y + (gd.Coord.Y * app.GridSize), }, Type: craw.Type, Updated: time.Now(), Username: username, } old, ok := chars[id] if !ok { chars[id] = c } else { if old.Type == "player" { if c.Type == "player" { chars[id] = c } else { old.Position = c.Position old.Username = username chars[id] = old } } else if old.Type != "unknown" { if c.Type != "unknown" { chars[id] = c } else { old.Position = c.Position old.Username = username chars[id] = old } } else { chars[id] = c } } } }) return nil } // UploadMarkers stores markers uploaded by the client. func (s *ClientService) UploadMarkers(ctx context.Context, data []byte) error { markers := []struct { Name string GridID string X, Y int Image string Type string Color string }{} if err := json.Unmarshal(data, &markers); err != nil { slog.Error("failed to decode marker upload", "error", err) return err } return s.st.Update(ctx, func(tx *bbolt.Tx) error { grid, idB, err := s.st.CreateMarkersBuckets(tx) if err != nil { return err } for _, mraw := range markers { key := []byte(fmt.Sprintf("%s_%d_%d", mraw.GridID, mraw.X, mraw.Y)) if grid.Get(key) != nil { continue } img := mraw.Image if img == "" { img = "gfx/terobjs/mm/custom" } id, err := idB.NextSequence() if err != nil { return err } idKey := []byte(strconv.Itoa(int(id))) m := app.Marker{ Name: mraw.Name, ID: int(id), GridID: mraw.GridID, Position: app.Position{X: mraw.X, Y: mraw.Y}, Image: img, } raw, _ := json.Marshal(m) grid.Put(key, raw) idB.Put(idKey, key) } return nil }) } // FixMultipartContentType fixes broken multipart Content-Type headers from some game clients. func FixMultipartContentType(ct string) string { if strings.Count(ct, "=") >= 2 && strings.Count(ct, "\"") == 0 { parts := strings.SplitN(ct, "=", 2) return parts[0] + "=\"" + parts[1] + "\"" } return ct }