feat: server map generator #59

Merged
Kichiyaki merged 1 commits from feat/server-map into master 2024-06-21 05:03:08 +00:00
8 changed files with 569 additions and 71 deletions

View File

@ -505,6 +505,9 @@ issues:
linters:
- gosec
- gocyclo
- path: server_map.go
linters:
- mnd
- path: _test\.go
text: add-constant
- path: bun/migrations

View File

@ -1,33 +1,27 @@
package main
import (
"errors"
"fmt"
"go/ast"
"go/constant"
"go/importer"
"go/parser"
"go/token"
"go/types"
"io/fs"
"log"
"os"
"slices"
"strings"
"golang.org/x/tools/go/packages"
"gopkg.in/yaml.v3"
)
func main() {
if _, err := os.Stat("./go.mod"); errors.Is(err, os.ErrNotExist) {
log.Fatalln("go.mod not found")
}
if err := generateYAML("./internal/domain"); err != nil {
if err := generateYAML("gitea.dwysokinski.me/twhelp/core/internal/domain"); err != nil {
log.Fatalln(err)
}
}
func generateYAML(root string) error {
m, err := getErrorCodesFromFolder(root)
func generateYAML(pkgPattern string) error {
m, err := getErrorCodesFromPackage(pkgPattern)
if err != nil {
return err
}
@ -48,6 +42,8 @@ func generateYAML(root string) error {
enum = append(enum, c)
}
slices.Sort(enum)
return yaml.NewEncoder(os.Stdout).Encode(struct {
Type string `yaml:"type"`
Description string `yaml:"description"`
@ -60,27 +56,25 @@ func generateYAML(root string) error {
}
//nolint:gocyclo
func getErrorCodesFromFolder(root string) (map[string]string, error) {
fset := token.NewFileSet()
astDomainPkg, err := parseDomainPackage(fset, root)
func getErrorCodesFromPackage(pkgPattern string) (map[string]string, error) {
pkg, err := packages.Load(&packages.Config{
Tests: false,
Mode: packages.NeedName |
packages.NeedFiles |
packages.NeedCompiledGoFiles |
packages.NeedTypes |
packages.NeedTypesInfo |
packages.NeedSyntax,
}, pkgPattern)
if err != nil {
return nil, err
}
conf := types.Config{Importer: importer.Default()}
files := make([]*ast.File, 0, len(astDomainPkg.Files))
for _, f := range astDomainPkg.Files {
files = append(files, f)
if len(pkg) == 0 {
return nil, fmt.Errorf("'%s': pkg not found", pkgPattern)
}
domainPkg, err := conf.Check(astDomainPkg.Name, fset, files, nil)
if err != nil {
return nil, err
}
scope := domainPkg.Scope()
domainPkg := pkg[0]
scope := domainPkg.Types.Scope()
m := make(map[string]string)
for _, n := range scope.Names() {
@ -95,14 +89,21 @@ func getErrorCodesFromFolder(root string) (map[string]string, error) {
continue
}
f := astDomainPkg.Files[fset.File(c.Pos()).Name()]
position := fset.Position(c.Pos())
commentMap := ast.NewCommentMap(fset, f, f.Comments)
filename := domainPkg.Fset.File(c.Pos()).Name()
fileIdx := slices.IndexFunc(domainPkg.GoFiles, func(f string) bool {
return f == filename
})
if fileIdx < 0 {
return nil, fmt.Errorf("'%s': file not found", filename)
}
f := domainPkg.Syntax[fileIdx]
position := domainPkg.Fset.Position(c.Pos())
commentMap := ast.NewCommentMap(domainPkg.Fset, f, f.Comments)
var desc string
for _, decl := range f.Decls {
declPosition := fset.Position(decl.Pos())
declPosition := domainPkg.Fset.Position(decl.Pos())
if declPosition.Filename != position.Filename || declPosition.Line != position.Line {
continue
}
@ -123,27 +124,3 @@ func getErrorCodesFromFolder(root string) (map[string]string, error) {
return m, nil
}
func parseDomainPackage(fset *token.FileSet, root string) (*ast.Package, error) {
pkgs, err := parser.ParseDir(fset, root, func(info fs.FileInfo) bool {
if info.IsDir() && info.Name() != root {
return false
}
if info.IsDir() || strings.HasSuffix(info.Name(), "_test.go") {
return false
}
return true
}, parser.AllErrors|parser.ParseComments)
if err != nil {
return nil, err
}
astDomainPkg, ok := pkgs["domain"]
if !ok {
return nil, errors.New("domain pkg not found")
}
return astDomainPkg, nil
}

5
go.mod
View File

@ -28,7 +28,9 @@ 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
golang.org/x/tools v0.19.0
gopkg.in/yaml.v3 v3.0.1
)
@ -43,7 +45,7 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/docker/cli v20.10.17+incompatible // indirect
github.com/docker/docker v20.10.7+incompatible // indirect
github.com/docker/docker v20.10.11+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@ -90,7 +92,6 @@ require (
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/tools v0.19.0 // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
mellium.im/sasl v0.3.1 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect

6
go.sum
View File

@ -40,8 +40,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/cli v20.10.17+incompatible h1:eO2KS7ZFeov5UJeaDmIs1NFEDRf32PaqRpvoEkKBy5M=
github.com/docker/cli v20.10.17+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v20.10.7+incompatible h1:Z6O9Nhsjv+ayUEeI1IojKbYcsGdgYSNqxe1s2MYzUhQ=
github.com/docker/docker v20.10.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v20.10.11+incompatible h1:OqzI/g/W54LczvhnccGqniFoQghHx3pklbLuhfXpqGo=
github.com/docker/docker v20.10.11+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
@ -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=

View File

@ -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{},

View File

@ -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,

View File

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

View File

@ -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))
})
}