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:
@@ -101,3 +101,17 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||
.actions form { margin: 0; }
|
||||
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
|
||||
|
||||
/* Move-timing chart: a server-rendered, script-free inline SVG line chart. */
|
||||
.chart { width: 100%; height: auto; max-width: 680px; margin-top: 0.4rem; }
|
||||
.chart .axis { stroke: var(--line); stroke-width: 1; }
|
||||
.chart .grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.6; }
|
||||
.chart .lbl { fill: var(--ink-dim); font-size: 11px; }
|
||||
.chart .ln { fill: none; stroke-width: 1.5; }
|
||||
.chart .ln-min { stroke: var(--ok); }
|
||||
.chart .ln-avg { stroke: var(--accent); }
|
||||
.chart .ln-max { stroke: var(--danger); }
|
||||
.lg { font-weight: 600; }
|
||||
.lg-min { color: var(--ok); }
|
||||
.lg-avg { color: var(--accent); }
|
||||
.lg-max { color: var(--danger); }
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
cases := map[float64]string{
|
||||
0: "0s", 30: "30s", 59: "59s", 60: "1m", 150: "3m", 3600: "1h", 3660: "1h1m", 7800: "2h10m",
|
||||
}
|
||||
for secs, want := range cases {
|
||||
if got := FormatDuration(secs); got != want {
|
||||
t.Errorf("FormatDuration(%v) = %q, want %q", secs, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveDurationChartEmpty(t *testing.T) {
|
||||
if got := MoveDurationChart(nil); got != "" {
|
||||
t.Errorf("empty chart = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveDurationChart(t *testing.T) {
|
||||
pts := []ChartPoint{{Ordinal: 1, Min: 5, Max: 20, Avg: 10}, {Ordinal: 2, Min: 8, Max: 40, Avg: 18}, {Ordinal: 3, Min: 12, Max: 90, Avg: 30}}
|
||||
svg := string(MoveDurationChart(pts))
|
||||
for _, want := range []string{"<svg", "ln-min", "ln-avg", "ln-max", "</svg>"} {
|
||||
if !strings.Contains(svg, want) {
|
||||
t.Errorf("chart missing %q\n%s", want, svg)
|
||||
}
|
||||
}
|
||||
if n := strings.Count(svg, "<polyline"); n != 3 {
|
||||
t.Errorf("polylines = %d, want 3", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestXTicks(t *testing.T) {
|
||||
cases := map[int][]int{1: {1}, 2: {1, 2}, 3: {1, 2, 3}, 10: {1, 5, 10}}
|
||||
for maxOrd, want := range cases {
|
||||
got := xTicks(maxOrd)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("xTicks(%d) = %v, want %v", maxOrd, got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("xTicks(%d) = %v, want %v", maxOrd, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,12 @@
|
||||
{{else}}<p class="note">no statistics</p>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{if .MoveChart}}
|
||||
<section class="panel"><h2>Move timing</h2>
|
||||
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
|
||||
{{.MoveChart}}
|
||||
</section>
|
||||
{{end}}
|
||||
<section class="panel"><h2>Identities</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<h1>Users</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th></tr></thead>
|
||||
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th><th title="per-move think time across all games">Move min</th><th>avg</th><th>max</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
@@ -11,9 +11,10 @@
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{.Language}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
{{if .HasMoveStats}}<td>{{.MoveMin}}</td><td>{{.MoveAvg}}</td><td>{{.MoveMax}}</td>{{else}}<td colspan="3"><span class="note">—</span></td>{{end}}
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5"><span class="note">no users</span></td></tr>
|
||||
<tr><td colspan="8"><span class="note">no users</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package adminconsole
|
||||
|
||||
import "html/template"
|
||||
|
||||
// The *View types are the display models the gin handlers fill and the templates
|
||||
// render. Time values are pre-formatted to strings by the handlers so the
|
||||
// templates stay logic-free.
|
||||
@@ -50,14 +52,19 @@ type UsersView struct {
|
||||
Pager Pager
|
||||
}
|
||||
|
||||
// UserRow is one account row in the list.
|
||||
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
||||
// pre-formatted move-duration summary (empty when it has no timed move).
|
||||
type UserRow struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
Kind string
|
||||
Language string
|
||||
Guest bool
|
||||
CreatedAt string
|
||||
ID string
|
||||
DisplayName string
|
||||
Kind string
|
||||
Language string
|
||||
Guest bool
|
||||
CreatedAt string
|
||||
HasMoveStats bool
|
||||
MoveMin string
|
||||
MoveAvg string
|
||||
MoveMax string
|
||||
}
|
||||
|
||||
// UserDetailView is one account with its stats, identities and recent games.
|
||||
@@ -80,6 +87,9 @@ type UserDetailView struct {
|
||||
Games []GameRow
|
||||
TelegramID string
|
||||
ConnectorEnabled bool
|
||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||
// time (min/mean/max), empty when the account has no timed move.
|
||||
MoveChart template.HTML
|
||||
}
|
||||
|
||||
// StatsRow is an account's lifetime statistics.
|
||||
|
||||
Reference in New Issue
Block a user