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