feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
+47
View File
@@ -0,0 +1,47 @@
// Package cronutil provides a thin wrapper over github.com/robfig/cron/v3
// for parsing the five-field cron expressions used by Galaxy services to
// describe periodic schedules such as turn_schedule. It exposes only the
// parser and a Next computation; no scheduler runtime, no logging, and no
// concurrency primitives.
package cronutil
import (
"fmt"
"time"
"github.com/robfig/cron/v3"
)
// fiveFieldParser parses standard five-field cron expressions
// (minute, hour, day-of-month, month, day-of-week). The grammar matches
// what Galaxy services accept for turn_schedule and is the only grammar
// supported by this package; six-field expressions with a seconds field
// are rejected.
var fiveFieldParser = cron.NewParser(
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow,
)
// Schedule holds a parsed five-field cron expression and computes the
// next firing time after a given moment. The zero value is not usable;
// callers obtain a Schedule from Parse.
type Schedule struct {
inner cron.Schedule
}
// Parse parses expr as a five-field cron expression and returns the
// resulting Schedule. Parse returns an error if expr is empty, contains
// a seconds field, or is otherwise rejected by the underlying parser.
func Parse(expr string) (Schedule, error) {
inner, err := fiveFieldParser.Parse(expr)
if err != nil {
return Schedule{}, fmt.Errorf("cronutil: parse %q: %w", expr, err)
}
return Schedule{inner: inner}, nil
}
// Next returns the next firing time strictly after after. The returned
// time is always in UTC; callers passing UTC values therefore get UTC
// values back. Calling Next on a zero-value Schedule panics.
func (s Schedule) Next(after time.Time) time.Time {
return s.inner.Next(after.UTC()).UTC()
}
+101
View File
@@ -0,0 +1,101 @@
package cronutil_test
import (
"testing"
"time"
"galaxy/cronutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseAcceptsFiveFieldExpressions(t *testing.T) {
t.Parallel()
accepted := []string{
"0 18 * * *",
"*/15 * * * *",
"0 */6 * * *",
"0 */4 * * *",
"0 0 * * *",
"0 0 1 1 *",
"30 9 * * MON-FRI",
}
for _, expr := range accepted {
t.Run(expr, func(t *testing.T) {
t.Parallel()
_, err := cronutil.Parse(expr)
require.NoError(t, err)
})
}
}
func TestParseRejectsInvalidExpressions(t *testing.T) {
t.Parallel()
cases := []struct {
name string
expr string
}{
{name: "empty", expr: ""},
{name: "whitespace only", expr: " "},
{name: "garbage", expr: "not a cron"},
{name: "six field with seconds", expr: "*/30 * * * * *"},
{name: "too few fields", expr: "0 18 *"},
{name: "minute out of range", expr: "60 * * * *"},
{name: "hour out of range", expr: "* 24 * * *"},
{name: "month out of range", expr: "* * * 13 *"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := cronutil.Parse(tc.expr)
require.Error(t, err)
})
}
}
func TestScheduleNextComputesExpectedFire(t *testing.T) {
t.Parallel()
schedule, err := cronutil.Parse("0 18 * * *")
require.NoError(t, err)
after := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
next := schedule.Next(after)
assert.Equal(t, time.Date(2026, 1, 1, 18, 0, 0, 0, time.UTC), next)
assert.Equal(t, time.UTC, next.Location())
}
func TestScheduleNextStepsToNextFifteenMinuteSlot(t *testing.T) {
t.Parallel()
schedule, err := cronutil.Parse("*/15 * * * *")
require.NoError(t, err)
after := time.Date(2026, 6, 30, 12, 7, 33, 0, time.UTC)
next := schedule.Next(after)
assert.Equal(t, time.Date(2026, 6, 30, 12, 15, 0, 0, time.UTC), next)
assert.Equal(t, time.UTC, next.Location())
}
func TestScheduleNextReturnsUTCForNonUTCInput(t *testing.T) {
t.Parallel()
schedule, err := cronutil.Parse("0 18 * * *")
require.NoError(t, err)
moscow, err := time.LoadLocation("Europe/Moscow")
require.NoError(t, err)
after := time.Date(2026, 1, 1, 12, 0, 0, 0, moscow) // 09:00 UTC
next := schedule.Next(after)
assert.Equal(t, time.Date(2026, 1, 1, 18, 0, 0, 0, time.UTC), next)
assert.Equal(t, time.UTC, next.Location())
}
+17
View File
@@ -0,0 +1,17 @@
module galaxy/cronutil
go 1.26.1
require (
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.11.1
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+13
View File
@@ -0,0 +1,13 @@
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+7
View File
@@ -0,0 +1,7 @@
package rest
// BanishRequest is the request body of POST /api/v1/admin/race/banish.
// RaceName must identify an existing race in the engine roster.
type BanishRequest struct {
RaceName string `json:"race_name" binding:"required,notblank"`
}
+4
View File
@@ -11,6 +11,10 @@ type StateResponse struct {
Stage uint `json:"stage"`
// List of Game's players
Players []PlayerState `json:"player"`
// Finished is true on the turn-generation response that ends the
// game; otherwise false. Game Master uses this as the sole signal to
// run the platform finish flow.
Finished bool `json:"finished"`
}
type PlayerState struct {