635f2fd9fc
- #3 nudge-on-own-turn: distinct result code nudge_own_turn + i18n (was reused 'not_your_turn') - #2 sanitize connector registration name to the editable format; Player/Игрок-XXXXX fallback - #5 variant-aware robot name pools (composed full/colloquial first + surname forms; ru gets <=20% latin) - #4 move-number-aware robot move timing (early 1-5min -> late 10-90min, skew k=4) - #7 emit move event to the actor too (multi-device sync); opponent_moved stays in-app only - #1 live game_move_duration{variant,phase} histogram + admin console per-user min/avg/max columns and an inline-SVG move-time-by-move-number chart (offline from the journal) - ProvisionRobot bypasses editor name validation (system names like 'Peter J.')
117 lines
4.6 KiB
Go
117 lines
4.6 KiB
Go
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)
|
|
}
|