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:
@@ -91,15 +91,61 @@ func encodeRedeemResult(r backendclient.RedeemResultResp) []byte {
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeStats builds a StatsView payload.
|
||||
// buildBestMoveTile builds a BestMoveTile table and returns its offset.
|
||||
func buildBestMoveTile(b *flatbuffers.Builder, t backendclient.BestMoveTileResp) flatbuffers.UOffsetT {
|
||||
letter := b.CreateString(t.Letter)
|
||||
fb.BestMoveTileStart(b)
|
||||
fb.BestMoveTileAddLetter(b, letter)
|
||||
fb.BestMoveTileAddValue(b, int32(t.Value))
|
||||
fb.BestMoveTileAddBlank(b, t.Blank)
|
||||
return fb.BestMoveTileEnd(b)
|
||||
}
|
||||
|
||||
// buildBestMove builds a BestMoveView table — its word tile vector first, per the
|
||||
// bottom-up rule — and returns its offset.
|
||||
func buildBestMove(b *flatbuffers.Builder, m backendclient.BestMoveResp) flatbuffers.UOffsetT {
|
||||
tiles := make([]flatbuffers.UOffsetT, len(m.Word))
|
||||
for i, t := range m.Word {
|
||||
tiles[i] = buildBestMoveTile(b, t)
|
||||
}
|
||||
fb.BestMoveViewStartWordVector(b, len(tiles))
|
||||
for i := len(tiles) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(tiles[i])
|
||||
}
|
||||
word := b.EndVector(len(tiles))
|
||||
variant := b.CreateString(m.Variant)
|
||||
fb.BestMoveViewStart(b)
|
||||
fb.BestMoveViewAddVariant(b, variant)
|
||||
fb.BestMoveViewAddScore(b, int32(m.Score))
|
||||
fb.BestMoveViewAddWord(b, word)
|
||||
return fb.BestMoveViewEnd(b)
|
||||
}
|
||||
|
||||
// encodeStats builds a StatsView payload, embedding the per-variant best moves (each a
|
||||
// BestMoveView with its word tiles) when present.
|
||||
func encodeStats(r backendclient.StatsResp) []byte {
|
||||
b := flatbuffers.NewBuilder(64)
|
||||
b := flatbuffers.NewBuilder(256)
|
||||
var bestMoves flatbuffers.UOffsetT
|
||||
if len(r.BestMoves) > 0 {
|
||||
offs := make([]flatbuffers.UOffsetT, len(r.BestMoves))
|
||||
for i, m := range r.BestMoves {
|
||||
offs[i] = buildBestMove(b, m)
|
||||
}
|
||||
fb.StatsViewStartBestMovesVector(b, len(offs))
|
||||
for i := len(offs) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(offs[i])
|
||||
}
|
||||
bestMoves = b.EndVector(len(offs))
|
||||
}
|
||||
fb.StatsViewStart(b)
|
||||
fb.StatsViewAddWins(b, int32(r.Wins))
|
||||
fb.StatsViewAddLosses(b, int32(r.Losses))
|
||||
fb.StatsViewAddDraws(b, int32(r.Draws))
|
||||
fb.StatsViewAddMaxGamePoints(b, int32(r.MaxGamePoints))
|
||||
fb.StatsViewAddMaxWordPoints(b, int32(r.MaxWordPoints))
|
||||
if len(r.BestMoves) > 0 {
|
||||
fb.StatsViewAddBestMoves(b, bestMoves)
|
||||
}
|
||||
b.Finish(fb.StatsViewEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
@@ -194,7 +194,11 @@ func TestStatsRoundTrip(t *testing.T) {
|
||||
if r.URL.Path != "/api/v1/user/stats" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"wins":5,"losses":3,"draws":1,"max_game_points":420,"max_word_points":90}`))
|
||||
_, _ = w.Write([]byte(`{"wins":5,"losses":3,"draws":1,"max_game_points":420,"max_word_points":90,` +
|
||||
`"best_moves":[{"variant":"scrabble_en","score":90,"word":[` +
|
||||
`{"letter":"c","value":3,"blank":false},` +
|
||||
`{"letter":"a","value":0,"blank":true},` +
|
||||
`{"letter":"t","value":1,"blank":false}]}]}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -208,6 +212,20 @@ func TestStatsRoundTrip(t *testing.T) {
|
||||
if st.Wins() != 5 || st.Losses() != 3 || st.Draws() != 1 || st.MaxGamePoints() != 420 || st.MaxWordPoints() != 90 {
|
||||
t.Fatalf("stats decoded wrong: %+v", st)
|
||||
}
|
||||
if st.BestMovesLength() != 1 {
|
||||
t.Fatalf("best moves length = %d, want 1", st.BestMovesLength())
|
||||
}
|
||||
var bm fb.BestMoveView
|
||||
st.BestMoves(&bm, 0)
|
||||
if string(bm.Variant()) != "scrabble_en" || bm.Score() != 90 || bm.WordLength() != 3 {
|
||||
t.Fatalf("best move decoded wrong: variant=%q score=%d wordLen=%d", bm.Variant(), bm.Score(), bm.WordLength())
|
||||
}
|
||||
// The middle tile is a blank: it carries its designated letter but scores 0.
|
||||
var tile fb.BestMoveTile
|
||||
bm.Word(&tile, 1)
|
||||
if string(tile.Letter()) != "a" || tile.Value() != 0 || !tile.Blank() {
|
||||
t.Fatalf("blank tile decoded wrong: letter=%q value=%d blank=%v", tile.Letter(), tile.Value(), tile.Blank())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGcgRoundTrip(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user