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.')
109 lines
3.4 KiB
Go
109 lines
3.4 KiB
Go
package adminconsole
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ChartPoint is one move-number sample of the move-duration chart: the min, mean and
|
|
// max think time (seconds) the account took on its Ordinal-th move across its games.
|
|
type ChartPoint struct {
|
|
Ordinal int
|
|
Min float64
|
|
Max float64
|
|
Avg float64
|
|
}
|
|
|
|
// FormatDuration renders a think-time in seconds as a compact human string
|
|
// ("45s", "3m", "1h5m"), for the user-list columns and the chart's Y labels.
|
|
func FormatDuration(secs float64) string {
|
|
d := time.Duration(secs * float64(time.Second))
|
|
switch {
|
|
case d < time.Minute:
|
|
return fmt.Sprintf("%ds", int(d.Seconds()+0.5))
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm", int(d.Minutes()+0.5))
|
|
default:
|
|
h := int(d.Hours())
|
|
if m := int(d.Minutes()) - h*60; m > 0 {
|
|
return fmt.Sprintf("%dh%dm", h, m)
|
|
}
|
|
return fmt.Sprintf("%dh", h)
|
|
}
|
|
}
|
|
|
|
// MoveDurationChart renders the per-move-number think-time chart as a self-contained,
|
|
// script-free inline SVG with three series (min, mean, max). The coordinates and
|
|
// labels are all derived from numeric data, so the result is safe template.HTML.
|
|
// An empty series renders nothing.
|
|
func MoveDurationChart(points []ChartPoint) template.HTML {
|
|
if len(points) == 0 {
|
|
return ""
|
|
}
|
|
const (
|
|
w, h = 640, 240
|
|
padL = 46
|
|
padR = 12
|
|
padT = 10
|
|
padB = 28
|
|
)
|
|
maxOrd := points[len(points)-1].Ordinal
|
|
if maxOrd < 1 {
|
|
maxOrd = 1
|
|
}
|
|
var maxY float64
|
|
for _, p := range points {
|
|
maxY = max(maxY, p.Max)
|
|
}
|
|
if maxY <= 0 {
|
|
maxY = 1
|
|
}
|
|
xOf := func(ord int) float64 {
|
|
if maxOrd == 1 {
|
|
return padL
|
|
}
|
|
return padL + (float64(ord-1)/float64(maxOrd-1))*(w-padL-padR)
|
|
}
|
|
yOf := func(v float64) float64 { return padT + (1-v/maxY)*(h-padT-padB) }
|
|
line := func(get func(ChartPoint) float64) string {
|
|
pts := make([]string, len(points))
|
|
for i, p := range points {
|
|
pts[i] = fmt.Sprintf("%.1f,%.1f", xOf(p.Ordinal), yOf(get(p)))
|
|
}
|
|
return strings.Join(pts, " ")
|
|
}
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, `<svg viewBox="0 0 %d %d" class="chart" role="img" aria-label="Move duration by move number">`, w, h)
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%.1f" class="axis"/>`, padL, padT, padL, float64(h-padB))
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="axis"/>`, padL, float64(h-padB), w-padR, float64(h-padB))
|
|
for _, frac := range []float64{0, 0.5, 1} {
|
|
v := maxY * frac
|
|
y := yOf(v)
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="grid"/>`, padL, y, w-padR, y)
|
|
fmt.Fprintf(&b, `<text x="%d" y="%.1f" class="lbl" text-anchor="end">%s</text>`, padL-5, y+3, FormatDuration(v))
|
|
}
|
|
for _, ord := range xTicks(maxOrd) {
|
|
fmt.Fprintf(&b, `<text x="%.1f" y="%d" class="lbl" text-anchor="middle">%d</text>`, xOf(ord), h-padB+15, ord)
|
|
}
|
|
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-max"/>`, line(func(p ChartPoint) float64 { return p.Max }))
|
|
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-avg"/>`, line(func(p ChartPoint) float64 { return p.Avg }))
|
|
fmt.Fprintf(&b, `<polyline points="%s" class="ln ln-min"/>`, line(func(p ChartPoint) float64 { return p.Min }))
|
|
b.WriteString(`</svg>`)
|
|
return template.HTML(b.String())
|
|
}
|
|
|
|
// xTicks returns up to three distinct ordinal labels for the chart's X axis.
|
|
func xTicks(maxOrd int) []int {
|
|
if maxOrd <= 2 {
|
|
out := make([]int, 0, maxOrd)
|
|
for i := 1; i <= maxOrd; i++ {
|
|
out = append(out, i)
|
|
}
|
|
return out
|
|
}
|
|
return []int{1, (maxOrd + 1) / 2, maxOrd}
|
|
}
|