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 scheduler exposes the next-tick computation Game Master uses
// to advance `runtime_records.next_generation_at` after a successful
// turn generation. It is a thin, stateless wrapper over
// `domain/schedule.Schedule.Next` with the force-next-turn skip rule
// baked in via the `skipNextTick` parameter.
//
// Two callers consume the wrapper today:
//
// - `service/turngeneration` recomputes the next tick after a
// successful (non-finished) generation;
// - `service/adminforce` (Stage 17) reuses the same instance so the
// skip rule lives in exactly one place.
//
// The package depends only on `domain/schedule` and stdlib `time`. It
// holds no clock and no logger; callers pass `after` explicitly.
package scheduler
import (
"errors"
"strings"
"time"
"galaxy/gamemaster/internal/domain/schedule"
)
// Service computes the next scheduler-driven turn-generation tick.
type Service struct{}
// New constructs a stateless Service value. Returning a pointer keeps
// the construction shape consistent with the other GM services even
// though Service has no dependencies.
func New() *Service {
return &Service{}
}
// ComputeNext parses turnSchedule and returns the next firing time
// strictly after `after`, applying the force-next-turn skip rule when
// skipNextTick is true.
//
// When skipNextTick is true the wrapper computes the immediate next
// cron step and then advances by one further step, so the inter-turn
// spacing is never shorter than one schedule interval. The returned
// `skipConsumed` flag reports whether the wrapper consumed the skip
// (true when skipNextTick was true).
//
// On parse error ComputeNext returns the zero time, false, and the
// error wrapped from `schedule.Parse`. The caller is responsible for
// mapping it to the orchestrator-level `invalid_request` code.
func (service *Service) ComputeNext(turnSchedule string, after time.Time, skipNextTick bool) (time.Time, bool, error) {
if service == nil {
return time.Time{}, false, errors.New("scheduler compute next: nil service")
}
parsed, err := schedule.Parse(strings.TrimSpace(turnSchedule))
if err != nil {
return time.Time{}, false, err
}
next, skipConsumed := parsed.Next(after, skipNextTick)
return next, skipConsumed, nil
}
@@ -0,0 +1,63 @@
package scheduler_test
import (
"testing"
"time"
"galaxy/gamemaster/internal/service/scheduler"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestComputeNextHappyPathWithoutSkip(t *testing.T) {
service := scheduler.New()
after := time.Date(2026, time.April, 30, 12, 0, 0, 0, time.UTC)
next, skipConsumed, err := service.ComputeNext("0 18 * * *", after, false)
require.NoError(t, err)
assert.False(t, skipConsumed)
expected := time.Date(2026, time.April, 30, 18, 0, 0, 0, time.UTC)
assert.Equal(t, expected, next)
assert.Equal(t, time.UTC, next.Location())
}
func TestComputeNextConsumesSkip(t *testing.T) {
service := scheduler.New()
after := time.Date(2026, time.April, 30, 12, 0, 0, 0, time.UTC)
next, skipConsumed, err := service.ComputeNext("0 18 * * *", after, true)
require.NoError(t, err)
assert.True(t, skipConsumed)
expected := time.Date(2026, time.May, 1, 18, 0, 0, 0, time.UTC)
assert.Equal(t, expected, next)
}
func TestComputeNextEveryQuarterHourSkip(t *testing.T) {
service := scheduler.New()
after := time.Date(2026, time.April, 30, 12, 0, 0, 0, time.UTC)
first, _, err := service.ComputeNext("*/15 * * * *", after, false)
require.NoError(t, err)
skipped, _, err := service.ComputeNext("*/15 * * * *", after, true)
require.NoError(t, err)
assert.Equal(t, first.Add(15*time.Minute), skipped, "skip advances by exactly one cron step")
}
func TestComputeNextRejectsInvalidCron(t *testing.T) {
service := scheduler.New()
_, _, err := service.ComputeNext("not-a-cron", time.Now().UTC(), false)
require.Error(t, err)
}
func TestComputeNextTrimsWhitespace(t *testing.T) {
service := scheduler.New()
after := time.Date(2026, time.April, 30, 12, 0, 0, 0, time.UTC)
next, _, err := service.ComputeNext(" 0 18 * * * ", after, false)
require.NoError(t, err)
expected := time.Date(2026, time.April, 30, 18, 0, 0, 0, time.UTC)
assert.Equal(t, expected, next)
}
func TestNilServiceRejected(t *testing.T) {
var service *scheduler.Service
_, _, err := service.ComputeNext("0 18 * * *", time.Now().UTC(), false)
require.Error(t, err)
}