diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go index f86088d..8e5934d 100644 --- a/backend/internal/server/banner.go +++ b/backend/internal/server/banner.go @@ -8,6 +8,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/ads" + "scrabble/backend/internal/engine" "scrabble/backend/internal/notify" ) @@ -56,9 +57,34 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi r := profileResponseFor(acc) r.Banner = s.bannerFor(ctx, acc) s.fillLinkedIdentities(ctx, &r, acc.ID) + r.DictVersions = s.currentDictVersions() return r } +// currentDictVersions reports the current dictionary version of every game variant with a +// resident dictionary, as engine.Variant stable labels. It backs profileResponse.DictVersions +// so an offline-capable client preloads the matching dawg per variant off the cold-start +// profile. A variant without a loaded dictionary is omitted (Registry.Latest reports +// ErrUnknownVariant); the returned slice is nil only when no variant is resident. +func (s *Server) currentDictVersions() []dictVersion { + if s.registry == nil { + return nil // no dictionary registry wired (e.g. a minimal test server): advertise none + } + variants := []engine.Variant{engine.VariantEnglish, engine.VariantRussianScrabble, engine.VariantErudit} + out := make([]dictVersion, 0, len(variants)) + for _, v := range variants { + version, _, err := s.registry.Latest(v) + if err != nil { + continue + } + out = append(out, dictVersion{Variant: v.String(), Version: version}) + } + if len(out) == 0 { + return nil + } + return out +} + // fillLinkedIdentities sets the profile's confirmed email address and platform-linked // flags from the account's identities, so the client offers the right link / unlink / // change-email controls. A read failure leaves them zero (no controls), logged as a diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 071e966..08620d8 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -63,6 +63,20 @@ type profileResponse struct { Email string `json:"email"` TelegramLinked bool `json:"telegram_linked"` VkLinked bool `json:"vk_linked"` + // DictVersions lists the current dictionary version per game variant (engine.Variant + // stable labels). An installed PWA preparing for offline play reads it to preload the + // right dawg per enabled variant off this cold-start response, without an extra request. + // Filled outside the pure projection (it reads the dictionary registry), so it is empty + // for callers that build the DTO without a Server. See Server.profileResponse. + DictVersions []dictVersion `json:"dict_versions,omitempty"` +} + +// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current +// dictionary version. profileResponse.DictVersions carries the set so an offline-capable +// client preloads the matching dawg per variant. +type dictVersion struct { + Variant string `json:"variant"` + Version string `json:"version"` } // tileDTO is one placed (or to-place) tile. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f1e113f..f46ce2d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1243,7 +1243,16 @@ browser-language detection — crawlers render with arbitrary languages). The fa mock build. The worker intercepts only top-level navigations (network-first with a cached-shell fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's installability requirement (a registered SW, needed for install on Android) and is the single -growth point for a future opt-in offline mode. The gateway registers the `.webmanifest` MIME type +growth point for the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings +toggle — distinct from the transient gateway-reachability signal — that tints the header blue with +an *Offline* chip and confines play to on-device `vs_ai` games. To have data ready before the +switch, the **Profile advertises the current dictionary version per variant** (`dict_versions`, +filled from the registry on the existing cold-start profile request — no extra round-trip), and an +eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries +— on lobby entry and on a variant-preference change — through the same three-tier loader, retried +with backoff and honouring the session miss-breaker; the move generator, the loader and the preload +orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in +the ad-banner slot. The gateway registers the `.webmanifest` MIME type in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served `immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are `no-cache` so a new deploy is picked up — both containers apply the same caching. An diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 9aa357a..0eca8d4 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -42,6 +42,15 @@ type ProfileResp struct { Email string `json:"email"` TelegramLinked bool `json:"telegram_linked"` VkLinked bool `json:"vk_linked"` + // DictVersions is the current dictionary version per game variant, forwarded verbatim + // into the Profile payload so an offline-capable client preloads the matching dawg. + DictVersions []DictVersion `json:"dict_versions,omitempty"` +} + +// DictVersion pairs a game variant's stable label with its current dictionary version. +type DictVersion struct { + Variant string `json:"variant"` + Version string `json:"version"` } // BannerResp is the advertising-banner block of an eligible viewer's profile: the diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 3258ba4..7ced0cc 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -95,6 +95,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte { banner = encodeBanner(b, *p.Banner) } prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector) + dictVersions := encodeDictVersions(b, p.DictVersions) fb.ProfileStart(b) fb.ProfileAddUserId(b, uid) fb.ProfileAddDisplayName(b, name) @@ -111,6 +112,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte { fb.ProfileAddEmail(b, email) fb.ProfileAddTelegramLinked(b, p.TelegramLinked) fb.ProfileAddVkLinked(b, p.VkLinked) + if dictVersions != 0 { + fb.ProfileAddDictVersions(b, dictVersions) + } if p.Banner != nil { fb.ProfileAddBanner(b, banner) } @@ -118,6 +122,31 @@ func encodeProfile(p backendclient.ProfileResp) []byte { return b.FinishedBytes() } +// encodeDictVersions builds the Profile's dict_versions vector — one DictVersion table per +// variant/version pair — and returns its offset, or 0 when there are none (the field is then +// omitted). Every child table and its strings are created before the vector is opened, per the +// FlatBuffers rule against nesting a table under one still being built; the caller invokes it +// before ProfileStart for the same reason. +func encodeDictVersions(b *flatbuffers.Builder, dvs []backendclient.DictVersion) flatbuffers.UOffsetT { + if len(dvs) == 0 { + return 0 + } + offsets := make([]flatbuffers.UOffsetT, len(dvs)) + for i, dv := range dvs { + variant := b.CreateString(dv.Variant) + version := b.CreateString(dv.Version) + fb.DictVersionStart(b) + fb.DictVersionAddVariant(b, variant) + fb.DictVersionAddVersion(b, version) + offsets[i] = fb.DictVersionEnd(b) + } + fb.ProfileStartDictVersionsVector(b, len(offsets)) + for i := len(offsets) - 1; i >= 0; i-- { + b.PrependUOffsetT(offsets[i]) + } + return b.EndVector(len(offsets)) +} + // optString creates a FlatBuffers string for a non-empty value, or 0 to omit the // optional field (the client then reads it as absent). func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 636ab8f..5a9e01b 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -223,6 +223,13 @@ table BannerInfo { // suppresses out-of-app platform push, leaving only the in-app live stream. banner // carries the advertising-banner block for an eligible viewer, absent otherwise // (all added trailing — backward-compatible). +// DictVersion is one variant's current dictionary version, carried on the profile so an offline +// client learns it from an existing cold-start request (no extra call for a rarely-used feature). +table DictVersion { + variant:string; + version:string; +} + table Profile { user_id:string; display_name:string; @@ -245,6 +252,10 @@ table Profile { email:string; telegram_linked:bool; vk_linked:bool; + // dict_versions carries each variant's current dictionary version so an offline client can + // preload the right dictionary and pin a new local game without a separate request (added + // trailing — backward-compatible). + dict_versions:[DictVersion]; } // BlockStatus reports the caller's current manual block. The UI fetches it after any operation diff --git a/pkg/fbs/scrabblefb/DictVersion.go b/pkg/fbs/scrabblefb/DictVersion.go new file mode 100644 index 0000000..31e60e9 --- /dev/null +++ b/pkg/fbs/scrabblefb/DictVersion.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type DictVersion struct { + _tab flatbuffers.Table +} + +func GetRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &DictVersion{} + x.Init(buf, n+offset) + return x +} + +func FinishDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &DictVersion{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *DictVersion) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *DictVersion) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *DictVersion) Variant() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *DictVersion) Version() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func DictVersionStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func DictVersionAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0) +} +func DictVersionAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(version), 0) +} +func DictVersionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index 3bb715b..82f33d0 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -211,8 +211,28 @@ func (rcv *Profile) MutateVkLinked(n bool) bool { return rcv._tab.MutateBoolSlot(34, n) } +func (rcv *Profile) DictVersions(obj *DictVersion, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(36)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *Profile) DictVersionsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(36)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + func ProfileStart(builder *flatbuffers.Builder) { - builder.StartObject(16) + builder.StartObject(17) } func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) @@ -265,6 +285,12 @@ func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool) func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) { builder.PrependBoolSlot(15, vkLinked, false) } +func ProfileAddDictVersions(builder *flatbuffers.Builder, dictVersions flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(16, flatbuffers.UOffsetT(dictVersions), 0) +} +func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 0403740..2cf27c9 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -20,10 +20,12 @@ import { join } from 'node:path'; const DIST = 'dist'; // Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview -// wiring, then to 112 for the PWA install feature (the install CTA + the pwa detection / -// service-worker registration live in the app entry; the heavy dict subsystem stays in lazy -// chunks). Its scoped CSS lands in the CSS chunk, not this JS budget. -const BUDGET = { app: 112, shared: 30, landing: 5 }; +// wiring, to 112 for the PWA install feature, then to 113 for the offline-mode wiring: the +// dictionary-preload trigger and the offline-state reactives live in the app entry (the Header +// reads them, the lobby/profile screens fire the trigger), while the heavy parts — the dict +// loader, the move generator and the preload orchestration — stay in lazy chunks. Scoped CSS +// lands in the CSS chunk, not this JS budget. +const BUDGET = { app: 113, shared: 30, landing: 5 }; // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // local file (e.g. the Telegram SDK loaded from a CDN) or is missing. diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 6e14256..3e385f2 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -1,7 +1,7 @@