- Updated frontend-nuxt.mdc to specify usage of composables for API calls. - Added new AuthCard and ConfirmModal components for improved UI consistency. - Introduced UserAvatar component for user profile display, replacing previous Gravatar implementation. - Implemented useFormSubmit composable for handling form submissions with loading and error states. - Enhanced vitest.config.ts to include coverage reporting for composables and components. - Removed deprecated useAdminApi and useAuth composables to streamline API interactions. - Updated login and setup pages to utilize new components and composables for better user experience.
496 lines
12 KiB
Go
496 lines
12 KiB
Go
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/apperr"
|
|
"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 apperr.ErrNotFound
|
|
}
|
|
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{}
|
|
if err := json.Unmarshal(gridRaw, &gd); err != nil {
|
|
return err
|
|
}
|
|
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 {
|
|
if err := json.Unmarshal(mraw, &mi); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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 {
|
|
if err := json.Unmarshal(curRaw, &cur); err != nil {
|
|
return err
|
|
}
|
|
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{}
|
|
if err := json.Unmarshal(curRaw, &cur); err != nil {
|
|
return err
|
|
}
|
|
greq.Map = cur.Map
|
|
greq.Coords = cur.Coord
|
|
}
|
|
}
|
|
if len(maps) > 1 {
|
|
grids.ForEach(func(k, v []byte) error {
|
|
gd := app.GridData{}
|
|
if err := json.Unmarshal(v, &gd); err != nil {
|
|
return err
|
|
}
|
|
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 {
|
|
if err := json.Unmarshal(tileraw, &td); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|