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:
@@ -53,11 +53,15 @@ test('invitations: the lobby shows an invitation and accepting clears it', async
|
||||
await expect(page.getByText(/From Kaya/)).toBeHidden();
|
||||
});
|
||||
|
||||
test('stats screen shows the metrics', async ({ page }) => {
|
||||
test('stats screen shows the metrics and the per-variant best move', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Stats/ }).click();
|
||||
await expect(page.getByText('Win rate')).toBeVisible();
|
||||
await expect(page.getByText('Best move')).toBeVisible();
|
||||
// The best move breaks down per played variant, each row labelled by the variant and
|
||||
// ending in the play's score (the word itself renders as game tiles).
|
||||
await expect(page.getByText('Scrabble', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('134')).toBeVisible();
|
||||
});
|
||||
|
||||
test('settings hub: tabs switch in place and back returns to the lobby', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
// A best-move word drawn as a row of game tiles, mirroring the board's placed-tile
|
||||
// look (letter top-left, point value bottom-right) at a small fixed size. A blank tile
|
||||
// shows its letter but no value, exactly as on the board. Letters are upper-cased for
|
||||
// display. The tile values ride on each tile, so this renders without the variant's
|
||||
// alphabet table (which the statistics screen has not cached).
|
||||
import type { BestMoveTile } from '../lib/model';
|
||||
|
||||
let { word }: { word: BestMoveTile[] } = $props();
|
||||
|
||||
const label = $derived(word.map((t) => t.letter).join('').toUpperCase());
|
||||
</script>
|
||||
|
||||
<span class="word" aria-label={label}>
|
||||
{#each word as tile, i (i)}
|
||||
<span class="tile" class:blank={tile.blank} aria-hidden="true">
|
||||
<span class="letter">{tile.letter.toUpperCase()}</span>
|
||||
{#if !tile.blank}<span class="val">{tile.value}</span>{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.word {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.tile {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: var(--tile-bg);
|
||||
color: var(--tile-text);
|
||||
border-radius: 3px;
|
||||
box-shadow: inset 0 -2px 0 var(--tile-edge);
|
||||
}
|
||||
.letter {
|
||||
position: absolute;
|
||||
top: 6%;
|
||||
left: 11%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.val {
|
||||
position: absolute;
|
||||
right: 8%;
|
||||
bottom: 3%;
|
||||
font-size: 7px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,8 @@ export { Ack } from './scrabblefb/ack.js';
|
||||
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
||||
export { BannerCampaign } from './scrabblefb/banner-campaign.js';
|
||||
export { BannerInfo } from './scrabblefb/banner-info.js';
|
||||
export { BestMoveTile } from './scrabblefb/best-move-tile.js';
|
||||
export { BestMoveView } from './scrabblefb/best-move-view.js';
|
||||
export { BlockList } from './scrabblefb/block-list.js';
|
||||
export { BlockStatus } from './scrabblefb/block-status.js';
|
||||
export { ChatList } from './scrabblefb/chat-list.js';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class BestMoveTile {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):BestMoveTile {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsBestMoveTile(bb:flatbuffers.ByteBuffer, obj?:BestMoveTile):BestMoveTile {
|
||||
return (obj || new BestMoveTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsBestMoveTile(bb:flatbuffers.ByteBuffer, obj?:BestMoveTile):BestMoveTile {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new BestMoveTile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
letter():string|null
|
||||
letter(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
letter(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
value():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
blank():boolean {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
static startBestMoveTile(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
}
|
||||
|
||||
static addLetter(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, letterOffset, 0);
|
||||
}
|
||||
|
||||
static addValue(builder:flatbuffers.Builder, value:number) {
|
||||
builder.addFieldInt32(1, value, 0);
|
||||
}
|
||||
|
||||
static addBlank(builder:flatbuffers.Builder, blank:boolean) {
|
||||
builder.addFieldInt8(2, +blank, +false);
|
||||
}
|
||||
|
||||
static endBestMoveTile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createBestMoveTile(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset, value:number, blank:boolean):flatbuffers.Offset {
|
||||
BestMoveTile.startBestMoveTile(builder);
|
||||
BestMoveTile.addLetter(builder, letterOffset);
|
||||
BestMoveTile.addValue(builder, value);
|
||||
BestMoveTile.addBlank(builder, blank);
|
||||
return BestMoveTile.endBestMoveTile(builder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { BestMoveTile } from '../scrabblefb/best-move-tile.js';
|
||||
|
||||
|
||||
export class BestMoveView {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):BestMoveView {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsBestMoveView(bb:flatbuffers.ByteBuffer, obj?:BestMoveView):BestMoveView {
|
||||
return (obj || new BestMoveView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsBestMoveView(bb:flatbuffers.ByteBuffer, obj?:BestMoveView):BestMoveView {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new BestMoveView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
variant():string|null
|
||||
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
variant(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
score():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
word(index: number, obj?:BestMoveTile):BestMoveTile|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? (obj || new BestMoveTile()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
wordLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startBestMoveView(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
}
|
||||
|
||||
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, variantOffset, 0);
|
||||
}
|
||||
|
||||
static addScore(builder:flatbuffers.Builder, score:number) {
|
||||
builder.addFieldInt32(1, score, 0);
|
||||
}
|
||||
|
||||
static addWord(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, wordOffset, 0);
|
||||
}
|
||||
|
||||
static createWordVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startWordVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endBestMoveView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createBestMoveView(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, score:number, wordOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
BestMoveView.startBestMoveView(builder);
|
||||
BestMoveView.addVariant(builder, variantOffset);
|
||||
BestMoveView.addScore(builder, score);
|
||||
BestMoveView.addWord(builder, wordOffset);
|
||||
return BestMoveView.endBestMoveView(builder);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { BestMoveView } from '../scrabblefb/best-move-view.js';
|
||||
|
||||
|
||||
export class StatsView {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
@@ -45,8 +48,18 @@ maxWordPoints():number {
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
bestMoves(index: number, obj?:BestMoveView):BestMoveView|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||
return offset ? (obj || new BestMoveView()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
bestMovesLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 14);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startStatsView(builder:flatbuffers.Builder) {
|
||||
builder.startObject(5);
|
||||
builder.startObject(6);
|
||||
}
|
||||
|
||||
static addWins(builder:flatbuffers.Builder, wins:number) {
|
||||
@@ -69,18 +82,35 @@ static addMaxWordPoints(builder:flatbuffers.Builder, maxWordPoints:number) {
|
||||
builder.addFieldInt32(4, maxWordPoints, 0);
|
||||
}
|
||||
|
||||
static addBestMoves(builder:flatbuffers.Builder, bestMovesOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(5, bestMovesOffset, 0);
|
||||
}
|
||||
|
||||
static createBestMovesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startBestMovesVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endStatsView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number):flatbuffers.Offset {
|
||||
static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number, bestMovesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
StatsView.startStatsView(builder);
|
||||
StatsView.addWins(builder, wins);
|
||||
StatsView.addLosses(builder, losses);
|
||||
StatsView.addDraws(builder, draws);
|
||||
StatsView.addMaxGamePoints(builder, maxGamePoints);
|
||||
StatsView.addMaxWordPoints(builder, maxWordPoints);
|
||||
StatsView.addBestMoves(builder, bestMovesOffset);
|
||||
return StatsView.endStatsView(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,53 @@ describe('codec', () => {
|
||||
draws: 1,
|
||||
maxGamePoints: 420,
|
||||
maxWordPoints: 90,
|
||||
bestMoves: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes a StatsView carrying a per-variant best move with a blank tile', () => {
|
||||
const b = new Builder(256);
|
||||
// Word "ca" where the 'a' is a blank: it carries its letter but scores 0.
|
||||
const cLetter = b.createString('c');
|
||||
fb.BestMoveTile.startBestMoveTile(b);
|
||||
fb.BestMoveTile.addLetter(b, cLetter);
|
||||
fb.BestMoveTile.addValue(b, 3);
|
||||
fb.BestMoveTile.addBlank(b, false);
|
||||
const tileC = fb.BestMoveTile.endBestMoveTile(b);
|
||||
const aLetter = b.createString('a');
|
||||
fb.BestMoveTile.startBestMoveTile(b);
|
||||
fb.BestMoveTile.addLetter(b, aLetter);
|
||||
fb.BestMoveTile.addValue(b, 0);
|
||||
fb.BestMoveTile.addBlank(b, true);
|
||||
const tileA = fb.BestMoveTile.endBestMoveTile(b);
|
||||
const word = fb.BestMoveView.createWordVector(b, [tileC, tileA]);
|
||||
const variant = b.createString('scrabble_en');
|
||||
fb.BestMoveView.startBestMoveView(b);
|
||||
fb.BestMoveView.addVariant(b, variant);
|
||||
fb.BestMoveView.addScore(b, 90);
|
||||
fb.BestMoveView.addWord(b, word);
|
||||
const bm = fb.BestMoveView.endBestMoveView(b);
|
||||
const bestMoves = fb.StatsView.createBestMovesVector(b, [bm]);
|
||||
fb.StatsView.startStatsView(b);
|
||||
fb.StatsView.addWins(b, 7);
|
||||
fb.StatsView.addBestMoves(b, bestMoves);
|
||||
b.finish(fb.StatsView.endStatsView(b));
|
||||
expect(decodeStats(b.asUint8Array())).toEqual({
|
||||
wins: 7,
|
||||
losses: 0,
|
||||
draws: 0,
|
||||
maxGamePoints: 0,
|
||||
maxWordPoints: 0,
|
||||
bestMoves: [
|
||||
{
|
||||
variant: 'scrabble_en',
|
||||
score: 90,
|
||||
word: [
|
||||
{ letter: 'c', value: 3, blank: false },
|
||||
{ letter: 'a', value: 0, blank: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
AccountRef,
|
||||
Banner,
|
||||
BannerCampaign,
|
||||
BestMove,
|
||||
BestMoveTile,
|
||||
BlockStatus,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
@@ -742,12 +744,24 @@ export function decodeRedeemResult(buf: Uint8Array): AccountRef {
|
||||
|
||||
export function decodeStats(buf: Uint8Array): Stats {
|
||||
const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf));
|
||||
const bestMoves: BestMove[] = [];
|
||||
for (let i = 0; i < v.bestMovesLength(); i++) {
|
||||
const m = v.bestMoves(i);
|
||||
if (!m) continue;
|
||||
const word: BestMoveTile[] = [];
|
||||
for (let j = 0; j < m.wordLength(); j++) {
|
||||
const t = m.word(j);
|
||||
if (t) word.push({ letter: s(t.letter()), value: t.value(), blank: t.blank() });
|
||||
}
|
||||
bestMoves.push({ variant: s(m.variant()) as Variant, score: m.score(), word });
|
||||
}
|
||||
return {
|
||||
wins: v.wins(),
|
||||
losses: v.losses(),
|
||||
draws: v.draws(),
|
||||
maxGamePoints: v.maxGamePoints(),
|
||||
maxWordPoints: v.maxWordPoints(),
|
||||
bestMoves,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+39
-1
@@ -50,7 +50,45 @@ export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Ka
|
||||
|
||||
export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }];
|
||||
|
||||
export const MOCK_STATS: Stats = { wins: 7, losses: 4, draws: 1, maxGamePoints: 421, maxWordPoints: 95 };
|
||||
export const MOCK_STATS: Stats = {
|
||||
wins: 7,
|
||||
losses: 4,
|
||||
draws: 1,
|
||||
maxGamePoints: 421,
|
||||
maxWordPoints: 134,
|
||||
// Letters are lower-cased as the backend emits them; the tile renderer upper-cases for
|
||||
// display. The 'd' in "wonderful" is a blank (value 0) to exercise wildcard rendering.
|
||||
// Erudit is absent on purpose, so the screen demonstrates skipping a not-yet-played variant.
|
||||
bestMoves: [
|
||||
{
|
||||
variant: 'scrabble_en',
|
||||
score: 134,
|
||||
word: [
|
||||
{ letter: 'w', value: 4, blank: false },
|
||||
{ letter: 'o', value: 1, blank: false },
|
||||
{ letter: 'n', value: 1, blank: false },
|
||||
{ letter: 'd', value: 0, blank: true },
|
||||
{ letter: 'e', value: 1, blank: false },
|
||||
{ letter: 'r', value: 1, blank: false },
|
||||
{ letter: 'f', value: 4, blank: false },
|
||||
{ letter: 'u', value: 1, blank: false },
|
||||
{ letter: 'l', value: 1, blank: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
variant: 'scrabble_ru',
|
||||
score: 88,
|
||||
word: [
|
||||
{ letter: 'с', value: 1, blank: false },
|
||||
{ letter: 'ъ', value: 10, blank: false },
|
||||
{ letter: 'ё', value: 4, blank: false },
|
||||
{ letter: 'м', value: 2, blank: false },
|
||||
{ letter: 'к', value: 2, blank: false },
|
||||
{ letter: 'а', value: 1, blank: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function mockInvitations(): Invitation[] {
|
||||
return [
|
||||
|
||||
+21
-1
@@ -206,13 +206,33 @@ export interface FriendCode {
|
||||
expiresAtUnix: number;
|
||||
}
|
||||
|
||||
/** A durable account's lifetime statistics. */
|
||||
/** One letter cell of a best-move word: its display letter, its tile value (0 for a
|
||||
* blank) and whether it is a blank — enough to render it as a game tile without the
|
||||
* variant's alphabet table. */
|
||||
export interface BestMoveTile {
|
||||
letter: string;
|
||||
value: number;
|
||||
blank: boolean;
|
||||
}
|
||||
|
||||
/** An account's highest-scoring single play within one variant: the variant, the play's
|
||||
* total score and its main word as ordered tiles. */
|
||||
export interface BestMove {
|
||||
variant: Variant;
|
||||
score: number;
|
||||
word: BestMoveTile[];
|
||||
}
|
||||
|
||||
/** A durable account's lifetime statistics. bestMoves breaks the best move down per
|
||||
* variant (with the word itself); it is empty for an account with no recorded play and
|
||||
* lists only variants the account has played. */
|
||||
export interface Stats {
|
||||
wins: number;
|
||||
losses: number;
|
||||
draws: number;
|
||||
maxGamePoints: number;
|
||||
maxWordPoints: number;
|
||||
bestMoves: BestMove[];
|
||||
}
|
||||
|
||||
/** Settings the inviter chooses for a friend game. */
|
||||
|
||||
@@ -8,6 +8,7 @@ const s = (wins: number, losses: number, draws: number): Stats => ({
|
||||
draws,
|
||||
maxGamePoints: 0,
|
||||
maxWordPoints: 0,
|
||||
bestMoves: [],
|
||||
});
|
||||
|
||||
describe('stats', () => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import WordTiles from '../components/WordTiles.svelte';
|
||||
import { app, handleError } from '../lib/app.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import { gamesPlayed, winRate } from '../lib/stats';
|
||||
import type { Stats } from '../lib/model';
|
||||
import { ALL_VARIANTS, variantNameKey } from '../lib/variants';
|
||||
import type { BestMove, Stats } from '../lib/model';
|
||||
|
||||
let stats = $state<Stats | null>(null);
|
||||
|
||||
@@ -27,10 +29,17 @@
|
||||
{ key: 'stats.played', value: gamesPlayed(stats) },
|
||||
{ key: 'stats.winRate', value: `${winRate(stats)}%` },
|
||||
{ key: 'stats.maxGame', value: stats.maxGamePoints },
|
||||
{ key: 'stats.maxWord', value: stats.maxWordPoints },
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
// The best move is shown as a full-width breakdown with the word itself, one row per
|
||||
// variant the player has played, in catalogue order (the backend lists only non-empty
|
||||
// variants). It replaces the former single "best move" number card.
|
||||
const ORDER = ALL_VARIANTS.map((v) => v.id);
|
||||
const bestMoves = $derived<BestMove[]>(
|
||||
stats ? [...stats.bestMoves].sort((a, b) => ORDER.indexOf(a.variant) - ORDER.indexOf(b.variant)) : [],
|
||||
);
|
||||
</script>
|
||||
|
||||
<Screen title={t('stats.title')} back="/">
|
||||
@@ -46,6 +55,18 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if bestMoves.length > 0}
|
||||
<div class="card bestmove">
|
||||
<span class="lbl">{t('stats.maxWord')}</span>
|
||||
<div class="rows">
|
||||
{#each bestMoves as bm (bm.variant)}
|
||||
<span class="variant">{t(variantNameKey(bm.variant))}</span>
|
||||
<span class="wordcell"><WordTiles word={bm.word} /></span>
|
||||
<span class="score">{bm.score}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Screen>
|
||||
@@ -79,4 +100,33 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.bestmove {
|
||||
margin-top: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
/* One grid for all rows so columns align across them: variant on the left, the word
|
||||
tiles right-aligned to a shared edge, the score right-aligned in its own column. */
|
||||
.rows {
|
||||
display: grid;
|
||||
/* minmax(0, 1fr) lets the word column shrink below its tiles' intrinsic width on a
|
||||
narrow screen (the cell then scrolls) instead of overlapping the variant label. */
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
row-gap: 12px;
|
||||
column-gap: 8px;
|
||||
}
|
||||
.variant {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.wordcell {
|
||||
justify-self: end;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.score {
|
||||
justify-self: end;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user