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
@@ -0,0 +1,59 @@
// Package schedule wraps `pkg/cronutil` with the force-next-turn skip
// rule used by Game Master's scheduler.
//
// The wrapper is pure: callers pass the current `skip_next_tick` flag
// and the wrapper returns both the next firing time and a boolean that
// reports whether the flag was consumed. The runtime-record store is
// responsible for persisting the cleared flag; this package never
// touches it.
//
// `gamemaster/README.md §Force-next-turn` describes the rule:
//
// If `skip_next_tick=true`, advance by one extra cron step and clear
// the flag.
package schedule
import (
"time"
"galaxy/cronutil"
)
// Schedule wraps `cronutil.Schedule` with the GM-specific
// skip-next-tick semantics. The zero value is not usable; callers
// obtain a Schedule from Parse.
type Schedule struct {
inner cronutil.Schedule
}
// Parse parses expr as a five-field cron expression and returns the
// resulting Schedule. Parse returns an error if expr is rejected by the
// underlying cronutil parser.
func Parse(expr string) (Schedule, error) {
inner, err := cronutil.Parse(expr)
if err != nil {
return Schedule{}, err
}
return Schedule{inner: inner}, nil
}
// Next returns the next firing time strictly after `after`, honouring
// the skip flag.
//
// When `skip` is false, Next returns `cronutil.Schedule.Next(after)`
// and reports `skipConsumed=false`.
//
// When `skip` is true, Next computes the cron step immediately after
// `after`, then advances by one further cron step and returns that
// time with `skipConsumed=true`. The caller is responsible for
// persisting the cleared flag after observing `skipConsumed`.
//
// All returned times are in UTC; cronutil.Schedule already enforces
// UTC normalisation on its inputs and outputs.
func (s Schedule) Next(after time.Time, skip bool) (time.Time, bool) {
first := s.inner.Next(after)
if !skip {
return first, false
}
return s.inner.Next(first), true
}
@@ -0,0 +1,67 @@
package schedule
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseRejectsBadExpr(t *testing.T) {
_, err := Parse("")
assert.Error(t, err)
_, err = Parse("0 0 31 2 *") // valid syntactically but never fires; cronutil accepts it
// cronutil only validates syntax; an impossible date is still parsed.
// We assert by separately rejecting clearly invalid syntax:
_, err = Parse("not-a-cron")
assert.Error(t, err)
_, err = Parse("0 18 * *") // four fields
assert.Error(t, err)
_, err = Parse("0 0 * * * *") // six fields
assert.Error(t, err)
}
func TestNextNoSkip(t *testing.T) {
// Fires every day at 18:00 UTC.
sched, err := Parse("0 18 * * *")
require.NoError(t, err)
after := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
got, skipped := sched.Next(after, false)
assert.False(t, skipped)
assert.Equal(t, time.Date(2026, 4, 27, 18, 0, 0, 0, time.UTC), got)
assert.Equal(t, time.UTC, got.Location())
}
func TestNextWithSkipAdvancesOneStep(t *testing.T) {
sched, err := Parse("0 18 * * *")
require.NoError(t, err)
after := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
got, skipped := sched.Next(after, true)
assert.True(t, skipped)
// First slot would be 2026-04-27 18:00 UTC; the skip rule advances
// to 2026-04-28 18:00 UTC.
assert.Equal(t, time.Date(2026, 4, 28, 18, 0, 0, 0, time.UTC), got)
}
func TestNextNormalisesNonUTCInput(t *testing.T) {
sched, err := Parse("*/15 * * * *")
require.NoError(t, err)
moscow := time.FixedZone("MSK", 3*60*60)
// 2026-04-27 15:30 MSK = 2026-04-27 12:30 UTC; next 15-minute slot
// in UTC is 12:45.
after := time.Date(2026, 4, 27, 15, 30, 0, 0, moscow)
got, skipped := sched.Next(after, false)
assert.False(t, skipped)
assert.Equal(t, time.Date(2026, 4, 27, 12, 45, 0, 0, time.UTC), got)
assert.Equal(t, time.UTC, got.Location())
}