- Updated docker-compose.tools.yml to mount source code at /src and set working directory for backend tools, ensuring proper Go module caching. - Modified Dockerfile.tools to install the latest golangci-lint version compatible with Go 1.24 and adjusted working directory for build-time operations. - Enhanced Makefile to build backend tools before running tests and linting, ensuring dependencies are up-to-date and improving overall workflow efficiency. - Refactored test and handler files to include error handling for database operations, enhancing reliability and debugging capabilities.
423 lines
11 KiB
Go
423 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/png"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/andyleap/hnh-map/internal/app"
|
|
"github.com/andyleap/hnh-map/internal/app/store"
|
|
"go.etcd.io/bbolt"
|
|
"golang.org/x/image/draw"
|
|
)
|
|
|
|
type zoomproc struct {
|
|
c app.Coord
|
|
m int
|
|
}
|
|
|
|
// MapService handles map, markers, grids, tiles business logic.
|
|
type MapService struct {
|
|
st *store.Store
|
|
gridStorage string
|
|
gridUpdates *app.Topic[app.TileData]
|
|
mergeUpdates *app.Topic[app.Merge]
|
|
getChars func() []app.Character
|
|
}
|
|
|
|
// MapServiceDeps holds dependencies for MapService construction.
|
|
type MapServiceDeps struct {
|
|
Store *store.Store
|
|
GridStorage string
|
|
GridUpdates *app.Topic[app.TileData]
|
|
MergeUpdates *app.Topic[app.Merge]
|
|
GetChars func() []app.Character
|
|
}
|
|
|
|
// NewMapService creates a MapService with the given dependencies.
|
|
func NewMapService(d MapServiceDeps) *MapService {
|
|
return &MapService{
|
|
st: d.Store,
|
|
gridStorage: d.GridStorage,
|
|
gridUpdates: d.GridUpdates,
|
|
mergeUpdates: d.MergeUpdates,
|
|
getChars: d.GetChars,
|
|
}
|
|
}
|
|
|
|
// GridStorage returns the grid storage directory path.
|
|
func (s *MapService) GridStorage() string { return s.gridStorage }
|
|
|
|
// GetCharacters returns all current characters.
|
|
func (s *MapService) GetCharacters() []app.Character {
|
|
if s.getChars == nil {
|
|
return nil
|
|
}
|
|
return s.getChars()
|
|
}
|
|
|
|
// GetMarkers returns all markers with computed map positions.
|
|
func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) {
|
|
var markers []app.FrontendMarker
|
|
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
grid := s.st.GetMarkersGridBucket(tx)
|
|
if grid == nil {
|
|
return nil
|
|
}
|
|
grids := tx.Bucket(store.BucketGrids)
|
|
if grids == nil {
|
|
return nil
|
|
}
|
|
return grid.ForEach(func(k, v []byte) error {
|
|
marker := app.Marker{}
|
|
if err := json.Unmarshal(v, &marker); err != nil {
|
|
return err
|
|
}
|
|
graw := grids.Get([]byte(marker.GridID))
|
|
if graw == nil {
|
|
return nil
|
|
}
|
|
g := app.GridData{}
|
|
if err := json.Unmarshal(graw, &g); err != nil {
|
|
return err
|
|
}
|
|
markers = append(markers, app.FrontendMarker{
|
|
Image: marker.Image,
|
|
Hidden: marker.Hidden,
|
|
ID: marker.ID,
|
|
Name: marker.Name,
|
|
Map: g.Map,
|
|
Position: app.Position{
|
|
X: marker.Position.X + g.Coord.X*app.GridSize,
|
|
Y: marker.Position.Y + g.Coord.Y*app.GridSize,
|
|
},
|
|
})
|
|
return nil
|
|
})
|
|
})
|
|
return markers, err
|
|
}
|
|
|
|
// GetMaps returns all maps, optionally including hidden ones.
|
|
func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) {
|
|
maps := make(map[int]*app.MapInfo)
|
|
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
return s.st.ForEachMap(tx, func(k, v []byte) error {
|
|
mapid, err := strconv.Atoi(string(k))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
mi := &app.MapInfo{}
|
|
if err := json.Unmarshal(v, mi); err != nil {
|
|
return err
|
|
}
|
|
if mi.Hidden && !showHidden {
|
|
return nil
|
|
}
|
|
maps[mapid] = mi
|
|
return nil
|
|
})
|
|
})
|
|
return maps, err
|
|
}
|
|
|
|
// GetConfig returns the application config for the frontend.
|
|
func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) {
|
|
config := app.Config{Auths: auths}
|
|
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
title := s.st.GetConfig(tx, "title")
|
|
if title != nil {
|
|
config.Title = string(title)
|
|
}
|
|
return nil
|
|
})
|
|
return config, err
|
|
}
|
|
|
|
// GetPage returns page metadata (title).
|
|
func (s *MapService) GetPage(ctx context.Context) (app.Page, error) {
|
|
p := app.Page{}
|
|
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
title := s.st.GetConfig(tx, "title")
|
|
if title != nil {
|
|
p.Title = string(title)
|
|
}
|
|
return nil
|
|
})
|
|
return p, err
|
|
}
|
|
|
|
// GetGrid returns a grid by its ID.
|
|
func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) {
|
|
var gd *app.GridData
|
|
err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
raw := s.st.GetGrid(tx, id)
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
gd = &app.GridData{}
|
|
return json.Unmarshal(raw, gd)
|
|
})
|
|
return gd, err
|
|
}
|
|
|
|
// GetTile returns a tile by map ID, coordinate, and zoom level.
|
|
func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData {
|
|
var td *app.TileData
|
|
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
raw := s.st.GetTile(tx, mapID, zoom, c.Name())
|
|
if raw != nil {
|
|
td = &app.TileData{}
|
|
return json.Unmarshal(raw, td)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil
|
|
}
|
|
return td
|
|
}
|
|
|
|
// getSubTiles returns up to 4 tile data for the given parent coord at zoom z-1 (sub-tiles at z).
|
|
// Order: (0,0), (1,0), (0,1), (1,1) to match the 2x2 loop in UpdateZoomLevel.
|
|
func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z int) []*app.TileData {
|
|
coords := []app.Coord{
|
|
{X: c.X*2 + 0, Y: c.Y*2 + 0},
|
|
{X: c.X*2 + 1, Y: c.Y*2 + 0},
|
|
{X: c.X*2 + 0, Y: c.Y*2 + 1},
|
|
{X: c.X*2 + 1, Y: c.Y*2 + 1},
|
|
}
|
|
keys := make([]string, len(coords))
|
|
for i := range coords {
|
|
keys[i] = coords[i].Name()
|
|
}
|
|
var rawMap map[string][]byte
|
|
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
rawMap = s.st.GetTiles(tx, mapid, z-1, keys)
|
|
return nil
|
|
}); err != nil {
|
|
return nil
|
|
}
|
|
result := make([]*app.TileData, 4)
|
|
for i, k := range keys {
|
|
if raw, ok := rawMap[k]; ok && len(raw) > 0 {
|
|
td := &app.TileData{}
|
|
if json.Unmarshal(raw, td) == nil {
|
|
result[i] = td
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SaveTile persists a tile and broadcasts the update.
|
|
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
|
|
_ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
|
td := &app.TileData{
|
|
MapID: mapid,
|
|
Coord: c,
|
|
Zoom: z,
|
|
File: f,
|
|
Cache: t,
|
|
}
|
|
raw, err := json.Marshal(td)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.gridUpdates.Send(td)
|
|
return s.st.PutTile(tx, mapid, z, c.Name(), raw)
|
|
})
|
|
}
|
|
|
|
// UpdateZoomLevel composes a zoom tile from 4 sub-tiles (one View for all 4 tile reads).
|
|
func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) {
|
|
subTiles := s.getSubTiles(ctx, mapid, c, z)
|
|
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
|
|
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
|
|
for i := 0; i < 4; i++ {
|
|
td := subTiles[i]
|
|
if td == nil || td.File == "" {
|
|
continue
|
|
}
|
|
x := i % 2
|
|
y := i / 2
|
|
subf, err := os.Open(filepath.Join(s.gridStorage, td.File))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
subimg, _, err := image.Decode(subf)
|
|
subf.Close()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil)
|
|
}
|
|
if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil {
|
|
slog.Error("failed to create zoom dir", "error", err)
|
|
return
|
|
}
|
|
path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())
|
|
relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name())
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
slog.Error("failed to create tile file", "path", path, "error", err)
|
|
return
|
|
}
|
|
if err := png.Encode(f, img); err != nil {
|
|
f.Close()
|
|
os.Remove(path)
|
|
slog.Error("failed to encode tile PNG", "path", path, "error", err)
|
|
return
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
slog.Error("failed to close tile file", "path", path, "error", err)
|
|
return
|
|
}
|
|
s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano())
|
|
}
|
|
|
|
// RebuildZooms rebuilds all zoom levels from base tiles.
|
|
// It can take a long time for many grids; the client should account for request timeouts.
|
|
func (s *MapService) RebuildZooms(ctx context.Context) error {
|
|
needProcess := map[zoomproc]struct{}{}
|
|
saveGrid := map[zoomproc]string{}
|
|
|
|
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
|
b := tx.Bucket(store.BucketGrids)
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
if err := b.ForEach(func(k, v []byte) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
grid := app.GridData{}
|
|
if err := json.Unmarshal(v, &grid); err != nil {
|
|
return err
|
|
}
|
|
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
|
|
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.DeleteBucket(store.BucketTiles); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
slog.Error("RebuildZooms: failed to update store", "error", err)
|
|
return err
|
|
}
|
|
|
|
for g, id := range saveGrid {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
|
|
if _, err := os.Stat(f); err != nil {
|
|
continue
|
|
}
|
|
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
|
|
}
|
|
for z := 1; z <= app.MaxZoomLevel; z++ {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
process := needProcess
|
|
needProcess = map[zoomproc]struct{}{}
|
|
for p := range process {
|
|
s.UpdateZoomLevel(ctx, p.m, p.c, z)
|
|
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReportMerge sends a merge event.
|
|
func (s *MapService) ReportMerge(from, to int, shift app.Coord) {
|
|
s.mergeUpdates.Send(&app.Merge{
|
|
From: from,
|
|
To: to,
|
|
Shift: shift,
|
|
})
|
|
}
|
|
|
|
// WatchTiles creates a channel that receives tile updates.
|
|
func (s *MapService) WatchTiles() chan *app.TileData {
|
|
c := make(chan *app.TileData, app.SSETileChannelSize)
|
|
s.gridUpdates.Watch(c)
|
|
return c
|
|
}
|
|
|
|
// WatchMerges creates a channel that receives merge updates.
|
|
func (s *MapService) WatchMerges() chan *app.Merge {
|
|
c := make(chan *app.Merge, app.SSEMergeChannelSize)
|
|
s.mergeUpdates.Watch(c)
|
|
return c
|
|
}
|
|
|
|
// GetAllTileCache returns all tiles for the initial SSE cache dump.
|
|
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
|
|
var cache []TileCache
|
|
_ = s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
td := app.TileData{}
|
|
if err := json.Unmarshal(v, &td); err != nil {
|
|
return err
|
|
}
|
|
cache = append(cache, TileCache{
|
|
M: td.MapID,
|
|
X: td.Coord.X,
|
|
Y: td.Coord.Y,
|
|
Z: td.Zoom,
|
|
T: int(td.Cache),
|
|
})
|
|
return nil
|
|
})
|
|
})
|
|
return cache
|
|
}
|
|
|
|
// TileCache represents a minimal tile entry for SSE streaming.
|
|
type TileCache struct {
|
|
M, X, Y, Z, T int
|
|
}
|
|
|
|
// ProcessZoomLevels processes zoom levels for a set of tile operations.
|
|
func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) {
|
|
needProcess := map[zoomproc]struct{}{}
|
|
for _, op := range ops {
|
|
s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
|
|
needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{}
|
|
}
|
|
for z := 1; z <= app.MaxZoomLevel; z++ {
|
|
process := needProcess
|
|
needProcess = map[zoomproc]struct{}{}
|
|
for p := range process {
|
|
s.UpdateZoomLevel(ctx, p.m, p.c, z)
|
|
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TileOp represents a tile save operation.
|
|
type TileOp struct {
|
|
MapID int
|
|
X, Y int
|
|
File string
|
|
}
|