feat: gamemaster
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user