feat: on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
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 `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.
- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
(immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
IndexedDB (best-effort, self-healing on a rejected blob) and reused across
sessions; a warm-up overlay covers a cold load, then the network preview is the
fallback; a bad-connection breaker stops warming after repeated misses; the move
preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
CI against the release dictionaries. A hidden debug readout lists the cached
dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
This commit is contained in:
@@ -21,11 +21,13 @@ var dictFiles = map[Variant]string{
|
||||
VariantErudit: "ru_erudit.dawg",
|
||||
}
|
||||
|
||||
// entry is one resident dictionary: the loaded finder and the solver built over
|
||||
// it. The finder is retained so Close can release it.
|
||||
// entry is one resident dictionary: the loaded finder, the solver built over it
|
||||
// and the file it was loaded from. The finder is retained so Close can release
|
||||
// it; path is retained so the raw bytes can be re-read for the client download.
|
||||
type entry struct {
|
||||
finder dawg.Finder
|
||||
solver *scrabble.Solver
|
||||
path string
|
||||
}
|
||||
|
||||
// Registry holds the dictionaries resident in memory, addressed by variant and
|
||||
@@ -130,7 +132,7 @@ func (r *Registry) Load(v Variant, version, dir string) error {
|
||||
if old, ok := r.entries[v][version]; ok {
|
||||
_ = old.finder.Close()
|
||||
}
|
||||
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)}
|
||||
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder), path: path}
|
||||
r.latest[v] = version
|
||||
return nil
|
||||
}
|
||||
@@ -202,6 +204,30 @@ func (r *Registry) Versions(v Variant) []string {
|
||||
return versions
|
||||
}
|
||||
|
||||
// DictBytes returns the raw serialized DAWG for the (variant, version) pair,
|
||||
// re-read from the file it was loaded from — the same immutable bytes the solver
|
||||
// holds. It backs the client-side dictionary download for the local move
|
||||
// preview. It returns ErrUnknownVariant or ErrUnknownVersion when that dictionary
|
||||
// is not resident, and wraps any read error. The file is read outside the lock.
|
||||
func (r *Registry) DictBytes(v Variant, version string) ([]byte, error) {
|
||||
r.mu.RLock()
|
||||
versions, ok := r.entries[v]
|
||||
if !ok {
|
||||
r.mu.RUnlock()
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
|
||||
}
|
||||
e, ok := versions[version]
|
||||
r.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
|
||||
}
|
||||
data, err := os.ReadFile(e.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("engine: read %s/%s dictionary bytes from %s: %w", v, version, e.path, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Lookup reports whether word is present in the (variant, version) dictionary,
|
||||
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
|
||||
// ErrUnknownVersion when that dictionary is not resident, and an error when word
|
||||
|
||||
@@ -1649,6 +1649,14 @@ func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bo
|
||||
return present, nil
|
||||
}
|
||||
|
||||
// DictBytes returns the raw serialized dictionary for the (variant, version) pair
|
||||
// from the registry, backing the client-side dictionary download used by the
|
||||
// local move preview. It surfaces engine.ErrUnknownVariant /
|
||||
// engine.ErrUnknownVersion when that dictionary is not resident.
|
||||
func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, error) {
|
||||
return svc.registry.DictBytes(variant, version)
|
||||
}
|
||||
|
||||
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
||||
// allowance plus the profile wallet.
|
||||
func hintsRemaining(allowance, used, wallet int) int {
|
||||
|
||||
@@ -90,6 +90,9 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/games/:id/draft", s.handleGetDraft)
|
||||
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
||||
u.POST("/games/:id/hide", s.handleHideGame)
|
||||
// Raw dictionary download for the client-side local move preview, keyed by
|
||||
// the game's pinned (variant, version); immutable, so cached hard.
|
||||
u.GET("/dict/:variant/:version", s.handleDictBytes)
|
||||
}
|
||||
if s.feedback != nil {
|
||||
u.POST("/feedback", s.handleFeedbackSubmit)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// handleDictBytes streams the raw serialized dictionary for a (variant, version)
|
||||
// pair so the client can validate and score moves locally — the local move
|
||||
// preview — instead of a network round trip per tile arrangement. It is reached
|
||||
// only through the gateway's session-gated /dict route (which resolves the
|
||||
// X-User-ID this group requires), and served with a long immutable cache lifetime
|
||||
// because a published dictionary version never changes. An unknown variant or a
|
||||
// version that is not resident is a 404.
|
||||
func (s *Server) handleDictBytes(c *gin.Context) {
|
||||
variant, err := engine.ParseVariant(c.Param("variant"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "unknown variant"})
|
||||
return
|
||||
}
|
||||
data, err := s.games.DictBytes(variant, c.Param("version"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "dictionary not found"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||
}
|
||||
Reference in New Issue
Block a user