package game import ( "context" "fmt" "strings" "github.com/google/uuid" ) // A move's "duration" is the think time from the previous move's commit (the moment // the turn started) to this move's commit. Only play/pass/exchange moves count; // timeouts and resignations are not think time. The very first move of a game has no // previous move, so its baseline is the game's creation time. The figures are derived // from the move journal (game_moves.created_at), so no schema change is needed. // // timedMovesCTE is the shared subquery yielding (account, game, ordinal, seconds) for // every timed move; the two reports aggregate it differently. const timedMovesCTE = ` SELECT gp.account_id AS aid, m.game_id AS gid, ROW_NUMBER() OVER (PARTITION BY m.game_id ORDER BY m.seq) AS ord, EXTRACT(EPOCH FROM (m.created_at - COALESCE(prev.created_at, g.created_at))) AS secs FROM backend.game_moves m JOIN backend.games g ON g.game_id = m.game_id LEFT JOIN backend.game_moves prev ON prev.game_id = m.game_id AND prev.seq = m.seq - 1 JOIN backend.game_players gp ON gp.game_id = m.game_id AND gp.seat = m.seat WHERE m.action IN ('play', 'pass', 'exchange')` // MoveDurationStat is the min, max and mean per-move think time (in seconds) for an // account across all its games, with the number of timed moves counted. type MoveDurationStat struct { MinSecs float64 MaxSecs float64 AvgSecs float64 Moves int } // MoveDurationStats returns the move-duration summary for each of accountIDs that has // at least one timed move; accounts with none are absent from the map. It powers the // admin user-list columns. The scan over the journal is acceptable for the low-traffic // console; per-human analysis is the authoritative use (the live metric aggregates all // seats including robots). func (s *Store) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) { if len(accountIDs) == 0 { return map[uuid.UUID]MoveDurationStat{}, nil } q := `WITH d AS (` + timedMovesCTE + `) SELECT aid, MIN(secs), MAX(secs), AVG(secs), COUNT(*) FROM d WHERE aid = ANY($1::uuid[]) GROUP BY aid` rows, err := s.db.QueryContext(ctx, q, uuidArrayLiteral(accountIDs)) if err != nil { return nil, fmt.Errorf("game: move-duration stats: %w", err) } defer rows.Close() out := make(map[uuid.UUID]MoveDurationStat, len(accountIDs)) for rows.Next() { var id uuid.UUID var st MoveDurationStat if err := rows.Scan(&id, &st.MinSecs, &st.MaxSecs, &st.AvgSecs, &st.Moves); err != nil { return nil, fmt.Errorf("game: scan move-duration stat: %w", err) } out[id] = st } return out, rows.Err() } // OrdinalDuration is the min/max/mean think time (seconds) at an account's k-th move // (Ordinal) across all its games. type OrdinalDuration struct { Ordinal int MinSecs float64 MaxSecs float64 AvgSecs float64 } // MoveDurationByOrdinal returns the account's per-move-number think-time summary, // ordered by move number, for the admin user-detail chart. The ordinal counts the // account's own moves within each game (its 1st, 2nd, … move). func (s *Store) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) { q := `WITH d AS (` + timedMovesCTE + ` AND gp.account_id = $1) SELECT ord, MIN(secs), MAX(secs), AVG(secs) FROM d GROUP BY ord ORDER BY ord` rows, err := s.db.QueryContext(ctx, q, accountID) if err != nil { return nil, fmt.Errorf("game: move-duration by ordinal: %w", err) } defer rows.Close() var out []OrdinalDuration for rows.Next() { var od OrdinalDuration if err := rows.Scan(&od.Ordinal, &od.MinSecs, &od.MaxSecs, &od.AvgSecs); err != nil { return nil, fmt.Errorf("game: scan ordinal duration: %w", err) } out = append(out, od) } return out, rows.Err() } // uuidArrayLiteral renders ids as a Postgres array literal ("{u1,u2,…}") for an // ANY($1::uuid[]) parameter. UUIDs are fixed-format, so the literal is injection-safe. func uuidArrayLiteral(ids []uuid.UUID) string { ss := make([]string, len(ids)) for i, id := range ids { ss[i] = id.String() } return "{" + strings.Join(ss, ",") + "}" } // MoveDurationStats exposes the store report to the admin console handlers. func (svc *Service) MoveDurationStats(ctx context.Context, accountIDs []uuid.UUID) (map[uuid.UUID]MoveDurationStat, error) { return svc.store.MoveDurationStats(ctx, accountIDs) } // MoveDurationByOrdinal exposes the per-move-number report to the admin console. func (svc *Service) MoveDurationByOrdinal(ctx context.Context, accountID uuid.UUID) ([]OrdinalDuration, error) { return svc.store.MoveDurationByOrdinal(ctx, accountID) }