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
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:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user