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
+1
View File
@@ -194,6 +194,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
Sessions: sessions,
Backend: backend,
Limiter: limiter,
Tracker: tracker,
Banlist: banlist,
+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 {
+78 -1
View File
@@ -68,6 +68,7 @@ const (
type Server struct {
registry *transcode.Registry
sessions *session.Cache
backend *backendclient.Client
limiter *ratelimit.Limiter
tracker *ratelimit.Tracker
banlist *ratelimit.Banlist
@@ -91,7 +92,10 @@ type Server struct {
type Deps struct {
Registry *transcode.Registry
Sessions *session.Cache
Limiter *ratelimit.Limiter
// Backend is the REST client backing the session-gated /dict blob route; a nil
// value disables that route (it 404s).
Backend *backendclient.Client
Limiter *ratelimit.Limiter
// Tracker accumulates limiter rejections for the periodic report; nil
// selects a private tracker (rejections are then only counted, never
// reported).
@@ -142,6 +146,7 @@ func NewServer(d Deps) *Server {
return &Server{
registry: d.Registry,
sessions: d.Sessions,
backend: d.Backend,
limiter: limiter,
tracker: tracker,
banlist: banlist,
@@ -183,6 +188,9 @@ func (s *Server) HTTPHandler() http.Handler {
// does not serve the app shell at the operator path.
mux.Handle("/_gm/", http.NotFoundHandler())
}
// The client-side local move preview pulls each game's pinned dictionary blob
// through this session-gated route (not public); see dictBytesHandler.
mux.Handle("/dict/", s.dictBytesHandler())
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
@@ -397,6 +405,75 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler {
})
}
// dictBytesHandler serves the raw dictionary blob for a (variant, version) pair to
// a signed-in client (the local move preview), proxying the backend's authed
// endpoint. It is session-gated — not public — bounds the download with the
// user-class limiter, and forwards the backend's immutable Cache-Control so the
// browser caches the blob hard. Only GET is allowed; the path is
// /dict/{variant}/{version}.
func (s *Server) dictBytesHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("user:"+ip, s.userPolicy) {
s.noteRateLimited(r.Context(), classUser, ip, "dict")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
uid, _, err := s.resolve(r.Context(), r.Header, ip)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
variant, version, ok := parseDictPath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
data, cacheControl, err := s.backend.DictBytes(r.Context(), uid, variant, version)
if err != nil {
var apiErr *backendclient.APIError
if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError {
http.NotFound(w, r) // unknown variant or version
return
}
s.log.Warn("dict fetch failed", zap.Error(err))
http.Error(w, "bad gateway", http.StatusBadGateway)
return
}
if cacheControl != "" {
w.Header().Set("Cache-Control", cacheControl)
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(data)
})
}
// parseDictPath splits /dict/{variant}/{version}; both segments must be present
// and non-empty, and the version must not contain a further slash.
func parseDictPath(p string) (variant, version string, ok bool) {
rest, found := strings.CutPrefix(p, "/dict/")
if !found {
return "", "", false
}
i := strings.IndexByte(rest, '/')
if i <= 0 || i == len(rest)-1 {
return "", "", false
}
variant, version = rest[:i], rest[i+1:]
if strings.Contains(version, "/") {
return "", "", false
}
return variant, version, true
}
// resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown.