feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
The statistics screen gains real depth, plus a hint-count bug fix found along the way. - Best move per variant: the screen shows the actual best-move word (drawn as game tiles; a wildcard shows its letter but no value), broken down by game variant, empty variants omitted. New account_best_move table, written at game finish. - Moves & hint share: two new lifetime tiles — the player's play count and the share of plays that used a hint — from summed account_stats counters (moves, hints_used). Honest-AI games are excluded, like the rest of the stats. - Hint-count fix: the in-game hint badge no longer goes stale across games. The global wallet now rides the wire apart from the per-game allowance (wallet_balance on StateView/HintResult/StatsView), so the client reads the live wallet rather than a per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet), its true per-game total. - Account merge: sums the new moves/hints_used counters and merges the per-variant best moves (higher score kept), which it previously dropped. - Admin: the user card shows Moves and Hints used. - UI polish: tab/label wording, game-over text, and e2e selectors hardened against label changes. All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru, UISN_DESIGN) updated in step.
This commit was merged in pull request #81.
This commit is contained in:
+2
-2
@@ -66,7 +66,7 @@ test('a placed tile is saved as a draft and restored on reopening the game', asy
|
||||
test('new game: variant buttons show a rules summary and the move-limit', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
|
||||
await page.getByRole('button', { name: /🎲/ }).click(); // lobby tab bar -> auto-match
|
||||
await expect(page.locator('.vrules').first()).toBeVisible(); // per-variant rules summary
|
||||
await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons
|
||||
});
|
||||
@@ -74,7 +74,7 @@ test('new game: variant buttons show a rules summary and the move-limit', async
|
||||
test('new game: auto-match shows the off-by-default rule toggle from the start (no layout jump on selection)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /New/ }).click(); // auto-match
|
||||
await page.getByRole('button', { name: /🎲/ }).click(); // auto-match
|
||||
// Several variants are offered, so nothing is selected: Start is disabled. The rule toggle is shown
|
||||
// from the start (a Russian variant is available), so selecting one does not shift the layout.
|
||||
const start = page.getByRole('button', { name: /Start game/i });
|
||||
|
||||
@@ -11,7 +11,7 @@ test('lobby: New Game disables and a notice shows at the simultaneous-game limit
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
|
||||
// Below the limit: the New Game tab is enabled and no notice is shown.
|
||||
const newGame = page.getByRole('button', { name: /New/ });
|
||||
const newGame = page.getByRole('button', { name: /🎲/ });
|
||||
await expect(newGame).toBeEnabled();
|
||||
await expect(page.getByText(/reached the simultaneous games limit/i)).toHaveCount(0);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { expect, test } from './fixtures';
|
||||
test('quick game: enter immediately, wait for an opponent, then it joins', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
|
||||
await page.getByRole('button', { name: /🎲/ }).click(); // lobby tab bar -> auto-match
|
||||
|
||||
// Choose a random human opponent (the default is the AI); pick a variant and start. The player
|
||||
// lands in an open game at once (no "searching" screen).
|
||||
@@ -38,7 +38,7 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async
|
||||
test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /New/ }).click();
|
||||
await page.getByRole('button', { name: /🎲/ }).click();
|
||||
|
||||
// AI is the default opponent: the move-clock line is replaced by the 7-day inactivity rule.
|
||||
await expect(page.getByText(/Loss after 7 days of inactivity/)).toBeVisible();
|
||||
@@ -64,7 +64,7 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a
|
||||
test('AI game: no GCG export offered after it ends', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /New/ }).click();
|
||||
await page.getByRole('button', { name: /🎲/ }).click();
|
||||
await page.locator('.variant').first().click(); // AI is the default opponent
|
||||
await page.getByRole('button', { name: /Start game/i }).click();
|
||||
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
|
||||
@@ -90,7 +90,7 @@ test('AI game: no GCG export offered after it ends', async ({ page }) => {
|
||||
async function enterOpenGame(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /New/ }).click();
|
||||
await page.getByRole('button', { name: /🎲/ }).click();
|
||||
await page.getByRole('button', { name: 'Random player' }).click();
|
||||
await page.locator('.variant').first().click();
|
||||
await page.getByRole('button', { name: /Start game/i }).click();
|
||||
|
||||
+10
-2
@@ -53,11 +53,19 @@ 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 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();
|
||||
await expect(page.getByText('134')).toBeVisible();
|
||||
});
|
||||
|
||||
test('settings hub: tabs switch in place and back returns to the lobby', async ({ page }) => {
|
||||
@@ -158,7 +166,7 @@ test('lobby ⚙️ tab shows the pending friend-request count', async ({ page })
|
||||
|
||||
test('play with friends: a game type is required to send an invitation', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar
|
||||
await page.getByRole('button', { name: /🎲/ }).click(); // lobby tab bar (New Game tab, by its 🎲 icon — the label is themeable)
|
||||
await page.getByRole('button', { name: 'Play with friends' }).click();
|
||||
|
||||
const send = page.getByRole('button', { name: 'Send invitation' });
|
||||
|
||||
@@ -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>
|
||||
+28
-5
@@ -18,6 +18,7 @@
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
import { variantNameKey } from '../lib/variants';
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { hintsLeft } from '../lib/hints';
|
||||
import { shareOrDownloadGcg } from '../lib/share';
|
||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||
import { patchLobbyGame } from '../lib/lobbycache';
|
||||
@@ -132,6 +133,10 @@
|
||||
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
|
||||
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
|
||||
const gameOver = $derived(!!view && view.game.status === 'finished');
|
||||
// The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
|
||||
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
|
||||
// hint was spent in another game (see lib/hints).
|
||||
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
|
||||
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
|
||||
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
|
||||
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
|
||||
@@ -154,6 +159,13 @@
|
||||
return MOVE_LABELS.has(action) ? t(`move.${action}` as MessageKey) : action;
|
||||
}
|
||||
|
||||
// syncWallet adopts the server's authoritative hint-wallet balance into the global profile.
|
||||
// The wallet is global, so keeping it live here (rather than per-game) is what stops the hint
|
||||
// badge from going stale when a wallet hint was spent in another game.
|
||||
function syncWallet(walletBalance: number) {
|
||||
if (app.profile) app.profile.hintBalance = walletBalance;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
// Ask for the alphabet table only on a per-variant cache miss (the first open of a
|
||||
@@ -167,6 +179,7 @@
|
||||
gateway.draftGet(id).catch(() => ''),
|
||||
]);
|
||||
view = st;
|
||||
syncWallet(st.walletBalance);
|
||||
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
||||
seedChatUnread(id, st.game.unreadChat);
|
||||
moves = hist.moves;
|
||||
@@ -615,7 +628,16 @@
|
||||
// applyMoveResult renders the actor's own just-committed move from the response — the move, the
|
||||
// post-move game and the refilled rack — without a follow-up game.state + game.history.
|
||||
function applyMoveResult(r: MoveResult) {
|
||||
view = { game: r.game, seat: r.move.player, rack: r.rack, bagLen: r.bagLen, hintsRemaining: view?.hintsRemaining ?? 0 };
|
||||
view = {
|
||||
game: r.game,
|
||||
seat: r.move.player,
|
||||
rack: r.rack,
|
||||
bagLen: r.bagLen,
|
||||
// A move is not a hint, so the per-game allowance and the wallet are unchanged: carry both
|
||||
// forward (their difference is the stable allowance; the badge adds the live wallet).
|
||||
hintsRemaining: view?.hintsRemaining ?? 0,
|
||||
walletBalance: view?.walletBalance ?? 0,
|
||||
};
|
||||
// The move result is an authoritative per-viewer view: a nudge the actor just answered by
|
||||
// moving is already cleared server-side, so reconcile the unread flag from it.
|
||||
seedChatUnread(id, r.game.unreadChat);
|
||||
@@ -698,7 +720,8 @@
|
||||
recenter++;
|
||||
}
|
||||
if (isCoarse()) zoomed = true;
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||
view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance };
|
||||
syncWallet(h.walletBalance);
|
||||
recompute();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1067,7 +1090,7 @@
|
||||
<div class="status">
|
||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||
{#if gameOver}
|
||||
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
||||
<strong class="over">{resultText()}</strong>
|
||||
{:else if placement.pending.length === 0}
|
||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||
{/if}
|
||||
@@ -1107,10 +1130,10 @@
|
||||
<TapConfirm
|
||||
triggerClass="tab"
|
||||
label={t('game.hint')}
|
||||
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
|
||||
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
|
||||
onconfirm={doHint}
|
||||
>
|
||||
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
||||
<span class="sq">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
||||
<span class="lbl">{t('game.hint')}</span>
|
||||
</TapConfirm>
|
||||
{#if placement.pending.length > 0}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,13 @@ hintsRemaining():number {
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
walletBalance():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startHintResult(builder:flatbuffers.Builder) {
|
||||
builder.startObject(2);
|
||||
builder.startObject(3);
|
||||
}
|
||||
|
||||
static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) {
|
||||
@@ -45,15 +50,20 @@ static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) {
|
||||
builder.addFieldInt32(1, hintsRemaining, 0);
|
||||
}
|
||||
|
||||
static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
|
||||
builder.addFieldInt32(2, walletBalance, 0);
|
||||
}
|
||||
|
||||
static endHintResult(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createHintResult(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset, hintsRemaining:number):flatbuffers.Offset {
|
||||
static createHintResult(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset, hintsRemaining:number, walletBalance:number):flatbuffers.Offset {
|
||||
HintResult.startHintResult(builder);
|
||||
HintResult.addMove(builder, moveOffset);
|
||||
HintResult.addHintsRemaining(builder, hintsRemaining);
|
||||
HintResult.addWalletBalance(builder, walletBalance);
|
||||
return HintResult.endHintResult(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,13 @@ alphabetLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
walletBalance():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 16);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startStateView(builder:flatbuffers.Builder) {
|
||||
builder.startObject(6);
|
||||
builder.startObject(7);
|
||||
}
|
||||
|
||||
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
||||
@@ -121,12 +126,16 @@ static startAlphabetVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
|
||||
builder.addFieldInt32(6, walletBalance, 0);
|
||||
}
|
||||
|
||||
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number):flatbuffers.Offset {
|
||||
StateView.startStateView(builder);
|
||||
StateView.addGame(builder, gameOffset);
|
||||
StateView.addSeat(builder, seat);
|
||||
@@ -134,6 +143,7 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse
|
||||
StateView.addBagLen(builder, bagLen);
|
||||
StateView.addHintsRemaining(builder, hintsRemaining);
|
||||
StateView.addAlphabet(builder, alphabetOffset);
|
||||
StateView.addWalletBalance(builder, walletBalance);
|
||||
return StateView.endStateView(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,28 @@ 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;
|
||||
}
|
||||
|
||||
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(5);
|
||||
builder.startObject(8);
|
||||
}
|
||||
|
||||
static addWins(builder:flatbuffers.Builder, wins:number) {
|
||||
@@ -69,18 +92,45 @@ 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 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):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);
|
||||
StatsView.addDraws(builder, draws);
|
||||
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,57 @@ describe('codec', () => {
|
||||
draws: 1,
|
||||
maxGamePoints: 420,
|
||||
maxWordPoints: 90,
|
||||
moves: 248,
|
||||
hintsUsed: 12,
|
||||
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,
|
||||
moves: 0,
|
||||
hintsUsed: 0,
|
||||
bestMoves: [
|
||||
{
|
||||
variant: 'scrabble_en',
|
||||
score: 90,
|
||||
word: [
|
||||
{ letter: 'c', value: 3, blank: false },
|
||||
{ letter: 'a', value: 0, blank: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+18
-1
@@ -11,6 +11,8 @@ import type {
|
||||
AccountRef,
|
||||
Banner,
|
||||
BannerCampaign,
|
||||
BestMove,
|
||||
BestMoveTile,
|
||||
BlockStatus,
|
||||
ChatMessage,
|
||||
EvalResult,
|
||||
@@ -381,6 +383,7 @@ function decodeStateViewTable(v: fb.StateView): StateView {
|
||||
rack,
|
||||
bagLen: v.bagLen(),
|
||||
hintsRemaining: v.hintsRemaining(),
|
||||
walletBalance: v.walletBalance(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -407,7 +410,7 @@ export function decodeMoveResult(buf: Uint8Array): MoveResult {
|
||||
export function decodeHintResult(buf: Uint8Array): HintResult {
|
||||
const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf));
|
||||
const m = r.move();
|
||||
return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining() };
|
||||
return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining(), walletBalance: r.walletBalance() };
|
||||
}
|
||||
|
||||
export function decodeEvalResult(buf: Uint8Array): EvalResult {
|
||||
@@ -742,12 +745,26 @@ 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(),
|
||||
moves: v.moves(),
|
||||
hintsUsed: v.hintsUsed(),
|
||||
bestMoves,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ function gameView(id: string): GameView {
|
||||
}
|
||||
|
||||
function view(id: string, rack: string[] = ['A', 'B']): StateView {
|
||||
return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1 };
|
||||
return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
|
||||
}
|
||||
|
||||
function move(player: number): MoveRecord {
|
||||
|
||||
@@ -27,7 +27,7 @@ function move(player: number): MoveRecord {
|
||||
}
|
||||
|
||||
function cache(moveCount: number, seat = 0, over = false): CachedGame {
|
||||
const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 };
|
||||
const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
|
||||
return { view, moves: [] };
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
|
||||
|
||||
describe('seedInitialState', () => {
|
||||
it('wraps an initial view with an empty journal', () => {
|
||||
const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 };
|
||||
const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2, walletBalance: 0 };
|
||||
expect(seedInitialState(view)).toEqual({ view, moves: [] });
|
||||
});
|
||||
});
|
||||
@@ -152,12 +152,12 @@ describe('applyOpponentJoined', () => {
|
||||
{ seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
|
||||
{ seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false },
|
||||
] };
|
||||
return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 };
|
||||
return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0, walletBalance: 0 };
|
||||
}
|
||||
|
||||
it("adopts the joined seats and status while preserving the cached rack and moves", () => {
|
||||
// The cached open game is still "searching": empty seats, status open, the starter's own rack.
|
||||
const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] };
|
||||
const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }, moves: [move(0)] };
|
||||
const res = applyOpponentJoined(cached, joinedState());
|
||||
expect(res?.view.game.status).toBe('active');
|
||||
expect(res?.view.game.seats).toHaveLength(2);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hintsLeft } from './hints';
|
||||
|
||||
// view carries only the two fields hintsLeft reads.
|
||||
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
|
||||
|
||||
describe('hintsLeft', () => {
|
||||
it('is zero without a view', () => {
|
||||
expect(hintsLeft(null, 5)).toBe(0);
|
||||
});
|
||||
|
||||
it('adds the per-game allowance to the live wallet (fresh view)', () => {
|
||||
// hints_remaining 4 = allowance 1 + wallet 3; live wallet matches the snapshot → 1 + 3.
|
||||
expect(hintsLeft(view(4, 3), 3)).toBe(4);
|
||||
});
|
||||
|
||||
it('reflects the LIVE wallet, not the per-game snapshot (the staleness fix)', () => {
|
||||
// The view was fetched when the wallet was 3 (allowance 1), but a wallet hint was since spent
|
||||
// in another game, so the live wallet is 2: the count must drop to 1 + 2 = 3, not stay at 4.
|
||||
expect(hintsLeft(view(4, 3), 2)).toBe(3);
|
||||
});
|
||||
|
||||
it('shows just the wallet when the per-game allowance is used up', () => {
|
||||
// allowance 0 (hints_remaining 3 == snapshot wallet 3); live wallet 3 → 0 + 3.
|
||||
expect(hintsLeft(view(3, 3), 3)).toBe(3);
|
||||
});
|
||||
|
||||
it('clamps a non-negative allowance and wallet', () => {
|
||||
expect(hintsLeft(view(2, 3), 0)).toBe(0);
|
||||
expect(hintsLeft(view(1, 0), -5)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Hint-count derivation, kept out of the .svelte component so it is unit-testable.
|
||||
//
|
||||
// The badge shows the per-game hint allowance remaining plus the player's global hint
|
||||
// wallet. The server's hints_remaining folds the two together, but the wallet is global —
|
||||
// shared across every game — so caching the combined number per game makes it go stale the
|
||||
// moment a wallet hint is spent in another game. We therefore split it: the per-game
|
||||
// allowance is hints_remaining - wallet_balance (both from the same fetch, so it is stable
|
||||
// and cacheable), and the wallet is read live from the global profile, never the per-game
|
||||
// snapshot.
|
||||
|
||||
import type { StateView } from './model';
|
||||
|
||||
/**
|
||||
* hintsLeft is the hint count for the badge: the per-game allowance remaining (the view's
|
||||
* hints_remaining minus the wallet snapshot baked into that same view) plus the live global
|
||||
* wallet balance. Passing the live wallet (not view.walletBalance) is what keeps the count
|
||||
* correct when a wallet hint was spent in another game since this view was fetched.
|
||||
*/
|
||||
export function hintsLeft(
|
||||
view: Pick<StateView, 'hintsRemaining' | 'walletBalance'> | null,
|
||||
walletBalance: number,
|
||||
): number {
|
||||
if (!view) return 0;
|
||||
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
|
||||
return allowance + Math.max(0, walletBalance);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export const en = {
|
||||
'lobby.noActive': 'No active games yet.',
|
||||
'lobby.noFinished': 'No finished games yet.',
|
||||
'lobby.limitReached': "You've reached the simultaneous games limit.",
|
||||
'lobby.new': 'New',
|
||||
'lobby.new': 'Play',
|
||||
'lobby.stats': 'Stats',
|
||||
'lobby.profile': 'Profile',
|
||||
'lobby.settings': 'Settings',
|
||||
@@ -83,7 +83,6 @@ export const en = {
|
||||
'game.passNoExchange': 'Pass without exchanging',
|
||||
'game.confirmResign': 'Resign this game?',
|
||||
'game.hintShown': 'Best move: {word} for {n}',
|
||||
'game.over': 'Game over',
|
||||
'game.won': 'You won',
|
||||
'game.lost': 'You lost',
|
||||
'game.tied': 'Draw',
|
||||
@@ -118,7 +117,7 @@ export const en = {
|
||||
'chat.nudge': 'Waiting for your move 🤭',
|
||||
'chat.nudgeBy': '{name}: Waiting for your move 🤭',
|
||||
'chat.nudgeAction': 'Nudge',
|
||||
'chat.awaitingReply': "Waiting for the opponent's reply",
|
||||
'chat.awaitingReply': "Let's be patient",
|
||||
'chat.empty': 'No messages yet.',
|
||||
'chat.nudged': '{name} nudged you',
|
||||
'chat.sentThisTurn': 'You can write again next turn.',
|
||||
@@ -275,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',
|
||||
|
||||
@@ -35,8 +35,8 @@ export const ru: Record<MessageKey, string> = {
|
||||
'lobby.noActive': 'Пока нет активных игр.',
|
||||
'lobby.noFinished': 'Пока нет завершённых игр.',
|
||||
'lobby.limitReached': 'Вы достигли лимита одновременных партий',
|
||||
'lobby.new': 'Новая',
|
||||
'lobby.stats': 'Статы',
|
||||
'lobby.new': 'Играть',
|
||||
'lobby.stats': 'Цифры',
|
||||
'lobby.profile': 'Профиль',
|
||||
'lobby.settings': 'Настройки',
|
||||
'lobby.about': 'О программе',
|
||||
@@ -56,7 +56,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
|
||||
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
|
||||
'new.searchHint':
|
||||
'Иногда поиск соперника может занимать некоторое время. Если не хотите ждать, после начала игры закройте приложение и возвращайтесь через пару минут.',
|
||||
'Иногда поиск соперника может занять некоторое время. Если не захотите ждать после начала игры, можете вернуться в приложение через несколько минут.',
|
||||
|
||||
'game.bag': '{n} в мешке',
|
||||
'game.bagEmpty': 'Мешок пуст',
|
||||
@@ -84,7 +84,6 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.passNoExchange': 'Пас без обмена',
|
||||
'game.confirmResign': 'Сдаться в этой игре?',
|
||||
'game.hintShown': 'Лучший ход: {word} на {n}',
|
||||
'game.over': 'Игра окончена',
|
||||
'game.won': 'Вы выиграли',
|
||||
'game.lost': 'Вы проиграли',
|
||||
'game.tied': 'Ничья',
|
||||
@@ -119,7 +118,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'chat.nudge': 'Жду Вашего хода 🤭',
|
||||
'chat.nudgeBy': '{name}: Жду Вашего хода 🤭',
|
||||
'chat.nudgeAction': 'Поторопить',
|
||||
'chat.awaitingReply': 'Ждём реакцию соперника',
|
||||
'chat.awaitingReply': 'Немного терпения',
|
||||
'chat.empty': 'Сообщений пока нет.',
|
||||
'chat.nudged': '{name} торопит вас',
|
||||
'chat.sentThisTurn': 'Можно написать снова в следующем ходу.',
|
||||
@@ -275,7 +274,9 @@ export const ru: Record<MessageKey, string> = {
|
||||
'stats.wins': 'Победы',
|
||||
'stats.losses': 'Поражения',
|
||||
'stats.draws': 'Ничьи',
|
||||
'stats.played': 'Игр',
|
||||
'stats.played': 'Игры',
|
||||
'stats.moves': 'Ходы',
|
||||
'stats.hintShare': 'Доля подсказок',
|
||||
'stats.winRate': 'Доля побед',
|
||||
'stats.maxGame': 'Лучшая игра',
|
||||
'stats.maxWord': 'Лучший ход',
|
||||
|
||||
@@ -295,7 +295,10 @@ export class MockGateway implements GatewayClient {
|
||||
seat: this.mySeat(g),
|
||||
rack: [...g.rack],
|
||||
bagLen: g.bagLen,
|
||||
hintsRemaining: g.hintsRemaining,
|
||||
// g.hintsRemaining is the per-game allowance; the wallet is the shared profile balance.
|
||||
// hints_remaining folds the two together (as the backend does), walletBalance is the wallet.
|
||||
hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
|
||||
walletBalance: this.profile.hintBalance,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,8 +398,11 @@ export class MockGateway implements GatewayClient {
|
||||
|
||||
async hint(gameId: string): Promise<HintResult> {
|
||||
const g = this.game(gameId);
|
||||
if (g.hintsRemaining <= 0) throw new GatewayError('hint_unavailable');
|
||||
g.hintsRemaining -= 1;
|
||||
if (g.hintsRemaining <= 0 && this.profile.hintBalance <= 0) throw new GatewayError('hint_unavailable');
|
||||
// Spend the per-game allowance first, then the shared wallet — mirroring the backend, so a
|
||||
// wallet hint in one game lowers the count shown in every other game (the bug this fixes).
|
||||
if (g.hintsRemaining > 0) g.hintsRemaining -= 1;
|
||||
else this.profile.hintBalance -= 1;
|
||||
const letter = g.rack.find((l) => l !== '?') ?? 'A';
|
||||
return {
|
||||
move: {
|
||||
@@ -411,7 +417,8 @@ export class MockGateway implements GatewayClient {
|
||||
score: valueForLetter(g.view.variant, letter),
|
||||
total: 0,
|
||||
},
|
||||
hintsRemaining: g.hintsRemaining,
|
||||
hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
|
||||
walletBalance: this.profile.hintBalance,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+41
-1
@@ -50,7 +50,47 @@ 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,
|
||||
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.
|
||||
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 [
|
||||
|
||||
+31
-2
@@ -67,13 +67,17 @@ export interface MoveRecord {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** A seated player's private view of a game. */
|
||||
/** A seated player's private view of a game. hintsRemaining folds the per-game allowance
|
||||
* together with the global wallet; walletBalance is the wallet alone, so the client can
|
||||
* derive the per-game allowance (hintsRemaining - walletBalance) and keep the wallet live
|
||||
* across games (see lib/hints). */
|
||||
export interface StateView {
|
||||
game: GameView;
|
||||
seat: number;
|
||||
rack: string[];
|
||||
bagLen: number;
|
||||
hintsRemaining: number;
|
||||
walletBalance: number;
|
||||
}
|
||||
|
||||
export interface MoveResult {
|
||||
@@ -87,6 +91,7 @@ export interface MoveResult {
|
||||
export interface HintResult {
|
||||
move: MoveRecord;
|
||||
hintsRemaining: number;
|
||||
walletBalance: number;
|
||||
}
|
||||
|
||||
export interface EvalResult {
|
||||
@@ -206,13 +211,37 @@ 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;
|
||||
/** Lifetime count of the player's plays (tile placements). */
|
||||
moves: number;
|
||||
/** Lifetime count of hints the player took (allowance + wallet). */
|
||||
hintsUsed: number;
|
||||
bestMoves: BestMove[];
|
||||
}
|
||||
|
||||
/** Settings the inviter chooses for a friend game. */
|
||||
|
||||
@@ -34,7 +34,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
|
||||
}
|
||||
|
||||
function stateView(id: string): StateView {
|
||||
return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1 };
|
||||
return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -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,8 +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);
|
||||
@@ -23,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;
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@
|
||||
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||||
</button>
|
||||
<button class="tab" onclick={() => navigate('/stats')}>
|
||||
<span class="sq">📊</span><span class="lbl">{t('lobby.stats')}</span>
|
||||
<span class="sq">✏️</span><span class="lbl">{t('lobby.stats')}</span>
|
||||
</button>
|
||||
<button class="tab" onclick={() => navigate('/settings')}>
|
||||
<span class="sq">⚙️{#if settingsBadge > 0}<span class="badge">{settingsBadge}</span>{/if}</span>
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
<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 { 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);
|
||||
|
||||
@@ -21,16 +32,25 @@
|
||||
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.maxWord', value: stats.maxWordPoints },
|
||||
{ key: 'stats.winRate', value: `${winRate(stats)}%` },
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
// 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 +66,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 +111,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