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:
2026-03-01 01:51:47 +03:00
parent 0466ff3087
commit 6529d7370e
92 changed files with 13411 additions and 8438 deletions

View 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
}