From 824f6609abac6a0ae7d3e280597788a9e33bcdc1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 30 Jan 2026 12:18:32 +0300 Subject: [PATCH] feat: voting procedures --- internal/controller/controller_export_test.go | 8 + internal/controller/controller_test.go | 12 +- internal/controller/generate_game.go | 6 +- internal/controller/generate_turn.go | 5 + internal/controller/race.go | 4 +- internal/controller/ship_group.go | 1 + internal/controller/vote.go | 214 +++++++++++++ internal/controller/vote_test.go | 301 ++++++++++++++++++ internal/model/game/game.go | 11 +- internal/model/game/planet.go | 4 + internal/model/game/race.go | 3 +- internal/router/router.go | 30 +- 12 files changed, 581 insertions(+), 18 deletions(-) create mode 100644 internal/controller/vote.go create mode 100644 internal/controller/vote_test.go diff --git a/internal/controller/controller_export_test.go b/internal/controller/controller_export_test.go index d40d524..ee27124 100644 --- a/internal/controller/controller_export_test.go +++ b/internal/controller/controller_export_test.go @@ -94,3 +94,11 @@ func BombPlanet(p *game.Planet, power float64) { func (c *Cache) ListProducingPlanets() iter.Seq[uint] { return c.listProducingPlanets() } + +func (c *Cache) VotesByRace() map[int]float64 { + return c.votesByRace() +} + +func VotingWinners(calc []*VoteGroup, gameVotes float64) []int { + return votingWinners(calc, gameVotes) +} diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go index ebb4b0a..4e7eecb 100644 --- a/internal/controller/controller_test.go +++ b/internal/controller/controller_test.go @@ -13,9 +13,9 @@ import ( var ( Race_0 = game.Race{ - ID: Race_0_ID, - Vote: Race_0_ID, - Name: "Race_0", + ID: Race_0_ID, + VoteFor: Race_0_ID, + Name: "Race_0", Tech: map[game.Tech]float64{ game.TechDrive: 1.1, game.TechWeapons: 1.2, @@ -25,9 +25,9 @@ var ( Relations: []game.RaceRelation{{RaceID: Race_1_ID, Relation: game.RelationWar}}, } Race_1 = game.Race{ - ID: Race_1_ID, - Vote: Race_1_ID, - Name: "Race_1", + ID: Race_1_ID, + VoteFor: Race_1_ID, + Name: "Race_1", Tech: map[game.Tech]float64{ game.TechDrive: 2.1, game.TechWeapons: 2.2, diff --git a/internal/controller/generate_game.go b/internal/controller/generate_game.go index 9dfb35c..44b8cfa 100644 --- a/internal/controller/generate_game.go +++ b/internal/controller/generate_game.go @@ -58,9 +58,9 @@ func buildGameOnMap(races []string, m generator.Map) (*game.Game, error) { } relations[i] = game.RaceRelation{RaceID: raceID, Relation: game.RelationWar} g.Race[i] = game.Race{ - ID: raceID, - Name: races[i], - Vote: raceID, + ID: raceID, + Name: races[i], + VoteFor: raceID, Tech: map[game.Tech]float64{ game.TechDrive: 1, game.TechWeapons: 1, diff --git a/internal/controller/generate_turn.go b/internal/controller/generate_turn.go index 7430ded..9619f43 100644 --- a/internal/controller/generate_turn.go +++ b/internal/controller/generate_turn.go @@ -44,6 +44,9 @@ func MakeTurn(c *Controller, r Repo, g *game.Game) error { // 15. Происходит отмена маршрутов, выходящих за зону полета кораблей. c.Cache.RemoveUnreachableRoutes() + // 16. Происходит голосование. + winners := c.Cache.TurnCalculateVotes() + /*** Last steps ***/ // Store battles @@ -56,6 +59,7 @@ func MakeTurn(c *Controller, r Repo, g *game.Game) error { } } } + // Remove killed ship groups c.Cache.DeleteKilledShipGroups() @@ -63,5 +67,6 @@ func MakeTurn(c *Controller, r Repo, g *game.Game) error { // TODO: Store individual reports + _ = winners return nil } diff --git a/internal/controller/race.go b/internal/controller/race.go index cdb8ce8..371fb11 100644 --- a/internal/controller/race.go +++ b/internal/controller/race.go @@ -87,13 +87,13 @@ func (c *Cache) GiveVotes(race, recipient string) error { if err != nil { return err } - c.g.Race[ri].Vote = c.g.Race[rec].ID + c.g.Race[ri].VoteFor = c.g.Race[rec].ID return nil } func (c *Cache) Voted(ri int) int { 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) { diff --git a/internal/controller/ship_group.go b/internal/controller/ship_group.go index 44446ef..ebafd55 100644 --- a/internal/controller/ship_group.go +++ b/internal/controller/ship_group.go @@ -118,6 +118,7 @@ func (c *Cache) DeleteKilledShipGroups() { c.unsafeDeleteShipGroup(i) } } + // TODO: delete empty fleets } func (c *Controller) JoinEqualGroups(raceName string) error { diff --git a/internal/controller/vote.go b/internal/controller/vote.go new file mode 100644 index 0000000..e484701 --- /dev/null +++ b/internal/controller/vote.go @@ -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 +} diff --git a/internal/controller/vote_test.go b/internal/controller/vote_test.go new file mode 100644 index 0000000..e226284 --- /dev/null +++ b/internal/controller/vote_test.go @@ -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}) +} diff --git a/internal/model/game/game.go b/internal/model/game/game.go index fe062f0..6d5b594 100644 --- a/internal/model/game/game.go +++ b/internal/model/game/game.go @@ -29,19 +29,20 @@ type Game struct { Age uint `json:"turn"` // Game's turn number Map Map `json:"map"` Race []Race `json:"races"` + Votes float64 `json:"votes"` ShipGroups []ShipGroup `json:"shipGroup,omitempty"` Fleets []Fleet `json:"fleet,omitempty"` } -func (g Game) Votes(raceID uuid.UUID) float64 { - // XXX: calculate [Race]Population once when loading Game from Storage? - var pop float64 +// TODO: remove if not needed +func (g Game) RaceVotes(raceID uuid.UUID) float64 { + var result float64 for i := range g.Map.Planet { 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) { diff --git a/internal/model/game/planet.go b/internal/model/game/planet.go index 123c02a..11b0210 100644 --- a/internal/model/game/planet.go +++ b/internal/model/game/planet.go @@ -41,6 +41,10 @@ type PlanetReportForeign struct { PlanetReport } +func (p Planet) Votes() float64 { + return p.Population / 1000. +} + // Производственный потенциал func (p Planet) ProductionCapacity() float64 { return PlanetProduction(p.Industry, p.Population) diff --git a/internal/model/game/race.go b/internal/model/game/race.go index f5dd1e2..f68423f 100644 --- a/internal/model/game/race.go +++ b/internal/model/game/race.go @@ -7,7 +7,8 @@ type Race struct { Name string `json:"name"` Extinct bool `json:"extinct"` - Vote uuid.UUID `json:"vote"` + Votes float64 `json:"votes"` + VoteFor uuid.UUID `json:"voteFor"` Relations []RaceRelation `json:"relations"` Tech TechSet `json:"tech"` diff --git a/internal/router/router.go b/internal/router/router.go index 01cf107..1b04ecb 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1,6 +1,8 @@ package router import ( + "fmt" + "net/http" "os" "github.com/gin-gonic/gin" @@ -10,6 +12,10 @@ import ( "github.com/iliadenisov/galaxy/internal/router/handler" ) +const ( + ISO8601 = "2006-01-02 15:04:05.0 -07:00" +) + func initConfig() func(*controller.Param) { return func(p *controller.Param) { // 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 { gin.SetMode(gin.ReleaseMode) 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 { v.RegisterValidation("notblank", notBlankStringValidator) @@ -51,3 +62,20 @@ func setupRouter(config controller.Config, executor handler.CommandExecutor) *gi 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) +}