Stage 17: backend defect fixes (nudge code, TG name, robot names/timing, multi-device push, move-duration metric + admin analytics)
- #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.')
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user