fs storage

This commit is contained in:
Ilia Denisov
2026-03-13 21:07:23 +02:00
committed by GitHub
parent 43039a79bf
commit 9ade76e21d
117 changed files with 1734 additions and 176 deletions
+84
View File
@@ -0,0 +1,84 @@
package generator
import (
"fmt"
"math"
"math/rand"
)
func Generate(cfg ...func(*MapSetting)) (Map, error) {
ms := DefaultMapSetting()
for i := range cfg {
cfg[i](&ms)
}
size := ms.ExpectedSize()
m, err := NewMap(size, size, ms.Players)
if err != nil {
return Map{}, fmt.Errorf("%s: NewMap: %s", ms, err)
}
freePlanets := ms.NobodysPlanets()
createPlanets := func(pc PlanetClass, ps PlanetSetting) error {
return m.CreatePlanets(pc, ps.Number(freePlanets), float64(ps.MinDistanceHW), RandIFn(ps.MinSize, ps.MaxSize), RandIFn(ps.MinResource, ps.MaxResource))
}
// 1. Place Giant planets
if err := createPlanets(PlanetClassGiant, ms.GiantPlanets); err != nil {
return Map{}, fmt.Errorf("%s: create giant planets: %s", ms, err)
}
// 2. Place Big planets
if err := createPlanets(PlanetClassBig, ms.BigPlanets); err != nil {
return Map{}, fmt.Errorf("%s: create big planets: %s", ms, err)
}
// 3. Place players' Home Worlds
for player := 0; player < int(ms.Players); player++ {
hwCoord, err := m.NewCoordinate(float64(ms.HWMinDistance))
if err != nil {
return Map{}, fmt.Errorf("%s: hw new_coordinate: %s", ms, err)
}
hwPlanet := NewPlanet(PlanetClassHW, hwCoord, ms.HWSize, ms.HWResources)
m.HomePlanets[player] = PlanetarySystem{HW: hwPlanet, DW: make([]Planet, ms.DWCount)}
for dw := 0; dw < int(ms.DWCount); dw++ {
p := rand.Float64()*(float64(ms.DWMaxDistance)-float64(ms.DWMinDistance)) + float64(ms.DWMinDistance)
phi := rand.Float64() * 360
x := p * math.Cos(phi)
y := p * math.Sin(phi)
dwPlanet := NewPlanet(PlanetClassDW, Coordinate{hwCoord.X + x, hwCoord.Y + y}, ms.DWSize, ms.DWResources)
m.HomePlanets[player].DW[dw] = dwPlanet
}
}
// 4. Clear plotter and set dead zones around existing planets
m.plotter.Clear()
for i := range m.HomePlanets {
m.plotter.MarkDeadZone(m.HomePlanets[i].HW.Position.X, m.HomePlanets[i].HW.Position.Y, ms.OthersMinDistance)
for j := range m.HomePlanets[i].DW {
m.plotter.MarkDeadZone(m.HomePlanets[i].DW[j].Position.X, m.HomePlanets[i].DW[j].Position.Y, ms.OthersMinDistance)
}
}
for i := range m.FreePlanets {
m.plotter.MarkDeadZone(m.FreePlanets[i].Position.X, m.FreePlanets[i].Position.Y, ms.OthersMinDistance)
}
// 5. Place Normal planets
if err := createPlanets(PlanetClassNormal, ms.NormalPlanets); err != nil {
return Map{}, fmt.Errorf("%s: create normal planets: %s", ms, err)
}
// 6. Place Rich planets
if err := createPlanets(PlanetClassRich, ms.RichPlanets); err != nil {
return Map{}, fmt.Errorf("%s: create rich planets: %s", ms, err)
}
// 7. Place Asteroids
if err := createPlanets(PlanetClassAsterioid, ms.Asterioids); err != nil {
return Map{}, fmt.Errorf("%s: create asteroids: %s", ms, err)
}
return *m, nil
}
+107
View File
@@ -0,0 +1,107 @@
package generator_test
import (
"fmt"
"testing"
"galaxy/game/internal/generator"
"github.com/stretchr/testify/assert"
)
func TestGenerator(t *testing.T) {
maxPlayers := 30
for players := 10; players <= maxPlayers; players++ {
t.Run(fmt.Sprintf("%d_players", players), func(t *testing.T) {
var s generator.MapSetting
m, err := generator.Generate(func(ms *generator.MapSetting) {
ms.Players = uint32(players)
s = *ms
})
if err != nil {
t.Fatalf("generate: %s", err)
return
}
assert.Equal(t, players, len(m.HomePlanets), "hw-s count")
for hw := range m.HomePlanets {
assert.Equal(t, s.HWSize, m.HomePlanets[hw].HW.Size, "hw #%d: size", hw)
assert.Equal(t, s.HWResources, m.HomePlanets[hw].HW.Resources, "hw #%d: resources", hw)
assert.Equal(t, int(s.DWCount), len(m.HomePlanets[hw].DW), "hw #%d: dw-s count", hw)
for dw := range m.HomePlanets[hw].DW {
assert.Equal(t, s.DWSize, m.HomePlanets[hw].DW[dw].Size, "hw #%d dw #%d: size", hw, dw)
assert.Equal(t, s.DWResources, m.HomePlanets[hw].DW[dw].Resources, "hw #%d dw #%d: resources", hw, dw)
d := m.ShortDistance(m.HomePlanets[hw].HW.Position, m.HomePlanets[hw].DW[dw].Position)
assert.LessOrEqualf(t, float64(s.DWMinDistance), d, "distance: HW[%.04f,%04f] <-> DW[%.04f,%04f]",
m.HomePlanets[hw].HW.Position.X, m.HomePlanets[hw].HW.Position.Y,
m.HomePlanets[hw].DW[dw].Position.X, m.HomePlanets[hw].DW[dw].Position.Y)
assert.GreaterOrEqualf(t, float64(s.DWMaxDistance), d, "distance: HW[%.04f,%04f] <-> DW[%.04f,%04f]",
m.HomePlanets[hw].HW.Position.X, m.HomePlanets[hw].HW.Position.Y,
m.HomePlanets[hw].DW[dw].Position.X, m.HomePlanets[hw].DW[dw].Position.Y)
}
}
assert.LessOrEqualf(t, int(s.NobodysPlanets()), len(m.FreePlanets), "free planets clount")
freePlanetCount := make(map[generator.PlanetClass]int)
for fp := range m.FreePlanets {
ps := planetSettings(t, m.FreePlanets[fp].PlanetClass, s)
testPlanetParameters(t, ps, m.FreePlanets[fp])
if v, ok := freePlanetCount[m.FreePlanets[fp].PlanetClass]; !ok {
freePlanetCount[m.FreePlanets[fp].PlanetClass] = 1
} else {
freePlanetCount[m.FreePlanets[fp].PlanetClass] = v + 1
}
if ps.MinDistanceHW > 0 {
for hw := range m.HomePlanets {
d := m.ShortDistance(m.HomePlanets[hw].HW.Position, m.FreePlanets[fp].Position)
assert.LessOrEqualf(t, float64(ps.MinDistanceHW), d, "distance: HW[%.04f,%04f] <-> planet_class=%v[%.04f,%04f]",
m.HomePlanets[hw].HW.Position.X, m.HomePlanets[hw].HW.Position.Y,
m.FreePlanets[fp].PlanetClass,
m.FreePlanets[fp].Position.X, m.FreePlanets[fp].Position.Y)
}
}
}
for pc, num := range freePlanetCount {
ps := planetSettings(t, pc, s)
maxNum := ps.Number(s.NobodysPlanets())
assert.Equalf(t, num, maxNum, "planet_class=%v ratio=%f of total %d", pc, ps.Ratio, s.NobodysPlanets())
}
})
}
}
func testPlanetParameters(t *testing.T, s generator.PlanetSetting, p generator.Planet) {
assert.LessOrEqualf(t, s.MinResource, p.Resources, "planet class=%s min resources", p.PlanetClass)
assert.GreaterOrEqualf(t, s.MaxResource, p.Resources, "planet class=%s max resources", p.PlanetClass)
assert.LessOrEqualf(t, s.MinSize, p.Size, "planet class=%s min size", p.PlanetClass)
assert.GreaterOrEqualf(t, s.MaxSize, p.Size, "planet class=%s max size", p.PlanetClass)
}
func planetSettings(t *testing.T, pc generator.PlanetClass, s generator.MapSetting) generator.PlanetSetting {
switch pc {
case generator.PlanetClassGiant:
return s.GiantPlanets
case generator.PlanetClassBig:
return s.BigPlanets
case generator.PlanetClassNormal:
return s.NormalPlanets
case generator.PlanetClassRich:
return s.RichPlanets
case generator.PlanetClassAsterioid:
return s.Asterioids
default:
assert.FailNow(t, "unexpected planet class: %s", pc)
return generator.PlanetSetting{}
}
}
func BenchmarkGenerator(b *testing.B) {
i := 0
for b.Loop() {
i++
b.Run(fmt.Sprintf("instance #%02d", i), func(b *testing.B) {
_, err := generator.Generate()
if err != nil {
b.Error(err)
}
})
}
}
+75
View File
@@ -0,0 +1,75 @@
package generator
import (
"fmt"
"math/rand"
"galaxy/util"
"galaxy/game/internal/generator/plotter"
)
type Map struct {
Width uint32
Height uint32
HomePlanets []PlanetarySystem
FreePlanets []Planet
plotter plotter.Plotter
}
type Coordinate struct {
X, Y float64
}
func NewMap(width, height, players uint32) (*Map, error) {
p, err := plotter.NewPlotter(width, height, defaultFactor)
if err != nil {
return nil, fmt.Errorf("NewPlotter: %s", err)
}
return &Map{
Width: width,
Height: height,
HomePlanets: make([]PlanetarySystem, players),
plotter: p,
}, nil
}
func (m *Map) CreatePlanets(pc PlanetClass, num int, deadZoneRadius float64, size, resources func() float64) error {
for range num {
coord, err := m.NewCoordinate(deadZoneRadius)
if err != nil {
return err
}
planet := NewPlanet(pc, coord, size(), resources())
m.AddPlanet(planet)
}
return nil
}
func (m *Map) AddPlanet(planet Planet) {
m.FreePlanets = append(m.FreePlanets, planet)
}
func (m Map) NewCoordinate(deadZoneRaduis float64) (Coordinate, error) {
if x, y, err := m.plotter.RandomFreePoint(deadZoneRaduis); err != nil {
return Coordinate{}, fmt.Errorf("NewCoordinate: RandomFreePoint: %s", err)
} else {
return Coordinate{X: x, Y: y}, nil
}
}
func (m Map) ShortDistance(from, to Coordinate) float64 {
return util.ShortDistance(m.Width, m.Height, from.X, from.Y, to.X, to.Y)
}
// RandI returns a random float64 value between min and max
func RandI(min, max float64) float64 {
return min + rand.Float64()*(max-min)
}
// RandIFn is a wrapper for the [RandI] func
func RandIFn(min, max float64) func() float64 {
return func() float64 {
return RandI(min, max)
}
}
+28
View File
@@ -0,0 +1,28 @@
package generator
import (
"fmt"
"testing"
"galaxy/util"
"github.com/stretchr/testify/assert"
)
func TestShortDistance(t *testing.T) {
for i, tc := range []struct {
w, h uint32
x1, y1, x2, y2, d float64
}{
{10, 10, 0, 0, 5, 5, 7.071},
{10, 10, 0, 0, 5.01, 5.01, 7.057},
{10, 10, 2, 2, 8, 2, 4.},
{10, 10, 8, 7, 1, 7, 3.},
} {
t.Run(fmt.Sprint(i), func(t *testing.T) {
m := Map{Width: tc.w, Height: tc.h}
d := m.ShortDistance(Coordinate{tc.x1, tc.y1}, Coordinate{tc.x2, tc.y2})
assert.Equal(t, tc.d, util.Fixed3(d))
})
}
}
+43
View File
@@ -0,0 +1,43 @@
package generator
import (
"fmt"
"math/rand"
)
type PlanetClass string
const (
PlanetClassHW PlanetClass = "HW"
PlanetClassDW PlanetClass = "DW"
PlanetClassGiant PlanetClass = "Giant"
PlanetClassBig PlanetClass = "Big"
PlanetClassNormal PlanetClass = "Normal"
PlanetClassRich PlanetClass = "Rich"
PlanetClassAsterioid PlanetClass = "Asteroid"
)
type Planet struct {
PlanetClass PlanetClass
Position Coordinate
Size float64
Resources float64 // Сырьё
}
type PlanetarySystem struct {
HW Planet
DW []Planet
}
func (p Planet) RandomName() string {
return fmt.Sprintf("%s-%04d-%04d", p.PlanetClass, rand.Intn(1000), rand.Intn(1000))
}
func NewPlanet(pc PlanetClass, c Coordinate, size, resources float64) Planet {
return Planet{
PlanetClass: pc,
Position: c,
Size: size,
Resources: resources,
}
}
+31
View File
@@ -0,0 +1,31 @@
package generator_test
import (
"regexp"
"testing"
g "galaxy/game/internal/generator"
"github.com/stretchr/testify/assert"
)
func TestPlanetRandomName(t *testing.T) {
re, err := regexp.Compile(`^([a-zA-Z]+)-(\d{4})-(\d{4})$`)
assert.NoError(t, err)
if err != nil {
return
}
for _, pc := range []g.PlanetClass{g.PlanetClassHW, g.PlanetClassDW, g.PlanetClassGiant, g.PlanetClassBig, g.PlanetClassNormal, g.PlanetClassRich, g.PlanetClassAsterioid} {
t.Run(string(pc), func(t *testing.T) {
name := g.NewPlanet(pc, g.Coordinate{0, 0}, 0, 0).RandomName()
g := re.FindStringSubmatch(name)
assert.NotNilf(t, g, "cannot parse: %q", name)
if g == nil {
return
}
assert.Equalf(t, 4, len(g), "regexp groups")
assert.Equal(t, string(pc), g[1])
assert.NotEqual(t, g[2], g[3])
})
}
}
@@ -0,0 +1,76 @@
package plotter
import (
"errors"
"fmt"
"math"
"math/rand"
"galaxy/game/internal/bitmap"
)
type Plotter struct {
factor float64
clearFn func()
circleFn func(x, y int, r float64)
freeCountFn func() int
freeNumberToCoordFn func(int) (int, int, error)
}
func NewPlotter(width, height uint32, factor float64) (Plotter, error) {
return NewBitmapPlotter(NewBitmap(width, height, factor), factor)
}
func NewBitmap(width, height uint32, factor float64) bitmap.Bitmap {
return bitmap.NewBitmap(AsPlotterSize(width, height, factor))
}
func NewBitmapPlotter(bm bitmap.Bitmap, factor float64) (Plotter, error) {
if factor > 1 || factor <= 0 {
return Plotter{}, fmt.Errorf("factor should be: 0 > F <= 1")
}
return Plotter{
factor: factor,
clearFn: bm.Clear,
circleFn: func(x, y int, r float64) { bm.Circle(x, y, r, true) },
freeCountFn: bm.FreeCount,
freeNumberToCoordFn: bm.GetFreeN,
}, nil
}
func (p Plotter) RandomFreePoint(deadZoneRaduis float64) (float64, float64, error) {
fsCount := p.freeCountFn()
if fsCount == 0 {
return 0, 0, errors.New("RandomFreePoint: no free space left")
}
next := rand.Intn(fsCount)
x, y, err := p.freeNumberToCoordFn(next)
if err != nil {
return 0, 0, fmt.Errorf("RandomFreePoint: freeNumberToCoordFn: %s", err)
}
if deadZoneRaduis > 0 {
p.plotDeadZone(x, y, deadZoneRaduis)
}
planetX := float64(x)*p.factor + rand.Float64()*p.factor
planetY := float64(y)*p.factor + rand.Float64()*p.factor
return planetX, planetY, nil
}
func (p Plotter) MarkDeadZone(x, y float64, radius float64) {
p.plotDeadZone(int(x/p.factor), int(y/p.factor), radius)
}
func (p Plotter) plotDeadZone(x, y int, radius float64) {
// Adding extra 'pixel' to avoid radius became less than deadZoneRaduis
// after division by factor due to floating-point operations specifics
p.circleFn(x, y, (radius+p.factor)/p.factor)
}
func (p Plotter) Clear() { p.clearFn() }
func AsPlotterSize(width, height uint32, factor float64) (uint32, uint32) {
if factor > 1 || factor <= 0 {
return width, height
}
return uint32(math.Ceil(float64(width) / float64(factor))), uint32(math.Ceil(float64(height) / float64(factor)))
}
@@ -0,0 +1,86 @@
package plotter_test
import (
"testing"
"galaxy/game/internal/generator/plotter"
)
func TestNewPlotter(t *testing.T) {
_, err := plotter.NewPlotter(10, 10, 0)
if err == nil {
t.Error("expect: error when factor=0")
}
_, err = plotter.NewPlotter(10, 10, -0.01)
if err == nil {
t.Error("expect: error when factor<0")
}
_, err = plotter.NewPlotter(10, 10, 1.001)
if err == nil {
t.Error("expect: error when factor>1")
}
_, err = plotter.NewPlotter(10, 10, 1)
if err != nil {
t.Error("expect: no error when factor=1")
}
}
func TestAsPlotterSize(t *testing.T) {
for _, tc := range []struct {
w, h uint32
f float64
ew, eh uint32
}{
{10, 10, 0, 10, 10},
{10, 10, -1, 10, 10},
{10, 10, 1.1, 10, 10},
{10, 20, 0.5, 20, 40},
{30, 60, 0.3, 100, 200},
{10, 20, 0.25, 40, 80},
} {
w, h := plotter.AsPlotterSize(tc.w, tc.h, tc.f)
if tc.ew != w || tc.eh != h {
t.Errorf("expect: w=%d h=%d, got: w=%d h=%d", tc.ew, tc.eh, w, h)
}
}
}
func TestRandomFreePoint(t *testing.T) {
var factor float64 = 0.25
var w, h uint32 = 20, 20
bm := plotter.NewBitmap(w, h, factor)
p, err := plotter.NewBitmapPlotter(bm, factor) // 80x80
if err != nil {
t.Fatal(err)
}
x, y, err := p.RandomFreePoint(3)
if err != nil {
t.Errorf("expect: no error getting random point, got: %s", err)
}
if x > float64(w) || y > float64(w) {
t.Errorf("expect: point coordinates within map size %dx%d, got: x=%f y=%f", w, h, x, y)
}
_, _, err = p.RandomFreePoint(0)
if err != nil {
t.Errorf("expect: no error when radius is zero, got: %s", err)
}
_, _, err = p.RandomFreePoint(float64(w + h)) // guaranteed to mark whole area dead zone
if err != nil {
t.Errorf("expect: no error getting random point, got: %s", err)
}
_, _, err = p.RandomFreePoint(1)
if err == nil {
t.Error("expect: error when no free space left, got: none")
}
p.Clear()
_, _, err = p.RandomFreePoint(10)
if err != nil {
t.Errorf("expect: no error getting random point after clearing, got: %s", err)
}
}
+112
View File
@@ -0,0 +1,112 @@
package generator
import (
"fmt"
"math"
)
const defaultFactor float64 = 0.1
type MapSetting struct {
Players uint32
HWSize float64
HWResources float64
HWMinDistance uint32
DWCount uint32
DWSize float64
DWResources float64
DWMinDistance uint32
DWMaxDistance uint32
GiantPlanets PlanetSetting
BigPlanets PlanetSetting
OthersMinDistance float64
NormalPlanets PlanetSetting
RichPlanets PlanetSetting
Asterioids PlanetSetting
}
func (ms MapSetting) String() string {
return fmt.Sprintf("MapSetting[players=%d HWMinDistance=%d Size=%d]", ms.Players, ms.HWMinDistance, ms.ExpectedSize())
}
func (ms MapSetting) ExpectedSize() uint32 {
return uint32(math.Sqrt(float64(ms.Players)) * float64(ms.HWMinDistance) * 1.5)
}
func (ms MapSetting) TotalPlanets() uint32 {
return ms.Players * 10
}
func (ms MapSetting) NobodysPlanets() uint32 {
return ms.TotalPlanets() - ms.Players*(ms.DWCount+1)
}
type PlanetSetting struct {
MinDistanceHW uint32
MinSize float64
MaxSize float64
MinResource float64 // Rules: [0.1, 20]
MaxResource float64
Ratio float64 // The proportion of the total number of free planets in the galaxy
}
// Number of planets need to be placed within freePlanets amount
func (ps PlanetSetting) Number(freePlanets uint32) int {
return int(math.Ceil(float64(freePlanets) * ps.Ratio))
}
func DefaultMapSetting() MapSetting {
return MapSetting{
Players: 25,
HWSize: 1000,
HWResources: 10,
HWMinDistance: 30,
DWCount: 2,
DWSize: 500,
DWResources: 10,
DWMinDistance: 5,
DWMaxDistance: 15,
GiantPlanets: PlanetSetting{
MinDistanceHW: 20,
MinSize: 1500,
MaxSize: 2500,
MinResource: 0.1,
MaxResource: 3,
Ratio: 0.06,
},
BigPlanets: PlanetSetting{
MinDistanceHW: 10,
MinSize: 1000,
MaxSize: 2000,
MinResource: 1,
MaxResource: 10,
Ratio: 0.18,
},
OthersMinDistance: defaultFactor, // min. is 1 pixel on the plotter
NormalPlanets: PlanetSetting{
MinDistanceHW: 0,
MinSize: 0,
MaxSize: 1000,
MinResource: 0.1,
MaxResource: 10,
Ratio: 0.5,
},
RichPlanets: PlanetSetting{
MinDistanceHW: 0,
MinSize: 0,
MaxSize: 500,
MinResource: 5,
MaxResource: 25,
Ratio: 0.18,
},
Asterioids: PlanetSetting{
MinDistanceHW: 0,
MinSize: 0,
MaxSize: 0,
MinResource: 0,
MaxResource: 0,
Ratio: 0.08,
},
}
}