Stage 5: robot opponent (pool, seed-derived strategy, move driver, matchmaker substitution)
- internal/robot: durable kind='robot' account pool (migration 00004); every per-game and per-turn choice derived deterministically from the game seed (restart-stable FNV mix); a background move driver; margin targeting (band 1-30, closest-to-band); right-skewed [2,90]min delays (median ~10m); opponent-anchored sleep with +/-3h drift; daytime nudge reply + proactive 12h nudge; friend/chat blocked via profile toggles. - engine.Candidates (decoded ranked plays); game.Candidates + RobotTurns; social.LastNudgeAt. - matchmaker: 10s wait then robot substitution (reaper) + Poll delivery seam. - config (BACKEND_ROBOT_DRIVE_INTERVAL, BACKEND_LOBBY_ROBOT_WAIT, BACKEND_LOBBY_REAPER_INTERVAL); main wiring + boot-time pool provisioning. - metrics: robot account_stats (authoritative balance) + robot_games_finished_total OTel counter + per-finish log. - docs: PLAN, ARCHITECTURE, FUNCTIONAL(+ru), TESTING, README; account.go comment. - tests: robot strategy units, matchmaker reaper/Poll, engine.Candidates; inttest robot full-game / substitution / proactive-nudge.
This commit is contained in:
@@ -426,6 +426,43 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter)}, nil
|
||||
}
|
||||
|
||||
// Candidates returns the to-move player's legal plays for a seated player on
|
||||
// their turn, ranked by descending score. It is the read the robot opponent uses
|
||||
// to choose a move by margin; it spends nothing and mutates no state. It returns
|
||||
// ErrNotAPlayer, ErrFinished or ErrNotYourTurn like the other turn-scoped reads.
|
||||
func (svc *Service) Candidates(ctx context.Context, gameID, accountID uuid.UUID) ([]engine.MoveRecord, error) {
|
||||
pre, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seat, ok := pre.seatOf(accountID)
|
||||
if !ok {
|
||||
return nil, ErrNotAPlayer
|
||||
}
|
||||
if pre.Status != StatusActive {
|
||||
return nil, ErrFinished
|
||||
}
|
||||
if pre.ToMove != seat {
|
||||
return nil, ErrNotYourTurn
|
||||
}
|
||||
|
||||
unlock := svc.locks.lock(gameID)
|
||||
defer unlock()
|
||||
g, err := svc.liveGame(ctx, pre)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.Candidates(), nil
|
||||
}
|
||||
|
||||
// RobotTurns returns the robot driver's view of every active game seating one of
|
||||
// robotIDs. It is the robot scheduler's periodic scan, mirroring the timeout
|
||||
// sweeper's ActiveGames read; the driver derives each robot's deadline from the
|
||||
// returned seed and turn cursor.
|
||||
func (svc *Service) RobotTurns(ctx context.Context, robotIDs []uuid.UUID) ([]RobotTurn, error) {
|
||||
return svc.store.RobotTurns(ctx, robotIDs)
|
||||
}
|
||||
|
||||
// GameState returns a seated player's view of the game: the shared summary plus
|
||||
// their private rack, the bag size and their remaining hint budget.
|
||||
func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) (StateView, error) {
|
||||
|
||||
@@ -346,6 +346,51 @@ func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RobotTurns returns one row per active game seating any of the given accounts,
|
||||
// for the robot scheduler. It joins games to game_players on the robot's seat and
|
||||
// carries the game's turn cursor and bag seed; the driver filters these against
|
||||
// each robot's per-game deadline. An empty id list returns no rows.
|
||||
func (s *Store) RobotTurns(ctx context.Context, ids []uuid.UUID) ([]RobotTurn, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
exprs := make([]postgres.Expression, len(ids))
|
||||
for i, id := range ids {
|
||||
exprs[i] = postgres.UUID(id)
|
||||
}
|
||||
stmt := postgres.SELECT(
|
||||
table.Games.GameID, table.Games.ToMove, table.Games.TurnStartedAt,
|
||||
table.Games.MoveCount, table.Games.Seed,
|
||||
table.GamePlayers.Seat, table.GamePlayers.AccountID,
|
||||
).FROM(
|
||||
table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID)),
|
||||
).WHERE(
|
||||
table.Games.Status.EQ(postgres.String(StatusActive)).
|
||||
AND(table.GamePlayers.AccountID.IN(exprs...)),
|
||||
).ORDER_BY(table.Games.TurnStartedAt.ASC())
|
||||
|
||||
var rows []struct {
|
||||
model.Games
|
||||
model.GamePlayers
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("game: list robot turns: %w", err)
|
||||
}
|
||||
out := make([]RobotTurn, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, RobotTurn{
|
||||
GameID: r.Games.GameID,
|
||||
RobotID: r.GamePlayers.AccountID,
|
||||
RobotSeat: int(r.GamePlayers.Seat),
|
||||
ToMove: int(r.Games.ToMove),
|
||||
TurnStartedAt: r.Games.TurnStartedAt,
|
||||
MoveCount: int(r.Games.MoveCount),
|
||||
Seed: r.Games.Seed,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GameSeed returns the bag seed a game was dealt from, used to replay it. The
|
||||
// seed is server-only state and never travels in the public Game view.
|
||||
func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
|
||||
|
||||
@@ -161,6 +161,21 @@ type HistoryView struct {
|
||||
Moves []HistoryMove
|
||||
}
|
||||
|
||||
// RobotTurn is the robot driver's view of one active game seating a robot: the
|
||||
// seat the robot holds, whose turn it currently is, when that turn started, the
|
||||
// move index and the bag seed. Seed is backend-internal state (never exposed in
|
||||
// the public Game view); the robot derives its deterministic per-game behaviour
|
||||
// from it, so the scheduler stays stateless and restart-safe.
|
||||
type RobotTurn struct {
|
||||
GameID uuid.UUID
|
||||
RobotID uuid.UUID
|
||||
RobotSeat int
|
||||
ToMove int
|
||||
TurnStartedAt time.Time
|
||||
MoveCount int
|
||||
Seed int64
|
||||
}
|
||||
|
||||
// Complaint is a word-check complaint awaiting admin review (Stage 9).
|
||||
type Complaint struct {
|
||||
ID uuid.UUID
|
||||
|
||||
Reference in New Issue
Block a user