Add configuration files and update project documentation
- Introduced .editorconfig for consistent coding styles across the project. - Added .golangci.yml for Go linting configuration. - Updated AGENTS.md to clarify project structure and components. - Enhanced CONTRIBUTING.md with Makefile usage for common tasks. - Updated Dockerfiles to use Go 1.24 and improved build instructions. - Refined README.md and deployment documentation for clarity. - Added testing documentation in testing.md for backend and frontend tests. - Introduced Makefile for streamlined development commands and tasks.
This commit is contained in:
477
internal/app/services/client.go
Normal file
477
internal/app/services/client.go
Normal file
@@ -0,0 +1,477 @@
|
||||
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
|
||||
})
|
||||
|
||||
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(),
|
||||
}
|
||||
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
|
||||
chars[id] = old
|
||||
}
|
||||
} else if old.Type != "unknown" {
|
||||
if c.Type != "unknown" {
|
||||
chars[id] = c
|
||||
} else {
|
||||
old.Position = c.Position
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user