feat(ui): on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s

Score and validate a tentative move on-device instead of a per-arrangement
network round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a new `conformance` CI job. The server stays
authoritative — submit_play re-validates — so the local result is an advisory
accelerator only; any cache miss, storage eviction or a bad-connection breaker
falls back to the network evaluate.

- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
  (immutable) streaming the pinned per-game dawg.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: an IndexedDB blob cache (best-effort, storage.persist()) + a loader
  (memory -> IndexedDB -> network, session-scoped bad-connection breaker) + a lobby
  prefetch (your-turn first); an adapter over the existing premiums/alphabet; a
  DictWarmup overlay while a cold dictionary loads (120ms flash-guard, 5s cap ->
  network); a ?nolocal flag; the DebugPanel reset also clears the dict cache.
- parity: generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run
  in CI against the release dictionaries.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
This commit is contained in:
Ilia Denisov
2026-07-01 20:13:01 +02:00
parent f0399e1bbc
commit d24a127406
38 changed files with 2375 additions and 11 deletions
+7
View File
@@ -483,6 +483,13 @@ func (c *Client) Evaluate(ctx context.Context, userID, gameID string, tiles []Pl
return out, err
}
// DictBytes fetches the raw serialized dictionary for a (variant, version) pair,
// backing the client-side local move preview. It returns the blob and the
// backend's Cache-Control header, forwarded so the immutable blob is cached hard.
func (c *Client) DictBytes(ctx context.Context, userID, variant, version string) ([]byte, string, error) {
return c.getRaw(ctx, "/api/v1/user/dict/"+url.PathEscape(variant)+"/"+url.PathEscape(version), userID, "Cache-Control")
}
// CheckWord looks a word up in the game's pinned dictionary. The word is carried as
// repeated ?idx= alphabet indices; the backend echoes the decoded concrete word.
func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) {
+28
View File
@@ -125,6 +125,34 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
return nil
}
// getRaw performs one REST GET, returning the raw (un-decoded) response body and
// the value of respHeader (e.g. Cache-Control). userID, when non-empty, is
// forwarded as X-User-ID. A non-2xx response is returned as an *APIError. Unlike
// do it does not JSON-decode, so it carries a binary payload such as a dictionary
// blob.
func (c *Client) getRaw(ctx context.Context, path, userID, respHeader string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return nil, "", fmt.Errorf("backendclient: new request: %w", err)
}
if userID != "" {
req.Header.Set("X-User-ID", userID)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("backendclient: read response: %w", err)
}
if resp.StatusCode >= http.StatusMultipleChoices {
return nil, "", parseAPIError(resp.StatusCode, data)
}
return data, resp.Header.Get(respHeader), nil
}
// parseAPIError extracts the backend's {error:{code,message}} envelope.
func parseAPIError(status int, data []byte) *APIError {
var env struct {