feat: gamemaster
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/engineclient"
|
||||
"galaxy/gamemaster/internal/adapters/lobbyclient"
|
||||
"galaxy/gamemaster/internal/adapters/lobbyeventspublisher"
|
||||
"galaxy/gamemaster/internal/adapters/notificationpublisher"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/engineversionstore"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/operationlog"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/playermappingstore"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/runtimerecordstore"
|
||||
"galaxy/gamemaster/internal/adapters/redisstate/streamoffsets"
|
||||
"galaxy/gamemaster/internal/adapters/rtmclient"
|
||||
"galaxy/gamemaster/internal/config"
|
||||
"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/membership"
|
||||
"galaxy/gamemaster/internal/service/orderput"
|
||||
"galaxy/gamemaster/internal/service/registerruntime"
|
||||
"galaxy/gamemaster/internal/service/reportget"
|
||||
"galaxy/gamemaster/internal/service/scheduler"
|
||||
"galaxy/gamemaster/internal/service/turngeneration"
|
||||
"galaxy/gamemaster/internal/telemetry"
|
||||
"galaxy/gamemaster/internal/worker/healtheventsconsumer"
|
||||
"galaxy/gamemaster/internal/worker/schedulerticker"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// wiring owns the process-level singletons constructed once during
|
||||
// `NewRuntime` and consumed by every worker and HTTP handler. Stage
|
||||
// 19 grew the struct to hold every store, adapter, service and
|
||||
// worker required by the listener and the long-lived components.
|
||||
type wiring struct {
|
||||
cfg config.Config
|
||||
|
||||
redisClient *redis.Client
|
||||
pgPool *sql.DB
|
||||
|
||||
clock func() time.Time
|
||||
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
|
||||
// Stores.
|
||||
runtimeRecords *runtimerecordstore.Store
|
||||
engineVersions *engineversionstore.Store
|
||||
playerMappings *playermappingstore.Store
|
||||
operationLogs *operationlog.Store
|
||||
streamOffsets *streamoffsets.Store
|
||||
|
||||
// External adapters.
|
||||
engineClient *engineclient.Client
|
||||
lobbyClient *lobbyclient.Client
|
||||
rtmClient *rtmclient.Client
|
||||
notificationPublisher *notificationpublisher.Publisher
|
||||
lobbyEventsPublisher *lobbyeventspublisher.Publisher
|
||||
|
||||
// Services.
|
||||
membershipCache *membership.Cache
|
||||
registerRuntimeSvc *registerruntime.Service
|
||||
engineVersionSvc *engineversionsvc.Service
|
||||
stopRuntimeSvc *adminstop.Service
|
||||
forceNextTurnSvc *adminforce.Service
|
||||
patchRuntimeSvc *adminpatch.Service
|
||||
banishRaceSvc *adminbanish.Service
|
||||
livenessSvc *livenessreply.Service
|
||||
commandExecuteSvc *commandexecute.Service
|
||||
orderPutSvc *orderput.Service
|
||||
reportGetSvc *reportget.Service
|
||||
schedulerSvc *scheduler.Service
|
||||
turnGenerationSvc *turngeneration.Service
|
||||
|
||||
// Workers.
|
||||
schedulerTicker *schedulerticker.Worker
|
||||
healthEventsConsumer *healtheventsconsumer.Worker
|
||||
|
||||
// closers releases adapter-level resources at runtime shutdown.
|
||||
closers []func() error
|
||||
}
|
||||
|
||||
// newWiring constructs the process-level dependency set. It validates
|
||||
// every required collaborator so callers can rely on them being
|
||||
// non-nil. Construction proceeds in four phases: persistence stores,
|
||||
// external adapters, services, workers. Each phase is in its own
|
||||
// helper to keep the function readable.
|
||||
func newWiring(
|
||||
cfg config.Config,
|
||||
redisClient *redis.Client,
|
||||
pgPool *sql.DB,
|
||||
clock func() time.Time,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*wiring, error) {
|
||||
if redisClient == nil {
|
||||
return nil, errors.New("new gamemaster wiring: nil redis client")
|
||||
}
|
||||
if pgPool == nil {
|
||||
return nil, errors.New("new gamemaster wiring: nil postgres pool")
|
||||
}
|
||||
if clock == nil {
|
||||
clock = time.Now
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if telemetryRuntime == nil {
|
||||
return nil, fmt.Errorf("new gamemaster wiring: nil telemetry runtime")
|
||||
}
|
||||
|
||||
w := &wiring{
|
||||
cfg: cfg,
|
||||
redisClient: redisClient,
|
||||
pgPool: pgPool,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
}
|
||||
|
||||
if err := w.buildPersistence(); err != nil {
|
||||
return nil, fmt.Errorf("new gamemaster wiring: persistence: %w", err)
|
||||
}
|
||||
if err := w.buildAdapters(); err != nil {
|
||||
return nil, fmt.Errorf("new gamemaster wiring: adapters: %w", err)
|
||||
}
|
||||
if err := w.buildServices(); err != nil {
|
||||
return nil, fmt.Errorf("new gamemaster wiring: services: %w", err)
|
||||
}
|
||||
if err := w.buildWorkers(); err != nil {
|
||||
return nil, fmt.Errorf("new gamemaster wiring: workers: %w", err)
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// buildPersistence constructs the four PostgreSQL stores plus the
|
||||
// Redis-backed stream-offset store. The stores share the connection
|
||||
// pools opened by the runtime; their lifecycles are owned by the
|
||||
// runtime, not the wiring.
|
||||
func (w *wiring) buildPersistence() error {
|
||||
timeout := w.cfg.Postgres.Conn.OperationTimeout
|
||||
|
||||
runtimeRecords, err := runtimerecordstore.New(runtimerecordstore.Config{
|
||||
DB: w.pgPool,
|
||||
OperationTimeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("runtime record store: %w", err)
|
||||
}
|
||||
w.runtimeRecords = runtimeRecords
|
||||
|
||||
engineVersions, err := engineversionstore.New(engineversionstore.Config{
|
||||
DB: w.pgPool,
|
||||
OperationTimeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine version store: %w", err)
|
||||
}
|
||||
w.engineVersions = engineVersions
|
||||
|
||||
playerMappings, err := playermappingstore.New(playermappingstore.Config{
|
||||
DB: w.pgPool,
|
||||
OperationTimeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("player mapping store: %w", err)
|
||||
}
|
||||
w.playerMappings = playerMappings
|
||||
|
||||
operationLogs, err := operationlog.New(operationlog.Config{
|
||||
DB: w.pgPool,
|
||||
OperationTimeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("operation log store: %w", err)
|
||||
}
|
||||
w.operationLogs = operationLogs
|
||||
|
||||
streamOffsets, err := streamoffsets.New(streamoffsets.Config{Client: w.redisClient})
|
||||
if err != nil {
|
||||
return fmt.Errorf("stream offset store: %w", err)
|
||||
}
|
||||
w.streamOffsets = streamOffsets
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAdapters constructs the HTTP clients (engine, Lobby, Runtime
|
||||
// Manager) and the two Redis Stream publishers. Their `Close` hooks
|
||||
// are appended to w.closers so idle TCP connections are released on
|
||||
// shutdown.
|
||||
func (w *wiring) buildAdapters() error {
|
||||
engine, err := engineclient.NewClient(engineclient.Config{
|
||||
CallTimeout: w.cfg.EngineClient.CallTimeout,
|
||||
ProbeTimeout: w.cfg.EngineClient.ProbeTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine client: %w", err)
|
||||
}
|
||||
w.engineClient = engine
|
||||
w.closers = append(w.closers, engine.Close)
|
||||
|
||||
lobby, err := lobbyclient.NewClient(lobbyclient.Config{
|
||||
BaseURL: w.cfg.Lobby.BaseURL,
|
||||
RequestTimeout: w.cfg.Lobby.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("lobby client: %w", err)
|
||||
}
|
||||
w.lobbyClient = lobby
|
||||
w.closers = append(w.closers, lobby.Close)
|
||||
|
||||
rtm, err := rtmclient.NewClient(rtmclient.Config{
|
||||
BaseURL: w.cfg.RTM.BaseURL,
|
||||
RequestTimeout: w.cfg.RTM.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("rtm client: %w", err)
|
||||
}
|
||||
w.rtmClient = rtm
|
||||
w.closers = append(w.closers, rtm.Close)
|
||||
|
||||
notification, err := notificationpublisher.NewPublisher(notificationpublisher.Config{
|
||||
Client: w.redisClient,
|
||||
Stream: w.cfg.Streams.NotificationIntents,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("notification publisher: %w", err)
|
||||
}
|
||||
w.notificationPublisher = notification
|
||||
|
||||
lobbyEvents, err := lobbyeventspublisher.NewPublisher(lobbyeventspublisher.Config{
|
||||
Client: w.redisClient,
|
||||
Stream: w.cfg.Streams.LobbyEvents,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("lobby events publisher: %w", err)
|
||||
}
|
||||
w.lobbyEventsPublisher = lobbyEvents
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildServices constructs every service-layer collaborator consumed
|
||||
// by the REST listener and the workers. Construction order matters
|
||||
// only between turngeneration → adminforce (the latter wraps the
|
||||
// former) and between membership cache → command/order/report
|
||||
// services.
|
||||
func (w *wiring) buildServices() error {
|
||||
cache, err := membership.NewCache(membership.Dependencies{
|
||||
Lobby: w.lobbyClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
TTL: w.cfg.MembershipCache.TTL,
|
||||
MaxGames: w.cfg.MembershipCache.MaxGames,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("membership cache: %w", err)
|
||||
}
|
||||
w.membershipCache = cache
|
||||
|
||||
w.schedulerSvc = scheduler.New()
|
||||
|
||||
registerSvc, err := registerruntime.NewService(registerruntime.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
EngineVersions: w.engineVersions,
|
||||
PlayerMappings: w.playerMappings,
|
||||
OperationLogs: w.operationLogs,
|
||||
Engine: w.engineClient,
|
||||
LobbyEvents: w.lobbyEventsPublisher,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("register runtime service: %w", err)
|
||||
}
|
||||
w.registerRuntimeSvc = registerSvc
|
||||
|
||||
engineVersionSvc, err := engineversionsvc.NewService(engineversionsvc.Dependencies{
|
||||
EngineVersions: w.engineVersions,
|
||||
OperationLogs: w.operationLogs,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine version service: %w", err)
|
||||
}
|
||||
w.engineVersionSvc = engineVersionSvc
|
||||
|
||||
turnGen, err := turngeneration.NewService(turngeneration.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
PlayerMappings: w.playerMappings,
|
||||
OperationLogs: w.operationLogs,
|
||||
Engine: w.engineClient,
|
||||
LobbyEvents: w.lobbyEventsPublisher,
|
||||
Notifications: w.notificationPublisher,
|
||||
Lobby: w.lobbyClient,
|
||||
Scheduler: w.schedulerSvc,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("turn generation service: %w", err)
|
||||
}
|
||||
w.turnGenerationSvc = turnGen
|
||||
|
||||
stopSvc, err := adminstop.NewService(adminstop.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
OperationLogs: w.operationLogs,
|
||||
RTM: w.rtmClient,
|
||||
LobbyEvents: w.lobbyEventsPublisher,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin stop service: %w", err)
|
||||
}
|
||||
w.stopRuntimeSvc = stopSvc
|
||||
|
||||
forceSvc, err := adminforce.NewService(adminforce.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
OperationLogs: w.operationLogs,
|
||||
TurnGeneration: turnGen,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin force service: %w", err)
|
||||
}
|
||||
w.forceNextTurnSvc = forceSvc
|
||||
|
||||
patchSvc, err := adminpatch.NewService(adminpatch.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
EngineVersions: w.engineVersions,
|
||||
OperationLogs: w.operationLogs,
|
||||
RTM: w.rtmClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin patch service: %w", err)
|
||||
}
|
||||
w.patchRuntimeSvc = patchSvc
|
||||
|
||||
banishSvc, err := adminbanish.NewService(adminbanish.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
PlayerMappings: w.playerMappings,
|
||||
OperationLogs: w.operationLogs,
|
||||
Engine: w.engineClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin banish service: %w", err)
|
||||
}
|
||||
w.banishRaceSvc = banishSvc
|
||||
|
||||
livenessSvc, err := livenessreply.NewService(livenessreply.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
Logger: w.logger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("liveness reply service: %w", err)
|
||||
}
|
||||
w.livenessSvc = livenessSvc
|
||||
|
||||
commandSvc, err := commandexecute.NewService(commandexecute.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
PlayerMappings: w.playerMappings,
|
||||
Membership: cache,
|
||||
Engine: w.engineClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("command execute service: %w", err)
|
||||
}
|
||||
w.commandExecuteSvc = commandSvc
|
||||
|
||||
orderSvc, err := orderput.NewService(orderput.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
PlayerMappings: w.playerMappings,
|
||||
Membership: cache,
|
||||
Engine: w.engineClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("put orders service: %w", err)
|
||||
}
|
||||
w.orderPutSvc = orderSvc
|
||||
|
||||
reportSvc, err := reportget.NewService(reportget.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
PlayerMappings: w.playerMappings,
|
||||
Membership: cache,
|
||||
Engine: w.engineClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get report service: %w", err)
|
||||
}
|
||||
w.reportGetSvc = reportSvc
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildWorkers constructs the long-lived components started by
|
||||
// `App.Run` alongside the listener: the per-second scheduler ticker
|
||||
// and the runtime:health_events consumer.
|
||||
func (w *wiring) buildWorkers() error {
|
||||
ticker, err := schedulerticker.NewWorker(schedulerticker.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
TurnGeneration: w.turnGenerationSvc,
|
||||
Telemetry: w.telemetry,
|
||||
Interval: w.cfg.Scheduler.TickInterval,
|
||||
Clock: w.clock,
|
||||
Logger: w.logger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("scheduler ticker: %w", err)
|
||||
}
|
||||
w.schedulerTicker = ticker
|
||||
|
||||
healthConsumer, err := healtheventsconsumer.NewWorker(healtheventsconsumer.Dependencies{
|
||||
Client: w.redisClient,
|
||||
Stream: w.cfg.Streams.HealthEvents,
|
||||
BlockTimeout: w.cfg.Streams.BlockTimeout,
|
||||
OffsetStore: w.streamOffsets,
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
LobbyEvents: w.lobbyEventsPublisher,
|
||||
Telemetry: w.telemetry,
|
||||
Clock: w.clock,
|
||||
Logger: w.logger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("health events consumer: %w", err)
|
||||
}
|
||||
w.healthEventsConsumer = healthConsumer
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// close releases adapter-level resources owned by the wiring layer.
|
||||
// Returns the joined error of every closer; the caller is expected
|
||||
// to invoke this once during process shutdown. Closers run in LIFO
|
||||
// order so the resource opened last is released first.
|
||||
func (w *wiring) close() error {
|
||||
var joined error
|
||||
for index := len(w.closers) - 1; index >= 0; index-- {
|
||||
if err := w.closers[index](); err != nil {
|
||||
joined = errors.Join(joined, err)
|
||||
}
|
||||
}
|
||||
w.closers = nil
|
||||
return joined
|
||||
}
|
||||
Reference in New Issue
Block a user