feat(lobby): enter the game immediately and wait for the opponent inside it
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s
Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place. Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent". Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
@@ -21,9 +23,15 @@ func gameSummary(g Game, names []string) notify.GameSummary {
|
||||
if s.Seat >= 0 && s.Seat < len(names) {
|
||||
name = names[s.Seat]
|
||||
}
|
||||
// An open game's still-empty opponent seat carries no account: send an empty id
|
||||
// (not the nil-UUID string) so the client renders it as "searching for opponent".
|
||||
accountID := ""
|
||||
if s.AccountID != uuid.Nil {
|
||||
accountID = s.AccountID.String()
|
||||
}
|
||||
seats = append(seats, notify.SeatStanding{
|
||||
Seat: s.Seat,
|
||||
AccountID: s.AccountID.String(),
|
||||
AccountID: accountID,
|
||||
DisplayName: name,
|
||||
Score: s.Score,
|
||||
HintsUsed: s.HintsUsed,
|
||||
|
||||
@@ -145,6 +145,96 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
||||
return svc.store.GetGame(ctx, id)
|
||||
}
|
||||
|
||||
// OpenOrJoin enters accountID into auto-match for the variant and per-turn rule in
|
||||
// params and returns the game they land in immediately: another waiting player's open
|
||||
// game (joined=true), the caller's own still-open game on a re-enqueue, or a fresh open
|
||||
// game seating only the caller with an empty opponent seat that a human or the reaper's
|
||||
// robot fills later. openDeadline is when the reaper substitutes a robot into a freshly
|
||||
// opened game (ignored when joining one). The bag seed defaults to random; params.Seed
|
||||
// pins it. First-move fairness comes from seating the caller at seat 0 or seat 1
|
||||
// (derived from the seed): seated at seat 1, the still-empty seat 0 moves first, so the
|
||||
// caller just waits for the opponent. It backs the lobby auto-match enqueue.
|
||||
func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params CreateParams, openDeadline time.Time) (Game, bool, error) {
|
||||
if _, err := svc.accounts.GetByID(ctx, accountID); err != nil {
|
||||
if errors.Is(err, account.ErrNotFound) {
|
||||
return Game{}, false, fmt.Errorf("%w: account %s not found", ErrInvalidConfig, accountID)
|
||||
}
|
||||
return Game{}, false, err
|
||||
}
|
||||
timeout := params.TurnTimeout
|
||||
if timeout == 0 {
|
||||
timeout = DefaultTurnTimeout
|
||||
}
|
||||
if !allowedTimeout(timeout) {
|
||||
return Game{}, false, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidConfig, timeout)
|
||||
}
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Game{}, false, fmt.Errorf("game: new id: %w", err)
|
||||
}
|
||||
seed := params.Seed
|
||||
if seed == 0 {
|
||||
seed = svc.rng()
|
||||
}
|
||||
deadline := openDeadline
|
||||
ins := gameInsert{
|
||||
id: id,
|
||||
variant: params.Variant.String(),
|
||||
dictVersion: svc.version,
|
||||
seed: seed,
|
||||
players: 2,
|
||||
turnTimeoutSecs: int(timeout / time.Second),
|
||||
hintsAllowed: params.HintsAllowed,
|
||||
hintsPerPlayer: params.HintsPerPlayer,
|
||||
dropoutTiles: params.DropoutTiles.String(),
|
||||
multipleWordsPerTurn: params.MultipleWordsPerTurn,
|
||||
status: StatusOpen,
|
||||
openDeadline: &deadline,
|
||||
}
|
||||
// Seat the caller at seat 0 or seat 1 (seat 0 always moves first); the other seat
|
||||
// is left empty (uuid.Nil) for the opponent.
|
||||
seats := []uuid.UUID{accountID, uuid.Nil}
|
||||
if seed&1 == 1 {
|
||||
seats = []uuid.UUID{uuid.Nil, accountID}
|
||||
}
|
||||
gameID, joined, created, err := svc.store.OpenOrJoin(ctx, accountID, ins, seats)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
if created {
|
||||
svc.metrics.recordStarted(ctx, params.Variant)
|
||||
}
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
return g, joined, nil
|
||||
}
|
||||
|
||||
// AttachRobot seats robotID in the empty opponent seat of open game gameID and flips
|
||||
// it to active, returning the now-active game and whether it attached (false, with a
|
||||
// zero Game, when a human joined first). It backs the matchmaking reaper.
|
||||
func (svc *Service) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (Game, bool, error) {
|
||||
attached, err := svc.store.AttachRobot(ctx, gameID, robotID)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
if !attached {
|
||||
return Game{}, false, nil
|
||||
}
|
||||
g, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return Game{}, false, err
|
||||
}
|
||||
return g, true, nil
|
||||
}
|
||||
|
||||
// ExpiredOpen returns the open games due for a robot substitution (deadline at or
|
||||
// before now) for the matchmaking reaper.
|
||||
func (svc *Service) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) {
|
||||
return svc.store.ExpiredOpen(ctx, now)
|
||||
}
|
||||
|
||||
// engineOp applies one transition to the live game, returning the decoded record
|
||||
// and, for an exchange, the swapped tiles.
|
||||
type engineOp func(g *engine.Game) (engine.MoveRecord, []string, error)
|
||||
@@ -189,6 +279,11 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
|
||||
if !ok {
|
||||
return MoveResult{}, ErrNotAPlayer
|
||||
}
|
||||
// Resign needs a present opponent to award the win, so it is refused while the game
|
||||
// is still waiting for one; the UI keeps the button disabled until then.
|
||||
if pre.Status == StatusOpen {
|
||||
return MoveResult{}, ErrNoOpponentYet
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
return MoveResult{}, ErrFinished
|
||||
}
|
||||
@@ -260,7 +355,10 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
|
||||
if !ok {
|
||||
return MoveResult{}, ErrNotAPlayer
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
// A move is allowed while the game is active or still open (the starter may move on
|
||||
// their turn before an opponent joins); only a finished game rejects it. The turn
|
||||
// check below keeps the starter off the still-empty opponent seat.
|
||||
if pre.Status == StatusFinished {
|
||||
return MoveResult{}, ErrFinished
|
||||
}
|
||||
if pre.ToMove != seat {
|
||||
@@ -382,6 +480,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
|
||||
summary := gameSummary(post, names)
|
||||
intents := make([]notify.Intent, 0, 2*len(post.Seats))
|
||||
for _, s := range post.Seats {
|
||||
if s.AccountID == uuid.Nil {
|
||||
continue // an open game's opponent seat is not yet filled — nobody to notify
|
||||
}
|
||||
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
|
||||
}
|
||||
// Game pushes are routed out-of-app by the game's own language, not the recipient's
|
||||
@@ -406,6 +507,9 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
|
||||
// seat, each with their own perspective + recipient-first score, so an offline player gets
|
||||
// an out-of-app "game over" push (online players take it from the in-app refresh).
|
||||
for _, s := range post.Seats {
|
||||
if s.AccountID == uuid.Nil {
|
||||
continue
|
||||
}
|
||||
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
|
||||
over.Language = lang
|
||||
intents = append(intents, over)
|
||||
@@ -540,7 +644,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
|
||||
if _, ok := pre.seatOf(accountID); !ok {
|
||||
return EvalResult{}, ErrNotAPlayer
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
if pre.Status == StatusFinished {
|
||||
return EvalResult{}, ErrFinished
|
||||
}
|
||||
|
||||
@@ -674,7 +778,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
if !ok {
|
||||
return HintResult{}, ErrNotAPlayer
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
if pre.Status == StatusFinished {
|
||||
return HintResult{}, ErrFinished
|
||||
}
|
||||
if pre.ToMove != seat {
|
||||
@@ -736,7 +840,7 @@ func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
if !ok {
|
||||
return nil, ErrNotAPlayer
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
if pre.Status == StatusFinished {
|
||||
return nil, ErrFinished
|
||||
}
|
||||
if pre.ToMove != seat {
|
||||
|
||||
+196
-15
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
@@ -40,6 +41,13 @@ type gameInsert struct {
|
||||
dropoutTiles string
|
||||
// multipleWordsPerTurn false selects the single-word rule for the game.
|
||||
multipleWordsPerTurn bool
|
||||
// status is the lifecycle state to create the game in: StatusActive for a normal
|
||||
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
|
||||
// empty string defaults to StatusActive.
|
||||
status string
|
||||
// openDeadline, set only for a StatusOpen game, is when the matchmaking reaper
|
||||
// substitutes a robot if no human has joined; nil for a normal game.
|
||||
openDeadline *time.Time
|
||||
}
|
||||
|
||||
// statDelta is one account's contribution to its statistics on a game finish.
|
||||
@@ -91,24 +99,186 @@ type activeGame struct {
|
||||
// first) inside a single transaction.
|
||||
func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUID) error {
|
||||
return withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
gi := table.Games.INSERT(
|
||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
||||
table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn,
|
||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.multipleWordsPerTurn)
|
||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert game: %w", err)
|
||||
return insertGameTx(ctx, tx, ins, seats)
|
||||
})
|
||||
}
|
||||
|
||||
// insertGameTx inserts the games row and one game_players row per seat (seat 0
|
||||
// first) on tx. A seat whose account id is uuid.Nil is written with a NULL
|
||||
// account_id — the still-empty opponent seat of a StatusOpen auto-match game.
|
||||
func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []uuid.UUID) error {
|
||||
status := ins.status
|
||||
if status == "" {
|
||||
status = StatusActive
|
||||
}
|
||||
var deadline any = postgres.NULL
|
||||
if ins.openDeadline != nil {
|
||||
deadline = postgres.TimestampzT(*ins.openDeadline)
|
||||
}
|
||||
gi := table.Games.INSERT(
|
||||
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
|
||||
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
|
||||
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
|
||||
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn,
|
||||
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
|
||||
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn)
|
||||
if _, err := gi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert game: %w", err)
|
||||
}
|
||||
for seat, accountID := range seats {
|
||||
var acc any = accountID
|
||||
if accountID == uuid.Nil {
|
||||
acc = postgres.NULL
|
||||
}
|
||||
for seat, accountID := range seats {
|
||||
pi := table.GamePlayers.INSERT(
|
||||
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
|
||||
).VALUES(ins.id, seat, accountID)
|
||||
if _, err := pi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert seat %d: %w", seat, err)
|
||||
pi := table.GamePlayers.INSERT(
|
||||
table.GamePlayers.GameID, table.GamePlayers.Seat, table.GamePlayers.AccountID,
|
||||
).VALUES(ins.id, seat, acc)
|
||||
if _, err := pi.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert seat %d: %w", seat, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openMatchKey hashes an auto-match bucket (variant + per-turn word rule) into the
|
||||
// advisory-lock key that serialises concurrent enqueues for that bucket, so two
|
||||
// players never both open a game instead of pairing.
|
||||
func openMatchKey(variant string, multipleWords bool) int64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(variant))
|
||||
if multipleWords {
|
||||
_, _ = h.Write([]byte{1})
|
||||
} else {
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
return int64(h.Sum64())
|
||||
}
|
||||
|
||||
// OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it
|
||||
// lands in: it re-uses the caller's own still-open game (joined=false, created=false,
|
||||
// a re-enqueue is idempotent), joins another player's waiting open game and flips it
|
||||
// active (joined=true), or opens a fresh game seating the caller with an empty
|
||||
// opponent seat (created=true). ins supplies the new game's immutable fields and is
|
||||
// used only when a game is created. A transaction-scoped advisory lock on the
|
||||
// (variant, rule) bucket serialises concurrent enqueues so two callers pair rather
|
||||
// than each opening a game. seats is the two-seat arrangement (the caller and uuid.Nil
|
||||
// for the still-empty opponent, in the chosen order) used only when a game is created.
|
||||
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, ins gameInsert, seats []uuid.UUID) (gameID uuid.UUID, joined, created bool, err error) {
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
|
||||
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
|
||||
return fmt.Errorf("open match lock: %w", e)
|
||||
}
|
||||
// 1. The caller's own still-open game for this bucket — a re-enqueue is idempotent.
|
||||
var own uuid.UUID
|
||||
switch e := tx.QueryRowContext(ctx,
|
||||
`SELECT g.game_id FROM backend.games g
|
||||
JOIN backend.game_players p ON p.game_id = g.game_id
|
||||
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 AND p.account_id = $3
|
||||
LIMIT 1`,
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&own); {
|
||||
case e == nil:
|
||||
gameID = own
|
||||
return nil
|
||||
case !errors.Is(e, sql.ErrNoRows):
|
||||
return fmt.Errorf("find own open game: %w", e)
|
||||
}
|
||||
// 2. Another player's open game waiting for an opponent — fill its seat and start it.
|
||||
var other uuid.UUID
|
||||
switch e := tx.QueryRowContext(ctx,
|
||||
`SELECT g.game_id FROM backend.games g
|
||||
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2
|
||||
AND NOT EXISTS (SELECT 1 FROM backend.game_players p
|
||||
WHERE p.game_id = g.game_id AND p.account_id = $3)
|
||||
ORDER BY g.created_at
|
||||
LIMIT 1 FOR UPDATE SKIP LOCKED`,
|
||||
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&other); {
|
||||
case e == nil:
|
||||
if er := fillOpenSeat(ctx, tx, other, accountID); er != nil {
|
||||
return er
|
||||
}
|
||||
gameID, joined = other, true
|
||||
return nil
|
||||
case !errors.Is(e, sql.ErrNoRows):
|
||||
return fmt.Errorf("find open game: %w", e)
|
||||
}
|
||||
// 3. None waiting — open a fresh game seating the caller (the other seat empty).
|
||||
if e := insertGameTx(ctx, tx, ins, seats); e != nil {
|
||||
return e
|
||||
}
|
||||
gameID, created = ins.id, true
|
||||
return nil
|
||||
})
|
||||
return gameID, joined, created, err
|
||||
}
|
||||
|
||||
// AttachRobot fills the empty opponent seat of open game gameID with robotID and
|
||||
// flips it to active, returning whether it attached. It is a no-op (false) when the
|
||||
// game is no longer open — a human joined first — so the reaper never double-fills.
|
||||
func (s *Store) AttachRobot(ctx context.Context, gameID, robotID uuid.UUID) (bool, error) {
|
||||
attached := false
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
var status string
|
||||
switch e := tx.QueryRowContext(ctx,
|
||||
`SELECT status FROM backend.games WHERE game_id = $1 FOR UPDATE`, gameID).Scan(&status); {
|
||||
case errors.Is(e, sql.ErrNoRows):
|
||||
return nil
|
||||
case e != nil:
|
||||
return fmt.Errorf("lock game for robot: %w", e)
|
||||
}
|
||||
if status != StatusOpen {
|
||||
return nil
|
||||
}
|
||||
if e := fillOpenSeat(ctx, tx, gameID, robotID); e != nil {
|
||||
return e
|
||||
}
|
||||
attached = true
|
||||
return nil
|
||||
})
|
||||
return attached, err
|
||||
}
|
||||
|
||||
// fillOpenSeat seats accountID in an open game's empty opponent seat and flips the
|
||||
// game to active, stamping a fresh turn clock. The caller holds the game row.
|
||||
func fillOpenSeat(ctx context.Context, tx *sql.Tx, gameID, accountID uuid.UUID) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE backend.game_players SET account_id = $2 WHERE game_id = $1 AND account_id IS NULL`,
|
||||
gameID, accountID); err != nil {
|
||||
return fmt.Errorf("fill opponent seat: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE backend.games SET status = 'active', open_deadline_at = NULL, turn_started_at = now(), updated_at = now()
|
||||
WHERE game_id = $1`, gameID); err != nil {
|
||||
return fmt.Errorf("activate game: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpiredOpen returns the open games whose robot deadline has passed (at or before
|
||||
// now), oldest deadline first, for the matchmaking reaper to fill with a robot.
|
||||
func (s *Store) ExpiredOpen(ctx context.Context, now time.Time) ([]OpenGame, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT game_id, variant FROM backend.games
|
||||
WHERE status = 'open' AND open_deadline_at IS NOT NULL AND open_deadline_at <= $1
|
||||
ORDER BY open_deadline_at`, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game: expired open: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []OpenGame
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var variantStr string
|
||||
if err := rows.Scan(&id, &variantStr); err != nil {
|
||||
return nil, fmt.Errorf("game: scan expired open: %w", err)
|
||||
}
|
||||
variant, err := engine.ParseVariant(variantStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game: expired open %s: %w", id, err)
|
||||
}
|
||||
out = append(out, OpenGame{ID: id, Variant: variant})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetGame loads the games row joined with its seats (ordered by seat), or
|
||||
@@ -670,9 +840,14 @@ func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, e
|
||||
}
|
||||
out := make([]RobotTurn, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
// The filter matches only the robot's (non-null) seat, so AccountID is set.
|
||||
robotID := uuid.Nil
|
||||
if r.GamePlayers.AccountID != nil {
|
||||
robotID = *r.GamePlayers.AccountID
|
||||
}
|
||||
out = append(out, RobotTurn{
|
||||
GameID: r.Games.GameID,
|
||||
RobotID: r.GamePlayers.AccountID,
|
||||
RobotID: robotID,
|
||||
RobotSeat: int(r.GamePlayers.Seat),
|
||||
ToMove: int(r.Games.ToMove),
|
||||
TurnStartedAt: r.Games.TurnStartedAt,
|
||||
@@ -773,9 +948,15 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
|
||||
}
|
||||
out.Seats = make([]Seat, 0, len(seats))
|
||||
for _, p := range seats {
|
||||
// A NULL account_id is the still-empty opponent seat of an open game; surface it
|
||||
// as uuid.Nil so callers keep the "seats == players" invariant.
|
||||
accountID := uuid.Nil
|
||||
if p.AccountID != nil {
|
||||
accountID = *p.AccountID
|
||||
}
|
||||
out.Seats = append(out.Seats, Seat{
|
||||
Seat: int(p.Seat),
|
||||
AccountID: p.AccountID,
|
||||
AccountID: accountID,
|
||||
Score: int(p.Score),
|
||||
HintsUsed: int(p.HintsUsed),
|
||||
IsWinner: p.IsWinner,
|
||||
|
||||
@@ -13,11 +13,16 @@ import (
|
||||
const (
|
||||
StatusActive = "active"
|
||||
StatusFinished = "finished"
|
||||
// StatusOpen is an auto-match game whose starter has already entered it but
|
||||
// which is still waiting for an opponent: the opponent seat holds no account
|
||||
// yet. The starter may move on their turn; it becomes StatusActive when a human
|
||||
// or a robot joins (see OpenGame, Service.OpenOrJoin and Service.AttachRobot).
|
||||
StatusOpen = "open"
|
||||
)
|
||||
|
||||
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen
|
||||
// and closed StatusComplaintResolved by the admin review queue with a
|
||||
// Disposition. The CHECK constraints live in migration 00008.
|
||||
// Disposition. The CHECK constraints live in the baseline migration.
|
||||
const (
|
||||
StatusComplaintOpen = "open"
|
||||
StatusComplaintResolved = "resolved"
|
||||
@@ -43,6 +48,10 @@ var (
|
||||
ErrNotYourTurn = errors.New("game: not the player's turn")
|
||||
// ErrFinished is returned when a transition is attempted on a finished game.
|
||||
ErrFinished = errors.New("game: game is finished")
|
||||
// ErrNoOpponentYet is returned when an action that needs a present opponent
|
||||
// (resign, chat, nudge) is attempted on an auto-match game still waiting for one
|
||||
// (StatusOpen).
|
||||
ErrNoOpponentYet = errors.New("game: no opponent has joined yet")
|
||||
// ErrGameActive is returned when an operation allowed only on a finished game
|
||||
// (such as a GCG export) is attempted while the game is still active.
|
||||
ErrGameActive = errors.New("game: game is still active")
|
||||
@@ -117,6 +126,14 @@ type Seat struct {
|
||||
IsWinner bool
|
||||
}
|
||||
|
||||
// OpenGame identifies an auto-match game waiting for an opponent whose robot
|
||||
// deadline has passed: the game the matchmaking reaper fills and the variant that
|
||||
// selects the substitute robot.
|
||||
type OpenGame struct {
|
||||
ID uuid.UUID
|
||||
Variant engine.Variant
|
||||
}
|
||||
|
||||
// seatOf returns the seat index of accountID and true, or (0, false) when the
|
||||
// account is not seated.
|
||||
func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
|
||||
|
||||
Reference in New Issue
Block a user