feat(stats): per-game hints used + lifetime moves and hint share
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m24s
Store the hints a player used in each game, and add two lifetime tiles — Moves and Hint share — to the statistics screen. - per-game: game_players.hints_used now counts EVERY hint (allowance + wallet), not just the free allowance, so it is the seat's true total hints used this game. The allowance decision (used < HintsPerPlayer) and the hint badge values (hints_remaining / wallet_balance) are unchanged — the lobby hint-count fix does NOT regress; only the admin "hints used" column, which silently under-counted wallet hints, becomes accurate. - account_stats gains two summed counters: moves (the player's plays — passes and exchanges excluded) and hints_used (every hint). Computed at game finish in buildStats over the same non-guest, non-honest-AI games as the rest of the stats. - wire: StatsView gains moves + hints_used (trailing); gateway + UI codec + model; regen. - ui: two tiles (Moves, Hint share = hints_used/moves, one-decimal % in the active locale); card order games·wins·draws·losses·moves·hint-share·best game·win-rate. - docs: ARCHITECTURE §9 + baseline comment, FUNCTIONAL (+ru), UI_DESIGN. Tests: TestHintPolicy (hints_used counts the wallet hint), TestGameLifecycleAndStats (moves>0, hints=0), gateway stats round-trip, UI hintShare unit + codec + e2e.
This commit is contained in:
@@ -58,6 +58,10 @@ test('stats screen shows the metrics and the per-variant best move', async ({ pa
|
||||
await page.getByRole('button', { name: /Stats/ }).click();
|
||||
await expect(page.getByText('Win rate')).toBeVisible();
|
||||
await expect(page.getByText('Best move')).toBeVisible();
|
||||
// The Moves count and the derived Hint share (12 hints / 248 plays = 4.8%, one decimal).
|
||||
await expect(page.getByText('Moves', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Hint share')).toBeVisible();
|
||||
await expect(page.getByText('4.8%')).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();
|
||||
|
||||
@@ -58,8 +58,18 @@ bestMovesLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
moves():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 16);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
hintsUsed():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 18);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startStatsView(builder:flatbuffers.Builder) {
|
||||
builder.startObject(6);
|
||||
builder.startObject(8);
|
||||
}
|
||||
|
||||
static addWins(builder:flatbuffers.Builder, wins:number) {
|
||||
@@ -98,12 +108,20 @@ static startBestMovesVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static addMoves(builder:flatbuffers.Builder, moves:number) {
|
||||
builder.addFieldInt32(6, moves, 0);
|
||||
}
|
||||
|
||||
static addHintsUsed(builder:flatbuffers.Builder, hintsUsed:number) {
|
||||
builder.addFieldInt32(7, hintsUsed, 0);
|
||||
}
|
||||
|
||||
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, bestMovesOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number, bestMovesOffset:flatbuffers.Offset, moves:number, hintsUsed:number):flatbuffers.Offset {
|
||||
StatsView.startStatsView(builder);
|
||||
StatsView.addWins(builder, wins);
|
||||
StatsView.addLosses(builder, losses);
|
||||
@@ -111,6 +129,8 @@ static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number,
|
||||
StatsView.addMaxGamePoints(builder, maxGamePoints);
|
||||
StatsView.addMaxWordPoints(builder, maxWordPoints);
|
||||
StatsView.addBestMoves(builder, bestMovesOffset);
|
||||
StatsView.addMoves(builder, moves);
|
||||
StatsView.addHintsUsed(builder, hintsUsed);
|
||||
return StatsView.endStatsView(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +284,8 @@ describe('codec', () => {
|
||||
fb.StatsView.addDraws(b, 1);
|
||||
fb.StatsView.addMaxGamePoints(b, 420);
|
||||
fb.StatsView.addMaxWordPoints(b, 90);
|
||||
fb.StatsView.addMoves(b, 248);
|
||||
fb.StatsView.addHintsUsed(b, 12);
|
||||
b.finish(fb.StatsView.endStatsView(b));
|
||||
expect(decodeStats(b.asUint8Array())).toEqual({
|
||||
wins: 7,
|
||||
@@ -291,6 +293,8 @@ describe('codec', () => {
|
||||
draws: 1,
|
||||
maxGamePoints: 420,
|
||||
maxWordPoints: 90,
|
||||
moves: 248,
|
||||
hintsUsed: 12,
|
||||
bestMoves: [],
|
||||
});
|
||||
});
|
||||
@@ -328,6 +332,8 @@ describe('codec', () => {
|
||||
draws: 0,
|
||||
maxGamePoints: 0,
|
||||
maxWordPoints: 0,
|
||||
moves: 0,
|
||||
hintsUsed: 0,
|
||||
bestMoves: [
|
||||
{
|
||||
variant: 'scrabble_en',
|
||||
|
||||
@@ -762,6 +762,8 @@ export function decodeStats(buf: Uint8Array): Stats {
|
||||
draws: v.draws(),
|
||||
maxGamePoints: v.maxGamePoints(),
|
||||
maxWordPoints: v.maxWordPoints(),
|
||||
moves: v.moves(),
|
||||
hintsUsed: v.hintsUsed(),
|
||||
bestMoves,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,6 +274,8 @@ export const en = {
|
||||
'stats.losses': 'Losses',
|
||||
'stats.draws': 'Draws',
|
||||
'stats.played': 'Games',
|
||||
'stats.moves': 'Moves',
|
||||
'stats.hintShare': 'Hint share',
|
||||
'stats.winRate': 'Win rate',
|
||||
'stats.maxGame': 'Best game',
|
||||
'stats.maxWord': 'Best move',
|
||||
|
||||
@@ -275,6 +275,8 @@ export const ru: Record<MessageKey, string> = {
|
||||
'stats.losses': 'Поражения',
|
||||
'stats.draws': 'Ничьи',
|
||||
'stats.played': 'Игр',
|
||||
'stats.moves': 'Ходы',
|
||||
'stats.hintShare': 'Доля подсказок',
|
||||
'stats.winRate': 'Доля побед',
|
||||
'stats.maxGame': 'Лучшая игра',
|
||||
'stats.maxWord': 'Лучший ход',
|
||||
|
||||
@@ -56,6 +56,8 @@ export const MOCK_STATS: Stats = {
|
||||
draws: 1,
|
||||
maxGamePoints: 421,
|
||||
maxWordPoints: 134,
|
||||
moves: 248, // plays across all games
|
||||
hintsUsed: 12, // -> hint share 12/248 = 4.8%
|
||||
// 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.
|
||||
|
||||
@@ -237,6 +237,10 @@ export interface Stats {
|
||||
draws: number;
|
||||
maxGamePoints: number;
|
||||
maxWordPoints: number;
|
||||
/** Lifetime count of the player's plays (tile placements). */
|
||||
moves: number;
|
||||
/** Lifetime count of hints the player took (allowance + wallet). */
|
||||
hintsUsed: number;
|
||||
bestMoves: BestMove[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { gamesPlayed, winRate } from './stats';
|
||||
import { gamesPlayed, hintSharePercent, winRate } from './stats';
|
||||
import type { Stats } from './model';
|
||||
|
||||
const s = (wins: number, losses: number, draws: number): Stats => ({
|
||||
@@ -8,9 +8,14 @@ const s = (wins: number, losses: number, draws: number): Stats => ({
|
||||
draws,
|
||||
maxGamePoints: 0,
|
||||
maxWordPoints: 0,
|
||||
moves: 0,
|
||||
hintsUsed: 0,
|
||||
bestMoves: [],
|
||||
});
|
||||
|
||||
// withCounts overrides moves/hintsUsed on the zero fixture for the hint-share cases.
|
||||
const withCounts = (moves: number, hintsUsed: number): Stats => ({ ...s(0, 0, 0), moves, hintsUsed });
|
||||
|
||||
describe('stats', () => {
|
||||
it('sums games played', () => {
|
||||
expect(gamesPlayed(s(7, 4, 1))).toBe(12);
|
||||
@@ -24,4 +29,14 @@ describe('stats', () => {
|
||||
it('win rate is 0 with no games', () => {
|
||||
expect(winRate(s(0, 0, 0))).toBe(0);
|
||||
});
|
||||
|
||||
it('computes the hint share (hints / plays)', () => {
|
||||
expect(hintSharePercent(withCounts(200, 10))).toBe(5); // 10/200 = 5%
|
||||
expect(hintSharePercent(withCounts(248, 12))).toBeCloseTo(4.8387, 3);
|
||||
});
|
||||
|
||||
it('hint share is 0 with no plays (no division by zero)', () => {
|
||||
expect(hintSharePercent(withCounts(0, 0))).toBe(0);
|
||||
expect(hintSharePercent(withCounts(0, 5))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,3 +12,10 @@ export function winRate(s: Stats): number {
|
||||
const n = gamesPlayed(s);
|
||||
return n > 0 ? Math.round((s.wins / n) * 100) : 0;
|
||||
}
|
||||
|
||||
/** hintSharePercent is the share of the player's plays that drew on a hint
|
||||
* (hints used / plays × 100), unrounded; 0 when no plays. The screen formats it to one
|
||||
* decimal in the active locale. */
|
||||
export function hintSharePercent(s: Stats): number {
|
||||
return s.moves > 0 ? (s.hintsUsed / s.moves) * 100 : 0;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,20 @@
|
||||
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 { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import { gamesPlayed, hintSharePercent, winRate } from '../lib/stats';
|
||||
import { ALL_VARIANTS, variantNameKey } from '../lib/variants';
|
||||
import type { BestMove, Stats } from '../lib/model';
|
||||
|
||||
// hintShare is shown to one decimal in the active locale's notation ("4.8%" / "4,8%").
|
||||
function hintShare(s: Stats): string {
|
||||
const pct = hintSharePercent(s).toLocaleString(i18n.locale, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
return `${pct}%`;
|
||||
}
|
||||
|
||||
let stats = $state<Stats | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
@@ -23,12 +32,14 @@
|
||||
const cards = $derived<{ key: MessageKey; value: string | number }[]>(
|
||||
stats
|
||||
? [
|
||||
{ key: 'stats.wins', value: stats.wins },
|
||||
{ key: 'stats.losses', value: stats.losses },
|
||||
{ key: 'stats.draws', value: stats.draws },
|
||||
{ key: 'stats.played', value: gamesPlayed(stats) },
|
||||
{ key: 'stats.winRate', value: `${winRate(stats)}%` },
|
||||
{ key: 'stats.wins', value: stats.wins },
|
||||
{ key: 'stats.draws', value: stats.draws },
|
||||
{ key: 'stats.losses', value: stats.losses },
|
||||
{ key: 'stats.moves', value: stats.moves },
|
||||
{ key: 'stats.hintShare', value: hintShare(stats) },
|
||||
{ key: 'stats.maxGame', value: stats.maxGamePoints },
|
||||
{ key: 'stats.winRate', value: `${winRate(stats)}%` },
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user