feat: voting procedures

This commit is contained in:
Ilia Denisov
2026-01-30 12:18:32 +03:00
parent abf72c16b4
commit 824f6609ab
12 changed files with 581 additions and 18 deletions
@@ -94,3 +94,11 @@ func BombPlanet(p *game.Planet, power float64) {
func (c *Cache) ListProducingPlanets() iter.Seq[uint] { func (c *Cache) ListProducingPlanets() iter.Seq[uint] {
return c.listProducingPlanets() return c.listProducingPlanets()
} }
func (c *Cache) VotesByRace() map[int]float64 {
return c.votesByRace()
}
func VotingWinners(calc []*VoteGroup, gameVotes float64) []int {
return votingWinners(calc, gameVotes)
}
+2 -2
View File
@@ -14,7 +14,7 @@ import (
var ( var (
Race_0 = game.Race{ Race_0 = game.Race{
ID: Race_0_ID, ID: Race_0_ID,
Vote: Race_0_ID, VoteFor: Race_0_ID,
Name: "Race_0", Name: "Race_0",
Tech: map[game.Tech]float64{ Tech: map[game.Tech]float64{
game.TechDrive: 1.1, game.TechDrive: 1.1,
@@ -26,7 +26,7 @@ var (
} }
Race_1 = game.Race{ Race_1 = game.Race{
ID: Race_1_ID, ID: Race_1_ID,
Vote: Race_1_ID, VoteFor: Race_1_ID,
Name: "Race_1", Name: "Race_1",
Tech: map[game.Tech]float64{ Tech: map[game.Tech]float64{
game.TechDrive: 2.1, game.TechDrive: 2.1,
+1 -1
View File
@@ -60,7 +60,7 @@ func buildGameOnMap(races []string, m generator.Map) (*game.Game, error) {
g.Race[i] = game.Race{ g.Race[i] = game.Race{
ID: raceID, ID: raceID,
Name: races[i], Name: races[i],
Vote: raceID, VoteFor: raceID,
Tech: map[game.Tech]float64{ Tech: map[game.Tech]float64{
game.TechDrive: 1, game.TechDrive: 1,
game.TechWeapons: 1, game.TechWeapons: 1,
+5
View File
@@ -44,6 +44,9 @@ func MakeTurn(c *Controller, r Repo, g *game.Game) error {
// 15. Происходит отмена маршрутов, выходящих за зону полета кораблей. // 15. Происходит отмена маршрутов, выходящих за зону полета кораблей.
c.Cache.RemoveUnreachableRoutes() c.Cache.RemoveUnreachableRoutes()
// 16. Происходит голосование.
winners := c.Cache.TurnCalculateVotes()
/*** Last steps ***/ /*** Last steps ***/
// Store battles // Store battles
@@ -56,6 +59,7 @@ func MakeTurn(c *Controller, r Repo, g *game.Game) error {
} }
} }
} }
// Remove killed ship groups // Remove killed ship groups
c.Cache.DeleteKilledShipGroups() c.Cache.DeleteKilledShipGroups()
@@ -63,5 +67,6 @@ func MakeTurn(c *Controller, r Repo, g *game.Game) error {
// TODO: Store individual reports // TODO: Store individual reports
_ = winners
return nil return nil
} }
+2 -2
View File
@@ -87,13 +87,13 @@ func (c *Cache) GiveVotes(race, recipient string) error {
if err != nil { if err != nil {
return err return err
} }
c.g.Race[ri].Vote = c.g.Race[rec].ID c.g.Race[ri].VoteFor = c.g.Race[rec].ID
return nil return nil
} }
func (c *Cache) Voted(ri int) int { func (c *Cache) Voted(ri int) int {
c.validateRaceIndex(ri) c.validateRaceIndex(ri)
return c.RaceIndex(c.g.Race[ri].Vote) return c.RaceIndex(c.g.Race[ri].VoteFor)
} }
func (c *Cache) UpdateRelation(ri, other int, rel game.Relation) (err error) { func (c *Cache) UpdateRelation(ri, other int, rel game.Relation) (err error) {
+1
View File
@@ -118,6 +118,7 @@ func (c *Cache) DeleteKilledShipGroups() {
c.unsafeDeleteShipGroup(i) c.unsafeDeleteShipGroup(i)
} }
} }
// TODO: delete empty fleets
} }
func (c *Controller) JoinEqualGroups(raceName string) error { func (c *Controller) JoinEqualGroups(raceName string) error {
+214
View File
@@ -0,0 +1,214 @@
package controller
import (
"cmp"
"fmt"
"maps"
"math/big"
"slices"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/internal/model/game"
)
type VoteGroup struct {
RaceIndex []int
Sum float64
}
type VoteNode struct {
ID int
Ally bool
Next *VoteNode
}
func (n VoteNode) String() string {
lh, rh := " ", "."
if n.Ally {
lh, rh = "{", "}"
}
return fmt.Sprintf("%s%d%s", lh, n.ID, rh)
}
func (c *Cache) TurnCalculateVotes() []int {
raceVotes := c.votesByRace()
calc := GroupVotes(raceVotes, VotingGraph(c.g.Race, c.RaceIndex))
c.g.Votes = 0
for ri, votes := range raceVotes {
c.g.Race[ri].Votes = votes
c.g.Votes += votes
}
return votingWinners(calc, c.g.Votes)
}
func VotingGraph(races []game.Race, raceIndex func(uuid.UUID) int) []*VoteNode {
nodes := make([]*VoteNode, len(races))
for ri := range races {
// TODO: filter inactive (RIP) races
r := &races[ri]
if nodes[ri] == nil {
nodes[ri] = &VoteNode{
ID: ri,
}
}
if r.VoteFor != r.ID {
vid := raceIndex(r.VoteFor)
if nodes[vid] == nil {
nodes[vid] = &VoteNode{
ID: vid,
}
}
nodes[ri].Next = nodes[vid]
}
}
return nodes
}
func (c *Cache) votesByRace() map[int]float64 {
result := make(map[int]float64)
for i := range c.g.Map.Planet {
p := &c.g.Map.Planet[i]
if p.Owner == uuid.Nil {
continue
}
ri := c.RaceIndex(p.Owner)
planetVotes := p.Votes()
result[ri] += planetVotes
}
return result
}
func GroupVotes(raceVotes map[int]float64, nodes []*VoteNode) []*VoteGroup {
votes := maps.Clone(raceVotes)
result := make([]*VoteGroup, 0)
chains := VotingChains(nodes)
chainingRaces := make(map[int]bool)
for i := range chains {
chain := chains[i]
if len(chain) == 0 {
panic("voters chain is empty")
}
vg := &VoteGroup{}
for j := range chain {
node := &chain[j]
if node.Ally || j == len(chain)-1 {
vg.RaceIndex = append(vg.RaceIndex, node.ID)
}
vg.Sum += votes[node.ID]
votes[node.ID] = 0
chainingRaces[node.ID] = true
}
// find a non-ally group (single race) which already have its votes and merge with a new VoteGroup instead of adding to result
if i := slices.IndexFunc(result, func(v *VoteGroup) bool { return len(v.RaceIndex) == 1 && v.RaceIndex[0] == vg.RaceIndex[0] }); i >= 0 && len(vg.RaceIndex) == 1 {
result[i].Sum += vg.Sum
} else {
result = append(result, vg)
}
}
for ri, votes := range votes {
if _, ok := chainingRaces[ri]; !ok && votes > 0 {
result = append(result, &VoteGroup{RaceIndex: []int{ri}, Sum: votes})
}
}
return result
}
func VotingChains(nodes []*VoteNode) [][]VoteNode {
visited := make(map[int]bool)
result := make([][]VoteNode, 0)
for i := range nodes {
n := nodes[i]
if v, ok := visited[n.ID]; (ok && v) || n.Next == nil {
continue
}
slow, fast := n, n
cycled := false
var cycleBound *VoteNode
for slow != nil && fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
if slow == fast {
slow = n
for slow != fast {
slow = slow.Next
fast = fast.Next
}
cycled = true
cycleBound = slow
break
}
}
var current *VoteNode
if cycled && !visited[slow.ID] {
result = append(result, make([]VoteNode, 0))
result[len(result)-1] = append(result[len(result)-1], VoteNode{ID: slow.ID, Ally: true})
visited[slow.ID] = true
current = slow.Next
for current != slow {
visited[current.ID] = true
result[len(result)-1] = append(result[len(result)-1], VoteNode{ID: current.ID, Ally: true})
current = current.Next
}
if n == slow {
continue
}
}
current = n
var finish *VoteNode
if cycleBound != nil {
if cycleBound == current.Next {
finish = current
} else {
finish = cycleBound
}
} else {
finish = nil
}
if finish != current {
result = append(result, make([]VoteNode, 0))
}
for current != finish {
visited[current.ID] = current.ID != n.ID && current.Next != nil
result[len(result)-1] = append(result[len(result)-1], VoteNode{ID: current.ID, Ally: false})
current = current.Next
}
}
return result
}
func votingWinners(calc []*VoteGroup, sumVotes float64) []int {
slices.SortFunc(calc, func(a, b *VoteGroup) int { return cmp.Compare(b.Sum, a.Sum) })
topVoter := calc[0]
maxVotes := &big.Rat{}
maxVotes.SetFloat64(topVoter.Sum)
winVotes := &big.Rat{}
winVotes.SetFloat64(sumVotes)
winVotes = winVotes.Mul(winVotes, big.NewRat(2, 3))
if maxVotes.Cmp(winVotes) >= 0 {
return topVoter.RaceIndex
}
return nil
}
+301
View File
@@ -0,0 +1,301 @@
package controller_test
import (
"testing"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/internal/controller"
"github.com/iliadenisov/galaxy/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestVotesByRace(t *testing.T) {
c, _ := newCache()
c.MustPlanet(R0_Planet_0_num).Size = 450.
c.MustPlanet(R0_Planet_0_num).Population = 450.
c.MustPlanet(R1_Planet_1_num).Size = 900.
c.MustPlanet(R1_Planet_1_num).Population = 900.
c.MustPlanet(R0_Planet_2_num).Size = 330.
c.MustPlanet(R0_Planet_2_num).Population = 330.
vbr := c.VotesByRace()
assert.Len(t, vbr, 2)
assert.Contains(t, vbr, Race_0_idx)
assert.Equal(t, 0.78, vbr[Race_0_idx])
assert.Contains(t, vbr, Race_1_idx)
assert.Equal(t, 0.9, vbr[Race_1_idx])
// TODO: add races without planets / dead races
}
func prepareRaces() ([]game.Race, func(u uuid.UUID) int) {
races := make([]game.Race, 20)
raceIndex := make(map[uuid.UUID]int)
for i := range len(races) {
races[i].ID = uuid.New()
races[i].VoteFor = races[i].ID
raceIndex[races[i].ID] = i
}
// 0 -> 1 -> 2 -> 3
// ^ |
// 5 -> 6 -> 4 <--'
races[0].VoteFor = races[1].ID
races[1].VoteFor = races[2].ID
races[2].VoteFor = races[3].ID
races[3].VoteFor = races[4].ID
races[4].VoteFor = races[2].ID
races[5].VoteFor = races[6].ID
races[6].VoteFor = races[4].ID
// 7 -> 10 -> 11
// ^
// 8 -> 9
races[7].VoteFor = races[10].ID
races[10].VoteFor = races[11].ID
races[8].VoteFor = races[9].ID
races[9].VoteFor = races[10].ID
// 12 -> 13
// 13 -> 12
races[12].VoteFor = races[13].ID
races[13].VoteFor = races[12].ID
// 14 -> 15 -> 16
// ^ |
// 17 <--'
races[14].VoteFor = races[15].ID
races[15].VoteFor = races[16].ID
races[16].VoteFor = races[17].ID
races[17].VoteFor = races[15].ID
// 19 -> 13
races[19].VoteFor = races[13].ID
return races, func(u uuid.UUID) int { return raceIndex[u] }
}
func TestVotingGraph(t *testing.T) {
races, raceIndex := prepareRaces()
voteNodes := controller.VotingGraph(races, raceIndex)
assert.Len(t, voteNodes, len(races))
for i := range voteNodes {
n := voteNodes[i]
switch i {
case 0:
assert.Equal(t, voteNodes[1], n.Next)
case 1:
assert.Equal(t, voteNodes[2], n.Next)
case 2:
assert.Equal(t, voteNodes[3], n.Next)
case 3:
assert.Equal(t, voteNodes[4], n.Next)
case 4:
assert.Equal(t, voteNodes[2], n.Next)
case 5:
assert.Equal(t, voteNodes[6], n.Next)
case 6:
assert.Equal(t, voteNodes[4], n.Next)
case 7:
assert.Equal(t, voteNodes[10], n.Next)
case 8:
assert.Equal(t, voteNodes[9], n.Next)
case 9:
assert.Equal(t, voteNodes[10], n.Next)
case 10:
assert.Equal(t, voteNodes[11], n.Next)
case 11:
assert.Nil(t, n.Next)
case 12:
assert.Equal(t, voteNodes[13], n.Next)
case 13:
assert.Equal(t, voteNodes[12], n.Next)
case 14:
assert.Equal(t, voteNodes[15], n.Next)
case 15:
assert.Equal(t, voteNodes[16], n.Next)
case 16:
assert.Equal(t, voteNodes[17], n.Next)
case 17:
assert.Equal(t, voteNodes[15], n.Next)
case 18:
assert.Nil(t, n.Next)
case 19:
assert.Equal(t, voteNodes[13], n.Next)
}
}
}
func TestVotingChains(t *testing.T) {
races, raceIndex := prepareRaces()
nodes := controller.VotingGraph(races, raceIndex)
vc := controller.VotingChains(nodes)
assert.Len(t, vc, 7)
for i := range vc {
n := vc[i]
switch i {
case 0:
assert.Len(t, n, 3)
assert.Equal(t, 2, n[0].ID)
assert.Equal(t, 3, n[1].ID)
assert.Equal(t, 4, n[2].ID)
assert.True(t, n[0].Ally)
assert.True(t, n[1].Ally)
assert.True(t, n[2].Ally)
case 1:
assert.Len(t, n, 2)
assert.Equal(t, 0, n[0].ID)
assert.Equal(t, 1, n[1].ID)
assert.False(t, n[0].Ally)
assert.False(t, n[1].Ally)
case 2:
assert.Len(t, n, 2)
assert.Equal(t, 5, n[0].ID)
assert.Equal(t, 6, n[1].ID)
assert.False(t, n[0].Ally)
assert.False(t, n[1].Ally)
case 3:
assert.Len(t, n, 3)
assert.Equal(t, 7, n[0].ID)
assert.Equal(t, 10, n[1].ID)
assert.Equal(t, 11, n[2].ID)
assert.False(t, n[0].Ally)
assert.False(t, n[1].Ally)
assert.False(t, n[2].Ally)
case 4:
assert.Len(t, n, 4)
assert.Equal(t, 8, n[0].ID)
assert.Equal(t, 9, n[1].ID)
assert.Equal(t, 10, n[2].ID)
assert.Equal(t, 11, n[3].ID)
assert.False(t, n[0].Ally)
assert.False(t, n[1].Ally)
assert.False(t, n[2].Ally)
assert.False(t, n[3].Ally)
case 5:
assert.Len(t, n, 2)
assert.Equal(t, 12, n[0].ID)
assert.Equal(t, 13, n[1].ID)
assert.True(t, n[0].Ally)
assert.True(t, n[1].Ally)
case 6:
assert.Len(t, n, 3)
assert.Equal(t, 15, n[0].ID)
assert.Equal(t, 16, n[1].ID)
assert.Equal(t, 17, n[2].ID)
assert.True(t, n[0].Ally)
assert.True(t, n[1].Ally)
assert.True(t, n[2].Ally)
}
}
}
func TestGroupVotes(t *testing.T) {
races, raceIndex := prepareRaces()
raceVotes := make(map[int]float64)
// [1] = 0.24
raceVotes[0] = 0.11
raceVotes[1] = 0.13
// [2,3,4] = 0.69
raceVotes[2] = 0.22
raceVotes[3] = 0.23
raceVotes[4] = 0.24
// [6] = 0.71
raceVotes[5] = 0.35
raceVotes[6] = 0.36
// [11] = 0.843
raceVotes[7] = 0.41
raceVotes[9] = 0.42
raceVotes[10] = 0.013
// [12,13] = 0.52
raceVotes[12] = 0.52
raceVotes[13] = 0.
// [14] = 1.04
raceVotes[14] = 1.04
// [15,16,17] = 2.49
raceVotes[15] = 1.15
raceVotes[16] = 0.16
raceVotes[17] = 1.18
// [18] = 3.18
raceVotes[18] = 3.18
// [19] = 0.019
raceVotes[19] = 0.019
calc := controller.GroupVotes(raceVotes, controller.VotingGraph(races, raceIndex))
assert.Len(t, calc, 9)
for i := range calc {
vg := calc[i]
switch i {
case 0:
assert.ElementsMatch(t, []int{2, 3, 4}, vg.RaceIndex)
assert.Equal(t, 0.69, vg.Sum)
case 4:
assert.ElementsMatch(t, []int{12, 13}, vg.RaceIndex)
assert.Equal(t, 0.52, vg.Sum)
case 5:
assert.ElementsMatch(t, []int{15, 16, 17}, vg.RaceIndex)
assert.InDelta(t, 2.49, vg.Sum, 0.001)
default:
assert.Len(t, vg.RaceIndex, 1)
switch ri := vg.RaceIndex[0]; ri {
case 1:
assert.Equal(t, 0.24, vg.Sum)
case 6:
assert.Equal(t, 0.71, vg.Sum)
case 11:
assert.Equal(t, 0.843, vg.Sum)
case 14:
assert.Equal(t, 1.04, vg.Sum)
case 18:
assert.Equal(t, 3.18, vg.Sum)
case 19:
assert.Equal(t, 0.019, vg.Sum)
default:
assert.Failf(t, "unexpected group", "id=%v sum=%f", vg.RaceIndex, vg.Sum)
}
}
}
}
func TestVotingWinners(t *testing.T) {
gameVotes := 100.0
var vg []*controller.VoteGroup
var winners []int
vg = []*controller.VoteGroup{
{Sum: 4.0, RaceIndex: []int{0}},
{Sum: 66.65, RaceIndex: []int{1, 2}},
{Sum: 5.0, RaceIndex: []int{3}},
{Sum: 25.0, RaceIndex: []int{4, 5, 6}},
}
winners = controller.VotingWinners(vg, gameVotes)
assert.Len(t, winners, 0)
vg = []*controller.VoteGroup{
{Sum: 4.0, RaceIndex: []int{0}},
{Sum: 66.666666666666666, RaceIndex: []int{1, 2}},
{Sum: 5.0, RaceIndex: []int{3}},
{Sum: 22.0, RaceIndex: []int{4, 5, 6}},
}
winners = controller.VotingWinners(vg, gameVotes)
assert.ElementsMatch(t, winners, []int{1, 2})
vg = []*controller.VoteGroup{
{Sum: 4.0, RaceIndex: []int{0}},
{Sum: 3.33, RaceIndex: []int{1, 2}},
{Sum: 66.67, RaceIndex: []int{3}},
{Sum: 25.0, RaceIndex: []int{4, 5, 6}},
}
winners = controller.VotingWinners(vg, gameVotes)
assert.ElementsMatch(t, winners, []int{3})
}
+6 -5
View File
@@ -29,19 +29,20 @@ type Game struct {
Age uint `json:"turn"` // Game's turn number Age uint `json:"turn"` // Game's turn number
Map Map `json:"map"` Map Map `json:"map"`
Race []Race `json:"races"` Race []Race `json:"races"`
Votes float64 `json:"votes"`
ShipGroups []ShipGroup `json:"shipGroup,omitempty"` ShipGroups []ShipGroup `json:"shipGroup,omitempty"`
Fleets []Fleet `json:"fleet,omitempty"` Fleets []Fleet `json:"fleet,omitempty"`
} }
func (g Game) Votes(raceID uuid.UUID) float64 { // TODO: remove if not needed
// XXX: calculate [Race]Population once when loading Game from Storage? func (g Game) RaceVotes(raceID uuid.UUID) float64 {
var pop float64 var result float64
for i := range g.Map.Planet { for i := range g.Map.Planet {
if g.Map.Planet[i].Owner == raceID { if g.Map.Planet[i].Owner == raceID {
pop += g.Map.Planet[i].Population result += g.Map.Planet[i].Votes()
} }
} }
return pop / 1000. return result
} }
func (g Game) MarshalBinary() (data []byte, err error) { func (g Game) MarshalBinary() (data []byte, err error) {
+4
View File
@@ -41,6 +41,10 @@ type PlanetReportForeign struct {
PlanetReport PlanetReport
} }
func (p Planet) Votes() float64 {
return p.Population / 1000.
}
// Производственный потенциал // Производственный потенциал
func (p Planet) ProductionCapacity() float64 { func (p Planet) ProductionCapacity() float64 {
return PlanetProduction(p.Industry, p.Population) return PlanetProduction(p.Industry, p.Population)
+2 -1
View File
@@ -7,7 +7,8 @@ type Race struct {
Name string `json:"name"` Name string `json:"name"`
Extinct bool `json:"extinct"` Extinct bool `json:"extinct"`
Vote uuid.UUID `json:"vote"` Votes float64 `json:"votes"`
VoteFor uuid.UUID `json:"voteFor"`
Relations []RaceRelation `json:"relations"` Relations []RaceRelation `json:"relations"`
Tech TechSet `json:"tech"` Tech TechSet `json:"tech"`
+29 -1
View File
@@ -1,6 +1,8 @@
package router package router
import ( import (
"fmt"
"net/http"
"os" "os"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -10,6 +12,10 @@ import (
"github.com/iliadenisov/galaxy/internal/router/handler" "github.com/iliadenisov/galaxy/internal/router/handler"
) )
const (
ISO8601 = "2006-01-02 15:04:05.0 -07:00"
)
func initConfig() func(*controller.Param) { func initConfig() func(*controller.Param) {
return func(p *controller.Param) { return func(p *controller.Param) {
// TODO: initialize base controller settings // TODO: initialize base controller settings
@@ -37,7 +43,12 @@ func NewRouterExecutor(executor handler.CommandExecutor) Router {
func setupRouter(config controller.Config, executor handler.CommandExecutor) *gin.Engine { func setupRouter(config controller.Config, executor handler.CommandExecutor) *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
r.Use(gin.Recovery())
// Logger middleware will write the logs to gin.DefaultWriter even if you set with GIN_MODE=release.
r.Use(gin.LoggerWithFormatter(logFormatter))
// Recovery middleware recovers from any panics and writes a 500 if there was one.
r.Use(gin.CustomRecovery(recoveryHandler))
if v, ok := binding.Validator.Engine().(*validator.Validate); ok { if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
v.RegisterValidation("notblank", notBlankStringValidator) v.RegisterValidation("notblank", notBlankStringValidator)
@@ -51,3 +62,20 @@ func setupRouter(config controller.Config, executor handler.CommandExecutor) *gi
return r return r
} }
func logFormatter(param gin.LogFormatterParams) string {
return fmt.Sprintf("[%s] \"%s %s %s %d %s\"\n",
param.TimeStamp.Format(ISO8601),
param.Method,
param.Path,
param.Request.Proto,
param.StatusCode,
param.Latency,
)
}
func recoveryHandler(c *gin.Context, recovered any) {
if err, ok := recovered.(string); ok {
fmt.Fprintf(os.Stderr, "recovered: %s", err)
}
c.AbortWithStatus(http.StatusInternalServerError)
}