From acc4e97a460c06bd32179ae02457993fe676f773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wysoki=C5=84ski?= Date: Mon, 10 Jun 2024 16:31:33 +0200 Subject: [PATCH] feat: server map generator --- .golangci.yml | 3 + .woodpecker/test.yml | 1 + go.mod | 1 + go.sum | 2 + internal/domain/domaintest/server_config.go | 4 +- internal/domain/domaintest/village.go | 8 +- internal/domain/server_map.go | 370 +++++++++++++++++++- internal/domain/server_map_test.go | 155 ++++++++ 8 files changed, 533 insertions(+), 11 deletions(-) create mode 100644 internal/domain/server_map_test.go diff --git a/.golangci.yml b/.golangci.yml index 3d64ebd..0a518c4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -505,6 +505,9 @@ issues: linters: - gosec - gocyclo + - path: server_map.go + linters: + - mnd - path: _test\.go text: add-constant - path: bun/migrations diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml index 69e8179..9a144d7 100644 --- a/.woodpecker/test.yml +++ b/.woodpecker/test.yml @@ -25,6 +25,7 @@ steps: image: *go_image pull: true commands: + - go mod download - go mod vendor - make generate diff --git a/go.mod b/go.mod index 62e76b9..40426dd 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/uptrace/bun/extra/bundebug v1.2.1 github.com/urfave/cli/v2 v2.27.2 go.uber.org/automaxprocs v1.5.3 + golang.org/x/image v0.17.0 golang.org/x/sync v0.7.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 9d2cd09..833e26e 100644 --- a/go.sum +++ b/go.sum @@ -233,6 +233,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/image v0.17.0 h1:nTRVVdajgB8zCMZVsViyzhnMKPwYeroEERRC64JuLco= +golang.org/x/image v0.17.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= diff --git a/internal/domain/domaintest/server_config.go b/internal/domain/domaintest/server_config.go index 458bf14..70d0381 100644 --- a/internal/domain/domaintest/server_config.go +++ b/internal/domain/domaintest/server_config.go @@ -20,7 +20,9 @@ func NewServerConfig(tb TestingTB) domain.ServerConfig { domain.ServerConfigBuildings{}, domain.ServerConfigSnob{}, domain.ServerConfigAlly{Limit: gofakeit.IntRange(1, 20)}, - domain.ServerConfigCoord{}, + domain.ServerConfigCoord{ + MapSize: gofakeit.IntRange(1000, 1000), + }, domain.ServerConfigSitter{}, domain.ServerConfigSleep{}, domain.ServerConfigNight{}, diff --git a/internal/domain/domaintest/village.go b/internal/domain/domaintest/village.go index 7c026ec..74b33c7 100644 --- a/internal/domain/domaintest/village.go +++ b/internal/domain/domaintest/village.go @@ -37,6 +37,8 @@ func NewVillageCursor(tb TestingTB, opts ...func(cfg *VillageCursorConfig)) doma type VillageConfig struct { ID int ServerKey string + X int + Y int PlayerID int } @@ -46,6 +48,8 @@ func NewVillage(tb TestingTB, opts ...func(cfg *VillageConfig)) domain.Village { cfg := &VillageConfig{ ID: RandID(), ServerKey: RandServerKey(), + X: gofakeit.IntRange(1, 999), + Y: gofakeit.IntRange(1, 999), PlayerID: gofakeit.IntRange(0, 10000), } @@ -58,8 +62,8 @@ func NewVillage(tb TestingTB, opts ...func(cfg *VillageConfig)) domain.Village { cfg.ServerKey, gofakeit.LetterN(50), gofakeit.IntRange(1, 10000), - gofakeit.IntRange(1, 999), - gofakeit.IntRange(1, 999), + cfg.X, + cfg.Y, gofakeit.LetterN(3), 0, cfg.PlayerID, diff --git a/internal/domain/server_map.go b/internal/domain/server_map.go index e4153fb..637bbbe 100644 --- a/internal/domain/server_map.go +++ b/internal/domain/server_map.go @@ -1,23 +1,109 @@ package domain +import ( + "fmt" + "image" + "image/color" + "image/png" + "io" + "strconv" + + "golang.org/x/image/draw" + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" +) + +type ServerMapMarker struct { + village VillageMeta + color color.RGBA +} + +func NewServerMapMarker(village VillageMeta) ServerMapMarker { + return ServerMapMarker{ + village: village, + color: color.RGBA{R: 255, G: 99, B: 71, A: 255}, + } +} + +func (m *ServerMapMarker) Village() VillageMeta { + return m.village +} + +func (m *ServerMapMarker) SetColor(c color.RGBA) error { + m.color = c + return nil +} + +func (m *ServerMapMarker) Color() color.RGBA { + return m.color +} + +type ServerMapLegendEntry struct { + color color.Color + names []string +} + +func NewServerMapLegendEntry(c color.Color, names ...string) ServerMapLegendEntry { + return ServerMapLegendEntry{color: c, names: names} +} + +func (e ServerMapLegendEntry) Color() color.Color { + return e.color +} + +func (e ServerMapLegendEntry) Names() []string { + return e.names +} + type ServerMap struct { - server Server - villagesCh <-chan VillageMetaWithRelations - scale float32 + server Server + size int + markersCh <-chan ServerMapMarker + backgroundColor color.RGBA + scale float32 + centerX int + centerY int + continentGrid bool + gridLineColor color.RGBA + continentNumbers bool + continentNumberColor color.RGBA + legendEntries []ServerMapLegendEntry } const serverMapModelName = "ServerMap" -func NewServerMap(server Server, villagesCh <-chan VillageMetaWithRelations) ServerMap { +func NewServerMap(server Server, markersCh <-chan ServerMapMarker) ServerMap { + size := server.Config().Coord().MapSize + return ServerMap{ - server: server, - villagesCh: villagesCh, - scale: 1, + server: server, + size: size, + markersCh: markersCh, + backgroundColor: color.RGBA{ + A: 255, + }, + scale: 1, + centerX: size / 2, + centerY: size / 2, + continentGrid: true, + gridLineColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, + continentNumbers: true, + continentNumberColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, } } +func (m *ServerMap) Scale() float32 { + return m.scale +} + +const ( + serverMapMinScale = 1 + serverMapMaxScale = 5 +) + func (m *ServerMap) SetScale(scale float32) error { - if err := validateInRange(scale, 0, 1); err != nil { + if err := validateInRange(scale, serverMapMinScale, serverMapMaxScale); err != nil { return ValidationError{ Model: serverMapModelName, Field: "scale", @@ -29,3 +115,271 @@ func (m *ServerMap) SetScale(scale float32) error { return nil } + +func (m *ServerMap) BackgroundColor() color.RGBA { + return m.backgroundColor +} + +func (m *ServerMap) SetBackgroundColor(backgroundColor color.RGBA) error { + m.backgroundColor = backgroundColor + return nil +} + +func (m *ServerMap) CenterX() int { + return m.centerX +} + +func (m *ServerMap) SetCenterX(centerX int) error { + m.centerX = centerX + return nil +} + +func (m *ServerMap) CenterY() int { + return m.centerY +} + +func (m *ServerMap) SetCenterY(centerY int) error { + m.centerY = centerY + return nil +} + +func (m *ServerMap) ContinentGrid() bool { + return m.continentGrid +} + +func (m *ServerMap) SetContinentGrid(continentGrid bool) error { + m.continentGrid = continentGrid + return nil +} + +func (m *ServerMap) GridLineColor() color.RGBA { + return m.gridLineColor +} + +func (m *ServerMap) SetGridLineColor(gridLineColor color.RGBA) error { + m.gridLineColor = gridLineColor + return nil +} + +func (m *ServerMap) ContinentNumbers() bool { + return m.continentNumbers +} + +func (m *ServerMap) SetContinentNumbers(continentNumbers bool) error { + m.continentNumbers = continentNumbers + return nil +} + +func (m *ServerMap) ContinentNumberColor() color.RGBA { + return m.continentNumberColor +} + +func (m *ServerMap) SetContinentNumberColor(continentNumberColor color.RGBA) error { + m.continentNumberColor = continentNumberColor + return nil +} + +func (m *ServerMap) LegendEntries() []ServerMapLegendEntry { + return m.legendEntries +} + +func (m *ServerMap) SetLegendEntries(legendEntries []ServerMapLegendEntry) error { + m.legendEntries = legendEntries + return nil +} + +func (m *ServerMap) Generate(w io.Writer) error { + r := image.Rectangle{Max: image.Point{X: m.size, Y: m.size}} + imgHalfWidth := r.Dx() / 2 + imgHalfHeight := r.Dy() / 2 + + img := image.NewRGBA(r) + + draw.Draw(img, img.Bounds(), image.NewUniform(m.backgroundColor), image.Point{}, draw.Src) + m.drawContinentGrid(img) + m.drawMarkers(img) + m.drawContinentNumbers(img) + legend, err := m.generateLegend() + if err != nil { + return err + } + + scaledImg := m.scaleImg(img) + + final := image.NewRGBA(image.Rect(0, 0, r.Dx()+legend.Rect.Dx(), r.Dy())) + draw.Draw(final, scaledImg.Rect, scaledImg, image.Point{ + X: int(float32(m.centerX)*m.scale) - imgHalfWidth, + Y: int(float32(m.centerY)*m.scale) - imgHalfHeight, + }, draw.Src) + draw.Draw(final, image.Rect(r.Dx(), 0, final.Rect.Dx(), final.Rect.Dy()), legend, image.Point{}, draw.Src) + + if err = png.Encode(w, final); err != nil { + return fmt.Errorf("couldn't encode image: %w", err) + } + + return nil +} + +const serverMapContinentDx = 100 + +func (m *ServerMap) drawContinentGrid(img *image.RGBA) { + if !m.continentGrid { + return + } + + for y := serverMapContinentDx; y < m.size; y += serverMapContinentDx { + for x := range m.size { + img.Set(x, y, m.gridLineColor) + img.Set(y, x, m.gridLineColor) + } + } +} + +const serverMapMarkerRectSideLength = 1 + +func (m *ServerMap) drawMarkers(img *image.RGBA) { + for marker := range m.markersCh { + village := marker.Village() + x := village.X() + y := village.Y() + rect := image.Rect(x, y, x+serverMapMarkerRectSideLength, y+serverMapMarkerRectSideLength) + draw.Draw(img, rect, &image.Uniform{C: marker.color}, image.Point{}, draw.Over) + } +} + +func (m *ServerMap) drawContinentNumbers(img *image.RGBA) { + if !m.continentNumbers { + return + } + + continent := 0 + + d := &font.Drawer{ + Dst: img, + Src: image.NewUniform(m.continentNumberColor), + Face: basicfont.Face7x13, + } + + for y := serverMapContinentDx; y <= m.size; y += serverMapContinentDx { + for x := serverMapContinentDx; x <= m.size; x += serverMapContinentDx { + continentStr := strconv.Itoa(continent) + + d.Dot = fixed.Point26_6{ + X: fixed.I(x) - d.MeasureString(continentStr), + Y: fixed.I(y - 1), + } + + d.DrawString(continentStr) + + continent++ + } + } +} + +func (m *ServerMap) scaleImg(img *image.RGBA) *image.RGBA { + if m.scale == 1 { + return img + } + + scaledSize := int(float32(m.size) * m.scale) + scaledImg := image.NewRGBA(image.Rect(0, 0, scaledSize, scaledSize)) + draw.NearestNeighbor.Scale(scaledImg, scaledImg.Rect, img, img.Rect, draw.Over, nil) + + return scaledImg +} + +const ( + serverMapLegendWidth = 400 + serverMapLegendPadding = 10 + serverMapColorWidth = 40 + serverMapSpaceColorText = 10 + serverMapLegendStringSeparator = " + " + serverMapLegendStringOverflow = "..." + serverMapLegendMaxStringLength = 45 +) + +var ErrTooManyLegendEntries error = simpleError{ + msg: "too many legend entries", + typ: ErrorTypeIncorrectInput, + code: "too-many-legend-entries", +} + +func (m *ServerMap) generateLegend() (*image.RGBA, error) { + if len(m.legendEntries) == 0 { + return image.NewRGBA(image.Rectangle{}), nil + } + + img := image.NewRGBA(image.Rectangle{Max: image.Point{X: serverMapLegendWidth, Y: m.size}}) + d := &font.Drawer{ + Dst: img, + Src: image.NewUniform(m.continentNumberColor), + Face: basicfont.Face7x13, + } + lineHeight := basicfont.Face7x13.Metrics().Ascent.Ceil() + basicfont.Face7x13.Metrics().Descent.Ceil() + lineMaxWidth := img.Rect.Dx() - serverMapLegendPadding*2 - serverMapColorWidth - serverMapSpaceColorText + offset := serverMapLegendPadding + + for _, e := range m.legendEntries { + namesLen := len(e.Names()) + + currentWidth := 0 + line := "" + var lines []string + + for i, name := range e.Names() { + if len(name) > serverMapLegendMaxStringLength { + name = name[:serverMapLegendMaxStringLength-len(serverMapLegendStringOverflow)] + "..." + } + + if i != namesLen-1 { + name += serverMapLegendStringSeparator + } + + strWidth := d.MeasureString(name).Ceil() + + if currentWidth+strWidth > lineMaxWidth { + lines = append(lines, line) + currentWidth = 0 + line = "" + } + + line += name + currentWidth += strWidth + } + + if line != "" { + lines = append(lines, line) + } + + yMin := offset + (len(lines) * lineHeight / 2) - lineHeight/2 + + draw.Draw( + img, + image.Rect( + serverMapLegendPadding, + yMin, + serverMapColorWidth+serverMapLegendPadding, + yMin+lineHeight, + ), + image.NewUniform(e.Color()), + image.Point{}, + draw.Src, + ) + + for i, l := range lines { + d.Dot = fixed.Point26_6{ + X: fixed.I(serverMapColorWidth + serverMapLegendPadding + serverMapSpaceColorText), + Y: fixed.I(offset + (i+1)*lineHeight), + } + d.DrawString(l) + } + + offset += (len(lines) * lineHeight) + serverMapLegendPadding + + if offset > img.Bounds().Dy()-serverMapLegendPadding { + return nil, ErrTooManyLegendEntries + } + } + + return img, nil +} diff --git a/internal/domain/server_map_test.go b/internal/domain/server_map_test.go new file mode 100644 index 0000000..a444f61 --- /dev/null +++ b/internal/domain/server_map_test.go @@ -0,0 +1,155 @@ +package domain_test + +import ( + "image/color" + "io" + "testing" + + "gitea.dwysokinski.me/twhelp/core/internal/domain" + "gitea.dwysokinski.me/twhelp/core/internal/domain/domaintest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServerMap_SetScale(t *testing.T) { + t.Parallel() + + type args struct { + scale float32 + } + + tests := []struct { + name string + args args + expectedErr error + }{ + { + name: "OK", + args: args{ + scale: 1, + }, + }, + { + name: "ERR: scale < 1", + args: args{ + scale: 0.99, + }, + expectedErr: domain.ValidationError{ + Model: "ServerMap", + Field: "scale", + Err: domain.MinGreaterEqualError[float32]{ + Min: 1, + Current: 0.99, + }, + }, + }, + { + name: "ERR: scale > 5", + args: args{ + scale: 5.00001, + }, + expectedErr: domain.ValidationError{ + Model: "ServerMap", + Field: "scale", + Err: domain.MaxLessEqualError[float32]{ + Max: 5, + Current: 5.00001, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + params := domain.NewServerMap(domaintest.NewServer(t), make(chan domain.ServerMapMarker)) + + require.ErrorIs(t, params.SetScale(tt.args.scale), tt.expectedErr) + if tt.expectedErr != nil { + return + } + assert.InDelta(t, tt.args.scale, params.Scale(), 0.001) + }) + } +} + +func TestServerMap_Generate(t *testing.T) { + t.Parallel() + + t.Run("OK: default", func(t *testing.T) { + t.Parallel() + + ch := make(chan domain.ServerMapMarker) + + go func() { + defer close(ch) + for i := range 500 { + ch <- domain.NewServerMapMarker(domaintest.NewVillage(t, func(cfg *domaintest.VillageConfig) { + cfg.X = i + cfg.Y = i + }).Meta()) + } + }() + + m := domain.NewServerMap(domaintest.NewServer(t), ch) + require.NoError(t, m.Generate(io.Discard)) + }) + + t.Run("OK: scale=2 legend", func(t *testing.T) { + t.Parallel() + + ch := make(chan domain.ServerMapMarker) + + go func() { + defer close(ch) + for i := range 500 { + ch <- domain.NewServerMapMarker(domaintest.NewVillage(t, func(cfg *domaintest.VillageConfig) { + cfg.X = i + cfg.Y = i + }).Meta()) + } + }() + + m := domain.NewServerMap(domaintest.NewServer(t), ch) + require.NoError(t, m.SetLegendEntries([]domain.ServerMapLegendEntry{ + domain.NewServerMapLegendEntry( + color.RGBA{R: 255, G: 204, B: 229, A: 255}, + "test22222^", + "test222", + ), + domain.NewServerMapLegendEntry( + color.RGBA{R: 255, G: 255, B: 255, A: 255}, + "test22222^", + "test2223", + "longstringlongstringlongstringlongstring", + "longstringlongstringlongstringlongstringlongstringlongstringlongstringlongstring", + "longstringlongstring2", + ), + })) + require.NoError(t, m.SetScale(2)) + require.NoError(t, m.Generate(io.Discard)) + }) + + t.Run("OK: scale=2 centerX=250 centerY=250", func(t *testing.T) { + t.Parallel() + + ch := make(chan domain.ServerMapMarker) + + go func() { + defer close(ch) + for i := range 500 { + ch <- domain.NewServerMapMarker(domaintest.NewVillage(t, func(cfg *domaintest.VillageConfig) { + cfg.X = i + cfg.Y = i + }).Meta()) + } + }() + + m := domain.NewServerMap(domaintest.NewServer(t), ch) + require.NoError(t, m.SetCenterX(250)) + require.NoError(t, m.SetCenterY(250)) + require.NoError(t, m.SetScale(2)) + require.NoError(t, m.Generate(io.Discard)) + }) +}