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,611 @@
package internalhttp
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"galaxy/gamemaster/internal/api/internalhttp/handlers"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/operation"
domainruntime "galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/service/adminbanish"
"galaxy/gamemaster/internal/service/adminforce"
"galaxy/gamemaster/internal/service/adminpatch"
"galaxy/gamemaster/internal/service/adminstop"
"galaxy/gamemaster/internal/service/commandexecute"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
"galaxy/gamemaster/internal/service/livenessreply"
"galaxy/gamemaster/internal/service/orderput"
"galaxy/gamemaster/internal/service/registerruntime"
"galaxy/gamemaster/internal/service/reportget"
"galaxy/gamemaster/internal/service/turngeneration"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers"
"github.com/getkin/kin-openapi/routers/legacy"
"github.com/stretchr/testify/require"
)
// TestInternalRESTConformance loads the OpenAPI specification, drives
// every internal REST operation against the live listener backed by
// stub services, and validates each request and response body
// against the spec via `openapi3filter.ValidateRequest` and
// `openapi3filter.ValidateResponse`. Failure-path response shapes
// are intentionally out of scope here; per-handler tests under
// `handlers/<op>_test.go` cover the failure branches.
func TestInternalRESTConformance(t *testing.T) {
t.Parallel()
doc := loadConformanceSpec(t)
router, err := legacy.NewRouter(doc)
require.NoError(t, err)
deps := newConformanceDeps()
server, err := NewServer(newConformanceConfig(), Dependencies{
Logger: nil,
Telemetry: nil,
Readiness: nil,
RuntimeRecords: deps.runtimeRecords,
RegisterRuntime: deps.registerRuntime,
ForceNextTurn: deps.forceNextTurn,
StopRuntime: deps.stopRuntime,
PatchRuntime: deps.patchRuntime,
BanishRace: deps.banishRace,
InvalidateMemberships: deps.membership,
GameLiveness: deps.liveness,
EngineVersions: deps.engineVersions,
CommandExecute: deps.commandExecute,
PutOrders: deps.putOrders,
GetReport: deps.getReport,
})
require.NoError(t, err)
cases := []conformanceCase{
{name: "internalHealthz", method: http.MethodGet, path: "/healthz"},
{name: "internalReadyz", method: http.MethodGet, path: "/readyz"},
{
name: "internalRegisterRuntime",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/register-runtime",
contentType: "application/json",
body: `{
"engine_endpoint": "http://galaxy-game-` + conformanceGameID + `:8080",
"members": [{"user_id": "user-1", "race_name": "Aelinari"}],
"target_engine_version": "1.2.3",
"turn_schedule": "0 18 * * *"
}`,
},
{
name: "internalBanishRace",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/race/Aelinari/banish",
expectedStatus: http.StatusNoContent,
},
{
name: "internalInvalidateMemberships",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/memberships/invalidate",
expectedStatus: http.StatusNoContent,
},
{
name: "internalGameLiveness",
method: http.MethodGet,
path: "/api/v1/internal/games/" + conformanceGameID + "/liveness",
},
{name: "internalListRuntimes", method: http.MethodGet, path: "/api/v1/internal/runtimes"},
{
name: "internalGetRuntime",
method: http.MethodGet,
path: "/api/v1/internal/runtimes/" + conformanceGameID,
},
{
name: "internalForceNextTurn",
method: http.MethodPost,
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/force-next-turn",
},
{
name: "internalStopRuntime",
method: http.MethodPost,
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/stop",
contentType: "application/json",
body: `{"reason":"admin_request"}`,
},
{
name: "internalPatchRuntime",
method: http.MethodPost,
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/patch",
contentType: "application/json",
body: `{"version":"1.2.4"}`,
},
{name: "internalListEngineVersions", method: http.MethodGet, path: "/api/v1/internal/engine-versions"},
{
name: "internalCreateEngineVersion",
method: http.MethodPost,
path: "/api/v1/internal/engine-versions",
contentType: "application/json",
body: `{"version":"1.2.5","image_ref":"galaxy/game:1.2.5"}`,
expectedStatus: http.StatusCreated,
},
{
name: "internalGetEngineVersion",
method: http.MethodGet,
path: "/api/v1/internal/engine-versions/1.2.3",
},
{
name: "internalUpdateEngineVersion",
method: http.MethodPatch,
path: "/api/v1/internal/engine-versions/1.2.3",
contentType: "application/json",
body: `{"image_ref":"galaxy/game:1.2.3-patch"}`,
},
{
name: "internalDeprecateEngineVersion",
method: http.MethodDelete,
path: "/api/v1/internal/engine-versions/1.2.3",
expectedStatus: http.StatusNoContent,
},
{
name: "internalResolveEngineVersionImageRef",
method: http.MethodGet,
path: "/api/v1/internal/engine-versions/1.2.3/image-ref",
},
{
name: "internalExecuteCommands",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/commands",
contentType: "application/json",
body: `{"commands":[{"name":"build","args":{}}]}`,
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
},
{
name: "internalPutOrders",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/orders",
contentType: "application/json",
body: `{"commands":[{"name":"move","args":{}}]}`,
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
},
{
name: "internalGetReport",
method: http.MethodGet,
path: "/api/v1/internal/games/" + conformanceGameID + "/reports/0",
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
runConformanceCase(t, server.handler, router, tc)
})
}
}
const (
conformanceGameID = "game-conformance"
conformanceUserID = "user-conformance"
conformanceServerURL = "http://localhost:8097"
userIDHeader = "X-User-ID"
)
type conformanceCase struct {
name string
method string
path string
contentType string
body string
expectedStatus int
extraHeaders map[string]string
}
func runConformanceCase(t *testing.T, handler http.Handler, router routers.Router, tc conformanceCase) {
t.Helper()
expectedStatus := tc.expectedStatus
if expectedStatus == 0 {
expectedStatus = http.StatusOK
}
var bodyReader io.Reader
if tc.body != "" {
bodyReader = strings.NewReader(tc.body)
}
request := httptest.NewRequest(tc.method, tc.path, bodyReader)
if tc.contentType != "" {
request.Header.Set("Content-Type", tc.contentType)
}
request.Header.Set("X-Galaxy-Caller", "admin")
for key, value := range tc.extraHeaders {
request.Header.Set(key, value)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
require.Equalf(t, expectedStatus, recorder.Code,
"operation %s returned %d: %s", tc.name, recorder.Code, recorder.Body.String())
validationURL := conformanceServerURL + tc.path
validationRequest := httptest.NewRequest(tc.method, validationURL, bodyReaderFor(tc.body))
if tc.contentType != "" {
validationRequest.Header.Set("Content-Type", tc.contentType)
}
validationRequest.Header.Set("X-Galaxy-Caller", "admin")
for key, value := range tc.extraHeaders {
validationRequest.Header.Set(key, value)
}
route, pathParams, err := router.FindRoute(validationRequest)
require.NoError(t, err)
requestInput := &openapi3filter.RequestValidationInput{
Request: validationRequest,
PathParams: pathParams,
Route: route,
Options: &openapi3filter.Options{
IncludeResponseStatus: true,
},
}
require.NoError(t, openapi3filter.ValidateRequest(context.Background(), requestInput))
responseInput := &openapi3filter.ResponseValidationInput{
RequestValidationInput: requestInput,
Status: recorder.Code,
Header: recorder.Header(),
Options: &openapi3filter.Options{
IncludeResponseStatus: true,
},
}
responseInput.SetBodyBytes(recorder.Body.Bytes())
require.NoError(t, openapi3filter.ValidateResponse(context.Background(), responseInput))
}
func loadConformanceSpec(t *testing.T) *openapi3.T {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
require.True(t, ok)
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "api", "internal-openapi.yaml")
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile(specPath)
require.NoError(t, err)
require.NoError(t, doc.Validate(context.Background()))
return doc
}
func bodyReaderFor(raw string) io.Reader {
if raw == "" {
return http.NoBody
}
return bytes.NewBufferString(raw)
}
func newConformanceConfig() Config {
return Config{
Addr: ":0",
ReadHeaderTimeout: time.Second,
ReadTimeout: time.Second,
WriteTimeout: time.Second,
IdleTimeout: time.Second,
}
}
// conformanceDeps groups the stub collaborators handed to the listener.
type conformanceDeps struct {
runtimeRecords *conformanceRuntimeRecords
registerRuntime *conformanceRegister
forceNextTurn *conformanceForce
stopRuntime *conformanceStop
patchRuntime *conformancePatch
banishRace *conformanceBanish
membership *conformanceMembership
liveness *conformanceLiveness
engineVersions *conformanceEngineVersions
commandExecute *conformanceCommands
putOrders *conformanceOrders
getReport *conformanceReport
}
func newConformanceDeps() *conformanceDeps {
return &conformanceDeps{
runtimeRecords: newConformanceRuntimeRecords(),
registerRuntime: &conformanceRegister{},
forceNextTurn: &conformanceForce{},
stopRuntime: &conformanceStop{},
patchRuntime: &conformancePatch{},
banishRace: &conformanceBanish{},
membership: &conformanceMembership{},
liveness: &conformanceLiveness{},
engineVersions: newConformanceEngineVersions(),
commandExecute: &conformanceCommands{},
putOrders: &conformanceOrders{},
getReport: &conformanceReport{},
}
}
// conformanceRecord builds a canonical running runtime record used
// by every stub service.
func conformanceRuntimeRecord() domainruntime.RuntimeRecord {
moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
next := moment.Add(time.Minute)
started := moment
return domainruntime.RuntimeRecord{
GameID: conformanceGameID,
Status: domainruntime.StatusRunning,
EngineEndpoint: "http://galaxy-game-" + conformanceGameID + ":8080",
CurrentImageRef: "galaxy/game:1.2.3",
CurrentEngineVersion: "1.2.3",
TurnSchedule: "0 18 * * *",
CurrentTurn: 0,
NextGenerationAt: &next,
SkipNextTick: false,
EngineHealth: "healthy",
CreatedAt: moment,
UpdatedAt: moment,
StartedAt: &started,
}
}
func conformanceEngineVersionRecord(version string) engineversion.EngineVersion {
moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
return engineversion.EngineVersion{
Version: version,
ImageRef: "galaxy/game:" + version,
Options: nil,
Status: engineversion.StatusActive,
CreatedAt: moment,
UpdatedAt: moment,
}
}
// conformanceRuntimeRecords is an in-memory store seeded with the
// canonical record so the get/list endpoints have something to return.
type conformanceRuntimeRecords struct {
mu sync.Mutex
stored map[string]domainruntime.RuntimeRecord
}
func newConformanceRuntimeRecords() *conformanceRuntimeRecords {
return &conformanceRuntimeRecords{
stored: map[string]domainruntime.RuntimeRecord{
conformanceGameID: conformanceRuntimeRecord(),
},
}
}
func (s *conformanceRuntimeRecords) Get(_ context.Context, gameID string) (domainruntime.RuntimeRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
record, ok := s.stored[gameID]
if !ok {
return domainruntime.RuntimeRecord{}, domainruntime.ErrNotFound
}
return record, nil
}
func (s *conformanceRuntimeRecords) List(_ context.Context) ([]domainruntime.RuntimeRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
for _, record := range s.stored {
out = append(out, record)
}
return out, nil
}
func (s *conformanceRuntimeRecords) ListByStatus(_ context.Context, status domainruntime.Status) ([]domainruntime.RuntimeRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
for _, record := range s.stored {
if record.Status == status {
out = append(out, record)
}
}
return out, nil
}
type conformanceRegister struct{}
func (s *conformanceRegister) Handle(_ context.Context, _ registerruntime.Input) (registerruntime.Result, error) {
return registerruntime.Result{
Record: conformanceRuntimeRecord(),
Outcome: operation.OutcomeSuccess,
}, nil
}
type conformanceForce struct{}
func (s *conformanceForce) Handle(_ context.Context, _ adminforce.Input) (adminforce.Result, error) {
return adminforce.Result{
TurnGeneration: turngeneration.Result{Record: conformanceRuntimeRecord()},
SkipScheduled: true,
Outcome: operation.OutcomeSuccess,
}, nil
}
type conformanceStop struct{}
func (s *conformanceStop) Handle(_ context.Context, _ adminstop.Input) (adminstop.Result, error) {
rec := conformanceRuntimeRecord()
rec.Status = domainruntime.StatusStopped
stopped := rec.UpdatedAt.Add(time.Second)
rec.StoppedAt = &stopped
rec.UpdatedAt = stopped
return adminstop.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil
}
type conformancePatch struct{}
func (s *conformancePatch) Handle(_ context.Context, in adminpatch.Input) (adminpatch.Result, error) {
rec := conformanceRuntimeRecord()
if in.Version != "" {
rec.CurrentImageRef = "galaxy/game:" + in.Version
rec.CurrentEngineVersion = in.Version
}
return adminpatch.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil
}
type conformanceBanish struct{}
func (s *conformanceBanish) Handle(_ context.Context, _ adminbanish.Input) (adminbanish.Result, error) {
return adminbanish.Result{Outcome: operation.OutcomeSuccess}, nil
}
type conformanceMembership struct{}
func (m *conformanceMembership) Invalidate(string) {}
type conformanceLiveness struct{}
func (s *conformanceLiveness) Handle(_ context.Context, _ livenessreply.Input) (livenessreply.Result, error) {
return livenessreply.Result{
Ready: true,
Status: domainruntime.StatusRunning,
}, nil
}
type conformanceEngineVersions struct {
mu sync.Mutex
versions map[string]engineversion.EngineVersion
}
func newConformanceEngineVersions() *conformanceEngineVersions {
return &conformanceEngineVersions{
versions: map[string]engineversion.EngineVersion{
"1.2.3": conformanceEngineVersionRecord("1.2.3"),
},
}
}
func (s *conformanceEngineVersions) List(_ context.Context, _ *engineversion.Status) ([]engineversion.EngineVersion, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]engineversion.EngineVersion, 0, len(s.versions))
for _, version := range s.versions {
out = append(out, version)
}
return out, nil
}
func (s *conformanceEngineVersions) Get(_ context.Context, version string) (engineversion.EngineVersion, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.versions[version]
if !ok {
return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound
}
return v, nil
}
func (s *conformanceEngineVersions) ResolveImageRef(_ context.Context, version string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.versions[version]
if !ok {
return "", engineversionsvc.ErrNotFound
}
return v.ImageRef, nil
}
func (s *conformanceEngineVersions) Create(_ context.Context, in engineversionsvc.CreateInput) (engineversion.EngineVersion, error) {
rec := engineversion.EngineVersion{
Version: in.Version,
ImageRef: in.ImageRef,
Options: in.Options,
Status: engineversion.StatusActive,
CreatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC),
}
s.mu.Lock()
s.versions[in.Version] = rec
s.mu.Unlock()
return rec, nil
}
func (s *conformanceEngineVersions) Update(_ context.Context, in engineversionsvc.UpdateInput) (engineversion.EngineVersion, error) {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.versions[in.Version]
if !ok {
return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound
}
if in.ImageRef != nil {
rec.ImageRef = *in.ImageRef
}
if in.Status != nil {
rec.Status = *in.Status
}
rec.UpdatedAt = time.Date(2026, 4, 30, 13, 0, 0, 0, time.UTC)
s.versions[in.Version] = rec
return rec, nil
}
func (s *conformanceEngineVersions) Deprecate(_ context.Context, in engineversionsvc.DeprecateInput) error {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.versions[in.Version]
if !ok {
return engineversionsvc.ErrNotFound
}
rec.Status = engineversion.StatusDeprecated
rec.UpdatedAt = time.Date(2026, 4, 30, 14, 0, 0, 0, time.UTC)
s.versions[in.Version] = rec
return nil
}
type conformanceCommands struct{}
func (s *conformanceCommands) Handle(_ context.Context, _ commandexecute.Input) (commandexecute.Result, error) {
return commandexecute.Result{
Outcome: operation.OutcomeSuccess,
RawResponse: json.RawMessage(`{"results":[]}`),
}, nil
}
type conformanceOrders struct{}
func (s *conformanceOrders) Handle(_ context.Context, _ orderput.Input) (orderput.Result, error) {
return orderput.Result{
Outcome: operation.OutcomeSuccess,
RawResponse: json.RawMessage(`{"results":[]}`),
}, nil
}
type conformanceReport struct{}
func (s *conformanceReport) Handle(_ context.Context, _ reportget.Input) (reportget.Result, error) {
return reportget.Result{
Outcome: operation.OutcomeSuccess,
RawResponse: json.RawMessage(`{"player":"Aelinari","turn":0}`),
}, nil
}
// Compile-time guards that the stubs satisfy the handler-level
// service interfaces accepted by the listener.
var (
_ handlers.RegisterRuntimeService = (*conformanceRegister)(nil)
_ handlers.ForceNextTurnService = (*conformanceForce)(nil)
_ handlers.StopRuntimeService = (*conformanceStop)(nil)
_ handlers.PatchRuntimeService = (*conformancePatch)(nil)
_ handlers.BanishRaceService = (*conformanceBanish)(nil)
_ handlers.MembershipInvalidator = (*conformanceMembership)(nil)
_ handlers.LivenessService = (*conformanceLiveness)(nil)
_ handlers.EngineVersionService = (*conformanceEngineVersions)(nil)
_ handlers.CommandExecuteService = (*conformanceCommands)(nil)
_ handlers.OrderPutService = (*conformanceOrders)(nil)
_ handlers.ReportGetService = (*conformanceReport)(nil)
_ handlers.RuntimeRecordsReader = (*conformanceRuntimeRecords)(nil)
)