feat(stats): show the best move word per game variant
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
Replace the single "best move" number on the statistics screen with a
full-width per-variant breakdown: the highest-scoring play in each variant
the player has played, drawn as game tiles (a wildcard shows its letter but
no value), with the words and scores right-aligned to shared edges.
- backend: new account_best_move table (PK account_id+variant) keeping the
main word as JSON tiles {letter,value,blank}; captured at game finish in
buildStats (blank flags taken from every placed blank — equivalent to the
final board), upserted in the finish transaction and replaced only by a
strictly higher-scoring play. Guest/honest-AI games still record nothing.
GetStats + statsDTO expose best_moves.
- wire: StatsView gains best_moves:[BestMoveView{variant,score,word:[BestMoveTile]}]
(trailing, backward-compatible); gateway encodeStats + UI codec updated.
- ui: new WordTiles component (board's tile look, fixed px size); Stats.svelte
drops the maxWord card and adds the full-width best-move card (catalogue
order, empty variants omitted).
- docs: ARCHITECTURE §9 + schema, FUNCTIONAL (+ru), UI_DESIGN.
Tests: mainWordTiles unit + buildStats end-to-end (inttest) + gateway and UI
codec round-trips (incl. a blank tile) + e2e.
This commit is contained in:
@@ -1419,19 +1419,41 @@ func replayMove(g *engine.Game, mv HistoryMove) error {
|
||||
}
|
||||
|
||||
// buildStats derives each seat's statistics contribution from a finished game:
|
||||
// win/loss/draw from the (resignation-aware) winner, the final score, and the
|
||||
// best single-move score from the log.
|
||||
// win/loss/draw from the (resignation-aware) winner, the final score, and the best
|
||||
// single play from the log — its score and, for the per-variant breakdown, its main
|
||||
// word as rendering tiles. Blank flags are taken from every blank ever placed (so a
|
||||
// blank laid by an earlier move and embedded in the best word is honoured), which is
|
||||
// equivalent to reading the final board since a placed tile never moves.
|
||||
func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
||||
res := g.Result()
|
||||
best := make(map[int]int)
|
||||
bestRec := make(map[int]engine.MoveRecord)
|
||||
blanks := make(map[[2]int]bool)
|
||||
for _, rec := range g.Log() {
|
||||
if rec.Action == engine.ActionPlay && rec.Score > best[rec.Player] {
|
||||
best[rec.Player] = rec.Score
|
||||
if rec.Action != engine.ActionPlay {
|
||||
continue
|
||||
}
|
||||
for _, t := range rec.Tiles {
|
||||
if t.Blank {
|
||||
blanks[[2]int{t.Row, t.Col}] = true
|
||||
}
|
||||
}
|
||||
if cur, ok := bestRec[rec.Player]; !ok || rec.Score > cur.Score {
|
||||
bestRec[rec.Player] = rec
|
||||
}
|
||||
}
|
||||
variant := g.Variant().String()
|
||||
values := letterValues(g.Variant())
|
||||
out := make([]statDelta, 0, len(seats))
|
||||
for _, s := range seats {
|
||||
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat), wordPoints: best[s.Seat]}
|
||||
d := statDelta{accountID: s.AccountID, gamePoints: g.Score(s.Seat)}
|
||||
if rec, ok := bestRec[s.Seat]; ok {
|
||||
d.wordPoints = rec.Score
|
||||
if rec.Score > 0 {
|
||||
d.bestVariant = variant
|
||||
d.bestScore = rec.Score
|
||||
d.bestTiles = mainWordTiles(rec, blanks, values)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case res.Winner < 0:
|
||||
d.draws = 1
|
||||
@@ -1445,6 +1467,49 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
|
||||
return out
|
||||
}
|
||||
|
||||
// letterValues builds a lower-cased letter -> tile value lookup for a variant from the
|
||||
// engine's alphabet table, so a best-move word can be rendered with per-tile values on a
|
||||
// screen (statistics) that has not cached the variant's alphabet. It is empty for an
|
||||
// unrecognised variant, leaving every value zero.
|
||||
func letterValues(v engine.Variant) map[string]int {
|
||||
table, err := engine.AlphabetTable(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]int, len(table))
|
||||
for _, e := range table {
|
||||
m[strings.ToLower(e.Letter)] = e.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// mainWordTiles decodes a play's main word into rendering tiles: each letter with its
|
||||
// tile value (0 for a blank) and blank flag. blanks is the set of board coordinates a
|
||||
// blank was ever placed on; values maps a lower-cased letter to its tile value. It walks
|
||||
// the word from its first-letter coordinate along the play's orientation.
|
||||
func mainWordTiles(rec engine.MoveRecord, blanks map[[2]int]bool, values map[string]int) []account.BestMoveTile {
|
||||
if len(rec.Words) == 0 {
|
||||
return nil
|
||||
}
|
||||
dr, dc := 0, 1
|
||||
if rec.Dir == engine.Vertical {
|
||||
dr, dc = 1, 0
|
||||
}
|
||||
letters := []rune(rec.Words[0])
|
||||
out := make([]account.BestMoveTile, len(letters))
|
||||
for i, r := range letters {
|
||||
row, col := rec.MainRow+i*dr, rec.MainCol+i*dc
|
||||
blank := blanks[[2]int{row, col}]
|
||||
letter := string(r)
|
||||
value := 0
|
||||
if !blank {
|
||||
value = values[strings.ToLower(letter)]
|
||||
}
|
||||
out[i] = account.BestMoveTile{Letter: letter, Value: value, Blank: blank}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nonGuestSeats filters out guest seats so the finish-time statistics are
|
||||
// recomputed for durable non-guest accounts only — guests never accrue
|
||||
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.
|
||||
|
||||
Reference in New Issue
Block a user