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,292 @@
// Package playermappingstore implements the PostgreSQL-backed adapter
// for `ports.PlayerMappingStore`.
//
// The package owns the on-disk shape of the `player_mappings` table
// defined in
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
// and translates the schema-agnostic `ports.PlayerMappingStore`
// interface declared in `internal/ports/playermappingstore.go` into
// concrete go-jet/v2 statements driven by the pgx driver.
//
// BulkInsert ships every row in a single multi-row INSERT so the
// operation is atomic — any unique-constraint violation rolls back the
// whole batch and is mapped to playermapping.ErrConflict.
package playermappingstore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/gamemaster/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/gamemaster/internal/adapters/postgres/jet/gamemaster/table"
"galaxy/gamemaster/internal/domain/playermapping"
"galaxy/gamemaster/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// Config configures one PostgreSQL-backed player-mapping store.
type Config struct {
DB *sql.DB
OperationTimeout time.Duration
}
// Store persists Game Master player mappings in PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed player-mapping store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres player mapping store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres player mapping store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// playerMappingSelectColumns matches scanRow's column order.
var playerMappingSelectColumns = pg.ColumnList{
pgtable.PlayerMappings.GameID,
pgtable.PlayerMappings.UserID,
pgtable.PlayerMappings.RaceName,
pgtable.PlayerMappings.EnginePlayerUUID,
pgtable.PlayerMappings.CreatedAt,
}
// BulkInsert installs every mapping in records using a single
// multi-row INSERT. Either every row is persisted or none of them is.
// Any PostgreSQL unique-violation
// (`(game_id, user_id)` PK or `(game_id, race_name)` UNIQUE) is mapped
// to playermapping.ErrConflict.
func (store *Store) BulkInsert(ctx context.Context, records []playermapping.PlayerMapping) error {
if store == nil || store.db == nil {
return errors.New("bulk insert player mappings: nil store")
}
if len(records) == 0 {
return nil
}
for index, record := range records {
if err := record.Validate(); err != nil {
return fmt.Errorf("bulk insert player mappings: record %d: %w", index, err)
}
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "bulk insert player mappings", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.PlayerMappings.INSERT(
pgtable.PlayerMappings.GameID,
pgtable.PlayerMappings.UserID,
pgtable.PlayerMappings.RaceName,
pgtable.PlayerMappings.EnginePlayerUUID,
pgtable.PlayerMappings.CreatedAt,
)
for _, record := range records {
stmt = stmt.VALUES(
record.GameID,
record.UserID,
record.RaceName,
record.EnginePlayerUUID,
record.CreatedAt.UTC(),
)
}
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
if sqlx.IsUniqueViolation(err) {
return fmt.Errorf("bulk insert player mappings: %w", playermapping.ErrConflict)
}
return fmt.Errorf("bulk insert player mappings: %w", err)
}
return nil
}
// Get returns the mapping identified by (gameID, userID).
func (store *Store) Get(ctx context.Context, gameID, userID string) (playermapping.PlayerMapping, error) {
if store == nil || store.db == nil {
return playermapping.PlayerMapping{}, errors.New("get player mapping: nil store")
}
if strings.TrimSpace(gameID) == "" {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: game id must not be empty")
}
if strings.TrimSpace(userID) == "" {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: user id must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get player mapping", store.operationTimeout)
if err != nil {
return playermapping.PlayerMapping{}, err
}
defer cancel()
stmt := pg.SELECT(playerMappingSelectColumns).
FROM(pgtable.PlayerMappings).
WHERE(pg.AND(
pgtable.PlayerMappings.GameID.EQ(pg.String(gameID)),
pgtable.PlayerMappings.UserID.EQ(pg.String(userID)),
))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
got, err := scanRow(row)
if sqlx.IsNoRows(err) {
return playermapping.PlayerMapping{}, playermapping.ErrNotFound
}
if err != nil {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: %w", err)
}
return got, nil
}
// GetByRace returns the mapping identified by (gameID, raceName).
func (store *Store) GetByRace(ctx context.Context, gameID, raceName string) (playermapping.PlayerMapping, error) {
if store == nil || store.db == nil {
return playermapping.PlayerMapping{}, errors.New("get player mapping by race: nil store")
}
if strings.TrimSpace(gameID) == "" {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: game id must not be empty")
}
if strings.TrimSpace(raceName) == "" {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: race name must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get player mapping by race", store.operationTimeout)
if err != nil {
return playermapping.PlayerMapping{}, err
}
defer cancel()
stmt := pg.SELECT(playerMappingSelectColumns).
FROM(pgtable.PlayerMappings).
WHERE(pg.AND(
pgtable.PlayerMappings.GameID.EQ(pg.String(gameID)),
pgtable.PlayerMappings.RaceName.EQ(pg.String(raceName)),
))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
got, err := scanRow(row)
if sqlx.IsNoRows(err) {
return playermapping.PlayerMapping{}, playermapping.ErrNotFound
}
if err != nil {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: %w", err)
}
return got, nil
}
// ListByGame returns every mapping owned by gameID, ordered by user_id
// ascending.
func (store *Store) ListByGame(ctx context.Context, gameID string) ([]playermapping.PlayerMapping, error) {
if store == nil || store.db == nil {
return nil, errors.New("list player mappings by game: nil store")
}
if strings.TrimSpace(gameID) == "" {
return nil, fmt.Errorf("list player mappings by game: game id must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list player mappings by game", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(playerMappingSelectColumns).
FROM(pgtable.PlayerMappings).
WHERE(pgtable.PlayerMappings.GameID.EQ(pg.String(gameID))).
ORDER_BY(pgtable.PlayerMappings.UserID.ASC())
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("list player mappings by game: %w", err)
}
defer rows.Close()
mappings := make([]playermapping.PlayerMapping, 0)
for rows.Next() {
got, err := scanRow(rows)
if err != nil {
return nil, fmt.Errorf("list player mappings by game: scan: %w", err)
}
mappings = append(mappings, got)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list player mappings by game: %w", err)
}
if len(mappings) == 0 {
return nil, nil
}
return mappings, nil
}
// DeleteByGame removes every mapping owned by gameID. The call is
// idempotent: it returns nil even when no rows were deleted.
func (store *Store) DeleteByGame(ctx context.Context, gameID string) error {
if store == nil || store.db == nil {
return errors.New("delete player mappings by game: nil store")
}
if strings.TrimSpace(gameID) == "" {
return fmt.Errorf("delete player mappings by game: game id must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete player mappings by game", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.PlayerMappings.DELETE().
WHERE(pgtable.PlayerMappings.GameID.EQ(pg.String(gameID)))
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
return fmt.Errorf("delete player mappings by game: %w", err)
}
return nil
}
// rowScanner abstracts *sql.Row and *sql.Rows so scanRow can be shared
// across single-row and iterated reads.
type rowScanner interface {
Scan(dest ...any) error
}
// scanRow scans one player_mappings row from rs.
func scanRow(rs rowScanner) (playermapping.PlayerMapping, error) {
var (
gameID string
userID string
raceName string
enginePlayerUUID string
createdAt time.Time
)
if err := rs.Scan(&gameID, &userID, &raceName, &enginePlayerUUID, &createdAt); err != nil {
return playermapping.PlayerMapping{}, err
}
return playermapping.PlayerMapping{
GameID: gameID,
UserID: userID,
RaceName: raceName,
EnginePlayerUUID: enginePlayerUUID,
CreatedAt: createdAt.UTC(),
}, nil
}
// Ensure Store satisfies the ports.PlayerMappingStore interface at
// compile time.
var _ ports.PlayerMappingStore = (*Store)(nil)