Merge pull request 'feat(offline): advertise dict versions + background dictionary preload' (#196) from feature/offline-dict-preload into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m42s

This commit was merged in pull request #196.
This commit is contained in:
2026-07-06 08:45:41 +00:00
27 changed files with 632 additions and 12 deletions
+26
View File
@@ -8,6 +8,7 @@ import (
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
"scrabble/backend/internal/ads" "scrabble/backend/internal/ads"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify" "scrabble/backend/internal/notify"
) )
@@ -56,9 +57,34 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
r := profileResponseFor(acc) r := profileResponseFor(acc)
r.Banner = s.bannerFor(ctx, acc) r.Banner = s.bannerFor(ctx, acc)
s.fillLinkedIdentities(ctx, &r, acc.ID) s.fillLinkedIdentities(ctx, &r, acc.ID)
r.DictVersions = s.currentDictVersions()
return r 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 // 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 / // 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 // change-email controls. A read failure leaves them zero (no controls), logged as a
+14
View File
@@ -63,6 +63,20 @@ type profileResponse struct {
Email string `json:"email"` Email string `json:"email"`
TelegramLinked bool `json:"telegram_linked"` TelegramLinked bool `json:"telegram_linked"`
VkLinked bool `json:"vk_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. // tileDTO is one placed (or to-place) tile.
+10 -1
View File
@@ -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 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 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 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 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 `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 `no-cache` so a new deploy is picked up — both containers apply the same caching. An
+9
View File
@@ -42,6 +42,15 @@ type ProfileResp struct {
Email string `json:"email"` Email string `json:"email"`
TelegramLinked bool `json:"telegram_linked"` TelegramLinked bool `json:"telegram_linked"`
VkLinked bool `json:"vk_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 // BannerResp is the advertising-banner block of an eligible viewer's profile: the
+29
View File
@@ -95,6 +95,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
banner = encodeBanner(b, *p.Banner) banner = encodeBanner(b, *p.Banner)
} }
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector) prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
dictVersions := encodeDictVersions(b, p.DictVersions)
fb.ProfileStart(b) fb.ProfileStart(b)
fb.ProfileAddUserId(b, uid) fb.ProfileAddUserId(b, uid)
fb.ProfileAddDisplayName(b, name) fb.ProfileAddDisplayName(b, name)
@@ -111,6 +112,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
fb.ProfileAddEmail(b, email) fb.ProfileAddEmail(b, email)
fb.ProfileAddTelegramLinked(b, p.TelegramLinked) fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
fb.ProfileAddVkLinked(b, p.VkLinked) fb.ProfileAddVkLinked(b, p.VkLinked)
if dictVersions != 0 {
fb.ProfileAddDictVersions(b, dictVersions)
}
if p.Banner != nil { if p.Banner != nil {
fb.ProfileAddBanner(b, banner) fb.ProfileAddBanner(b, banner)
} }
@@ -118,6 +122,31 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
return b.FinishedBytes() 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 // optString creates a FlatBuffers string for a non-empty value, or 0 to omit the
// optional field (the client then reads it as absent). // optional field (the client then reads it as absent).
func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT { func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT {
+11
View File
@@ -223,6 +223,13 @@ table BannerInfo {
// suppresses out-of-app platform push, leaving only the in-app live stream. banner // 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 // carries the advertising-banner block for an eligible viewer, absent otherwise
// (all added trailing — backward-compatible). // (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 { table Profile {
user_id:string; user_id:string;
display_name:string; display_name:string;
@@ -245,6 +252,10 @@ table Profile {
email:string; email:string;
telegram_linked:bool; telegram_linked:bool;
vk_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 // BlockStatus reports the caller's current manual block. The UI fetches it after any operation
+71
View File
@@ -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()
}
+27 -1
View File
@@ -211,8 +211,28 @@ func (rcv *Profile) MutateVkLinked(n bool) bool {
return rcv._tab.MutateBoolSlot(34, n) 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) { func ProfileStart(builder *flatbuffers.Builder) {
builder.StartObject(16) builder.StartObject(17)
} }
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) 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) { func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
builder.PrependBoolSlot(15, vkLinked, false) 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 { func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+6 -4
View File
@@ -20,10 +20,12 @@ import { join } from 'node:path';
const DIST = 'dist'; const DIST = 'dist';
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview // 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 / // wiring, to 112 for the PWA install feature, then to 113 for the offline-mode wiring: the
// service-worker registration live in the app entry; the heavy dict subsystem stays in lazy // dictionary-preload trigger and the offline-state reactives live in the app entry (the Header
// chunks). Its scoped CSS lands in the CSS chunk, not this JS budget. // reads them, the lobby/profile screens fire the trigger), while the heavy parts — the dict
const BUDGET = { app: 112, shared: 30, landing: 5 }; // 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 // 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. // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+18 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte'; import { offlineMode, dictPreloadWarning } from '../lib/offline.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { app, openDebug } from '../lib/app.svelte'; import { app, openDebug } from '../lib/app.svelte';
import Spinner from './Spinner.svelte'; import Spinner from './Spinner.svelte';
@@ -58,7 +58,11 @@
coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the
dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears, dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears,
per this same condition, once onboarding closes. --> per this same condition, once onboarding closes. -->
{#if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive} {#if dictPreloadWarning.active}
<!-- A background dictionary preload for offline readiness failed (poor connection): a soft
notice takes the ad banner's slot until a later preload succeeds and clears it. -->
<p class="preload-warn" role="alert">{t('offline.preloadWarning')}</p>
{:else if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
<AdBanner <AdBanner
campaigns={app.profile.banner.campaigns} campaigns={app.profile.banner.campaigns}
timings={app.profile.banner.timings} timings={app.profile.banner.timings}
@@ -142,6 +146,18 @@
border-radius: 999px; border-radius: 999px;
white-space: nowrap; white-space: nowrap;
} }
/* The offline-readiness preload warning: a soft, muted strip in the ad banner's slot, sized like
the banner region so the bar does not jump when it appears. */
.preload-warn {
margin: 0;
padding: 7px var(--pad);
font-size: 0.78rem;
line-height: 1.3;
text-align: center;
color: var(--text-muted);
background: var(--surface-2);
border-top: 1px solid var(--border);
}
.back { .back {
background: none; background: none;
border: none; border: none;
+1
View File
@@ -17,6 +17,7 @@ export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
export { CheckWordRequest } from './scrabblefb/check-word-request.js'; export { CheckWordRequest } from './scrabblefb/check-word-request.js';
export { ComplaintRequest } from './scrabblefb/complaint-request.js'; export { ComplaintRequest } from './scrabblefb/complaint-request.js';
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js'; export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
export { DictVersion } from './scrabblefb/dict-version.js';
export { DraftRequest } from './scrabblefb/draft-request.js'; export { DraftRequest } from './scrabblefb/draft-request.js';
export { DraftView } from './scrabblefb/draft-view.js'; export { DraftView } from './scrabblefb/draft-view.js';
export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js'; export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js';
+60
View File
@@ -0,0 +1,60 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class DictVersion {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):DictVersion {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsDictVersion(bb:flatbuffers.ByteBuffer, obj?:DictVersion):DictVersion {
return (obj || new DictVersion()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsDictVersion(bb:flatbuffers.ByteBuffer, obj?:DictVersion):DictVersion {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new DictVersion()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
variant():string|null
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
variant(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
version():string|null
version(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
version(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startDictVersion(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, variantOffset, 0);
}
static addVersion(builder:flatbuffers.Builder, versionOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, versionOffset, 0);
}
static endDictVersion(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createDictVersion(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, versionOffset:flatbuffers.Offset):flatbuffers.Offset {
DictVersion.startDictVersion(builder);
DictVersion.addVariant(builder, variantOffset);
DictVersion.addVersion(builder, versionOffset);
return DictVersion.endDictVersion(builder);
}
}
+28 -1
View File
@@ -3,6 +3,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { BannerInfo } from '../scrabblefb/banner-info.js'; import { BannerInfo } from '../scrabblefb/banner-info.js';
import { DictVersion } from '../scrabblefb/dict-version.js';
export class Profile { export class Profile {
@@ -124,8 +125,18 @@ vkLinked():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
} }
dictVersions(index: number, obj?:DictVersion):DictVersion|null {
const offset = this.bb!.__offset(this.bb_pos, 36);
return offset ? (obj || new DictVersion()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
dictVersionsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 36);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startProfile(builder:flatbuffers.Builder) { static startProfile(builder:flatbuffers.Builder) {
builder.startObject(16); builder.startObject(17);
} }
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -204,6 +215,22 @@ static addVkLinked(builder:flatbuffers.Builder, vkLinked:boolean) {
builder.addFieldInt8(15, +vkLinked, +false); builder.addFieldInt8(15, +vkLinked, +false);
} }
static addDictVersions(builder:flatbuffers.Builder, dictVersionsOffset:flatbuffers.Offset) {
builder.addFieldOffset(16, dictVersionsOffset, 0);
}
static createDictVersionsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startDictVersionsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset { static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
+22
View File
@@ -712,6 +712,28 @@ describe('codec', () => {
expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']); expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']);
}); });
it('decodes the profile dictionary versions into a per-variant map', () => {
const b = new Builder(128);
const uid = b.createString('u-1');
const en = fb.DictVersion.createDictVersion(b, b.createString('scrabble_en'), b.createString('v1.3.0'));
const er = fb.DictVersion.createDictVersion(b, b.createString('erudit_ru'), b.createString('v1.2.0'));
const vec = fb.Profile.createDictVersionsVector(b, [en, er]);
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
fb.Profile.addDictVersions(b, vec);
b.finish(fb.Profile.endProfile(b));
expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({ scrabble_en: 'v1.3.0', erudit_ru: 'v1.2.0' });
});
it('decodes an absent dict_versions vector to an empty map', () => {
const b = new Builder(64);
const uid = b.createString('u-1');
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
b.finish(fb.Profile.endProfile(b));
expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({});
});
it('encodes the update-profile variant preferences', () => { it('encodes the update-profile variant preferences', () => {
const buf = encodeUpdateProfile({ const buf = encodeUpdateProfile({
displayName: 'Kaya', displayName: 'Kaya',
+16
View File
@@ -364,9 +364,25 @@ export function decodeProfile(buf: Uint8Array): Profile {
email: s(p.email()), email: s(p.email()),
telegramLinked: p.telegramLinked(), telegramLinked: p.telegramLinked(),
vkLinked: p.vkLinked(), vkLinked: p.vkLinked(),
dictVersions: decodeDictVersions(p),
}; };
} }
// decodeDictVersions reads the profile's per-variant current dictionary versions into a
// variant-keyed map, so the offline preloader can look up the version for each enabled
// variant. An entry with an unknown variant or empty version is skipped.
function decodeDictVersions(p: fb.Profile): Partial<Record<Variant, string>> {
const out: Partial<Record<Variant, string>> = {};
for (let i = 0; i < p.dictVersionsLength(); i++) {
const dv = p.dictVersions(i);
if (!dv) continue;
const variant = s(dv.variant()) as Variant;
const version = s(dv.version());
if (variant && version) out[variant] = version;
}
return out;
}
// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants // decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
// the player enabled in Settings). // the player enabled in Settings).
function decodeVariantPreferences(p: fb.Profile): Variant[] { function decodeVariantPreferences(p: fb.Profile): Variant[] {
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { preloadDicts } from './preload';
import type { Variant } from '../model';
import type { Dawg } from './dawg';
// A non-null stand-in for a loaded reader — preloadDicts only checks getDawg's result for null.
const DAWG = {} as Dawg;
const noSleep = (): Promise<void> => Promise.resolve();
describe('preloadDicts', () => {
it('fetches every enabled variant that has a known version', async () => {
const calls: string[] = [];
const res = await preloadDicts({ scrabble_en: 'v1', scrabble_ru: 'v2', erudit_ru: 'v3' }, ['scrabble_en', 'erudit_ru'], {
getDawg: async (v: Variant, ver: string) => {
calls.push(`${v}@${ver}`);
return DAWG;
},
disabled: () => false,
sleep: noSleep,
});
expect(res.ok).toEqual(['scrabble_en', 'erudit_ru']);
expect(res.failed).toEqual([]);
expect(calls).toEqual(['scrabble_en@v1', 'erudit_ru@v3']);
});
it('marks a variant with no known version as failed without fetching it', async () => {
const calls: string[] = [];
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en', 'scrabble_ru'], {
getDawg: async (v: Variant) => {
calls.push(v);
return DAWG;
},
disabled: () => false,
sleep: noSleep,
});
expect(res.ok).toEqual(['scrabble_en']);
expect(res.failed).toEqual(['scrabble_ru']);
expect(calls).toEqual(['scrabble_en']);
});
it('retries a transient failure with linear backoff, then succeeds', async () => {
let attempts = 0;
const waits: number[] = [];
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
getDawg: async () => (++attempts >= 3 ? DAWG : null),
disabled: () => false,
sleep: async (ms: number) => void waits.push(ms),
retries: 3,
backoffMs: 100,
});
expect(res.ok).toEqual(['scrabble_en']);
expect(attempts).toBe(3);
expect(waits).toEqual([100, 200]);
});
it('gives up a persistent failure after the retry budget', async () => {
let attempts = 0;
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
getDawg: async () => {
attempts++;
return null;
},
disabled: () => false,
sleep: noSleep,
retries: 2,
});
expect(res.failed).toEqual(['scrabble_en']);
expect(res.ok).toEqual([]);
expect(attempts).toBe(3);
});
it('stops retrying once the session miss-breaker trips', async () => {
let attempts = 0;
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
getDawg: async () => {
attempts++;
return null;
},
disabled: () => true,
sleep: noSleep,
retries: 5,
});
expect(res.failed).toEqual(['scrabble_en']);
expect(attempts).toBe(1);
});
});
+68
View File
@@ -0,0 +1,68 @@
// Background dictionary preload for offline readiness. An installed PWA with a confirmed email
// warms the dictionaries for the player's enabled variants while online, so a later switch to
// deliberate offline mode has the data it needs. The pure preloadDicts here takes its side effects
// (getDawg, the session miss-breaker, sleep) as dependencies, so it unit-tests in the node env. The
// eligibility and once/online guard live in offline.svelte.ts (kickDictPreload); the browser
// orchestration that supplies the real side effects and raises the in-lobby warning lives in
// preloadrun.ts, which offline.svelte.ts imports dynamically so neither the loader nor the
// generator is pulled into the main bundle.
import type { Variant } from '../model';
import type { Dawg } from './dawg';
/** PreloadDeps injects preloadDicts's side effects so the logic stays pure and testable. */
export interface PreloadDeps {
/** getDawg resolves the (variant, version) reader, serving memory/IndexedDB before the network,
* or null on any miss — mirrors the in-game loader. */
getDawg: (variant: Variant, version: string) => Promise<Dawg | null>;
/** disabled reports whether the session dictionary miss-breaker has tripped (too many network
* misses this session); when it has, a network fetch will not recover, so retries stop. */
disabled: () => boolean;
/** sleep waits between retries; defaults to a real timer. */
sleep?: (ms: number) => Promise<void>;
/** retries is the number of extra attempts after the first (default 2). */
retries?: number;
/** backoffMs is the base linear backoff between attempts (default 800). */
backoffMs?: number;
}
/** PreloadResult reports which enabled variants ended up available (ok) and which are still
* missing (failed) after the preload — the caller surfaces a warning when failed is non-empty. */
export interface PreloadResult {
ok: Variant[];
failed: Variant[];
}
/**
* preloadDicts fetches, via getDawg, the dictionary for each enabled variant that has a known
* version, retrying transient misses with linear backoff. A variant with no known version, or one
* still missing after the retry budget, lands in failed; the rest in ok. It never throws and stops
* retrying a variant once the session miss-breaker (disabled) trips, since the network will not
* recover this session — a cached dictionary is still served by getDawg regardless.
*/
export async function preloadDicts(
versions: Partial<Record<Variant, string>>,
enabled: readonly Variant[],
deps: PreloadDeps,
): Promise<PreloadResult> {
const sleep = deps.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms)));
const retries = deps.retries ?? 2;
const backoffMs = deps.backoffMs ?? 800;
const ok: Variant[] = [];
const failed: Variant[] = [];
for (const variant of enabled) {
const version = versions[variant];
if (!version) {
failed.push(variant);
continue;
}
let dawg: Dawg | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
dawg = await deps.getDawg(variant, version);
if (dawg || deps.disabled()) break;
if (attempt < retries) await sleep(backoffMs * (attempt + 1));
}
(dawg ? ok : failed).push(variant);
}
return { ok, failed };
}
+24
View File
@@ -0,0 +1,24 @@
// Browser-only orchestration for the offline dictionary preload. Kept apart from the pure
// preloadDicts (preload.ts) — which unit-tests in the node env — and lazily imported by
// offline.svelte.ts's kickDictPreload, so the dict loader/generator it pulls in stays out of the
// main bundle. It supplies the real side effects (getDawg, the session miss-breaker) and raises the
// in-lobby notice when a first-lobby preload cannot fetch every enabled variant's dictionary.
import type { Profile } from '../model';
import { preloadDicts } from './preload';
import { getDawg, dictLoadingDisabled } from '../dict';
import { setDictPreloadWarning } from '../offline.svelte';
/**
* runPreload warms the dictionaries for the profile's enabled variants (using the versions the
* profile advertises) via the real dict loader, so a later switch to offline mode has the data.
* When warnOnFail is set (the first lobby entry), it raises the in-lobby notice if a variant is
* still missing afterwards, and clears it on a run where every variant is available.
*/
export async function runPreload(prof: Profile, warnOnFail: boolean): Promise<void> {
const res = await preloadDicts(prof.dictVersions, prof.variantPreferences, {
getDawg,
disabled: dictLoadingDisabled,
});
if (warnOnFail) setDictPreloadWarning(res.failed.length > 0);
}
+1
View File
@@ -217,6 +217,7 @@ export const en = {
'settings.offlineMode': 'Play mode', 'settings.offlineMode': 'Play mode',
'settings.online': 'Online', 'settings.online': 'Online',
'settings.offline': 'Offline', 'settings.offline': 'Offline',
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
'about.title': 'About', 'about.title': 'About',
'about.tab': 'Info', 'about.tab': 'Info',
+1
View File
@@ -217,6 +217,7 @@ export const ru: Record<MessageKey, string> = {
'settings.offlineMode': 'Режим игры', 'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн', 'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн', 'settings.offline': 'Оффлайн',
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
'about.title': 'О программе', 'about.title': 'О программе',
'about.tab': 'Инфо', 'about.tab': 'Инфо',
+3
View File
@@ -42,6 +42,9 @@ export const PROFILE: Profile = {
email: 'you@example.com', email: 'you@example.com',
telegramLinked: false, telegramLinked: false,
vkLinked: false, vkLinked: false,
// Every variant's current dictionary version, as the backend advertises it on the profile —
// the offline preloader targets `variant@version`.
dictVersions: { erudit_ru: 'v1.3.0', scrabble_ru: 'v1.3.0', scrabble_en: 'v1.3.0' },
}; };
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile // Seed social/account data for the mock (pnpm start + Playwright). The mock profile
+4
View File
@@ -161,6 +161,10 @@ export interface Profile {
/** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */ /** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */
telegramLinked: boolean; telegramLinked: boolean;
vkLinked: boolean; vkLinked: boolean;
/** Current dictionary version per game variant (deployment-wide, not per-account). An
* offline-capable PWA reads it to preload the matching dawg for each enabled variant off
* this cold-start response. Empty only in a degenerate deployment with no resident dictionary. */
dictVersions: Partial<Record<Variant, string>>;
} }
/** Banner is the advertising-banner block of an eligible viewer's profile. */ /** Banner is the advertising-banner block of an eligible viewer's profile. */
+57 -1
View File
@@ -3,7 +3,11 @@
// Settings toggle is the source of truth), distinct from connection.svelte.ts's transient // Settings toggle is the source of truth), distinct from connection.svelte.ts's transient
// gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts. // gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts.
import { loadOfflinePref, saveOfflinePref } from './offline'; import { loadOfflinePref, saveOfflinePref, offlinePreloadEligible } from './offline';
import { isStandalone } from './pwa';
import { insideTelegram } from './telegram';
import { insideVK } from './vk';
import type { Profile } from './model';
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription). // Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription).
let active = $state(loadOfflinePref()); let active = $state(loadOfflinePref());
@@ -21,3 +25,55 @@ export function setOfflineMode(on: boolean): void {
active = on; active = on;
saveOfflinePref(on); saveOfflinePref(on);
} }
// The dict-preload warning: true when a first-lobby background preload could not fetch every
// enabled variant's dictionary (typically a poor connection), so offline mode may be incomplete.
// The lobby shows a notice in place of the ad banner while it holds.
let preloadWarn = $state(false);
/** dictPreloadWarning exposes the reactive preload-failure flag; read it in markup / $derived. */
export const dictPreloadWarning = {
/** active is true while a first-lobby dictionary preload has left a variant unavailable. */
get active(): boolean {
return preloadWarn;
},
};
/** setDictPreloadWarning raises or clears the in-lobby preload-failure notice. */
export function setDictPreloadWarning(on: boolean): void {
preloadWarn = on;
}
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby
// mount or a variant-preference change) can start another. Module-scoped so mounts do not stack.
let preloadInFlight = false;
/**
* kickDictPreload starts a background preload of the enabled variants' dictionaries for an
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later
* switch to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain
* browser tab, without a confirmed email, while offline, or when a preload is already running;
* getDawg's caching makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a
* fetch failure raises the in-lobby notice, and a later successful run clears it. The dict loader
* and generator are imported dynamically, so neither is pulled into the main bundle.
*/
export function kickDictPreload(prof: Profile | null, warnOnFail = false): void {
if (preloadInFlight || !prof) return;
const eligible = offlinePreloadEligible({
hasEmail: !!prof.email,
standalone: isStandalone(),
inTelegram: insideTelegram(),
inVK: insideVK(),
online: typeof navigator === 'undefined' || navigator.onLine !== false,
});
if (!eligible) return;
preloadInFlight = true;
void import('./dict/preloadrun')
.then((m) => m.runPreload(prof, warnOnFail))
.catch(() => {
/* best-effort warmup — a failed dynamic import just leaves offline data unprimed */
})
.finally(() => {
preloadInFlight = false;
});
}
+11 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts } from './offline'; import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible } from './offline';
import type { Variant } from './model'; import type { Variant } from './model';
// A minimal in-memory localStorage for the persistence tests (node has none). // A minimal in-memory localStorage for the persistence tests (node has none).
@@ -35,4 +35,14 @@ describe('offline mode helpers', () => {
const has = (v: Variant): boolean => v === 'scrabble_en'; const has = (v: Variant): boolean => v === 'scrabble_en';
expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']); expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']);
}); });
it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => {
const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true };
expect(offlinePreloadEligible(base)).toBe(true);
expect(offlinePreloadEligible({ ...base, hasEmail: false })).toBe(false);
expect(offlinePreloadEligible({ ...base, standalone: false })).toBe(false);
expect(offlinePreloadEligible({ ...base, inTelegram: true })).toBe(false);
expect(offlinePreloadEligible({ ...base, inVK: true })).toBe(false);
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
});
}); });
+17
View File
@@ -41,3 +41,20 @@ export function offlineReady(enabled: readonly Variant[], hasDict: (v: Variant)
export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] { export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] {
return enabled.filter((v) => !hasDict(v)); return enabled.filter((v) => !hasDict(v));
} }
/**
* offlinePreloadEligible reports whether a background dictionary preload should run in this
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
* with a confirmed email, currently online. Elsewhere the preload is wasted bandwidth — the context
* has no offline mode to prepare for — so kickDictPreload skips it. Mirrors the Settings offline
* toggle's eligibility so the two never disagree.
*/
export function offlinePreloadEligible(opts: {
hasEmail: boolean;
standalone: boolean;
inTelegram: boolean;
inVK: boolean;
online: boolean;
}): boolean {
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
}
+8 -1
View File
@@ -13,6 +13,7 @@
import { badgeKind } from '../lib/unread'; import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache'; import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload'; import { preloadGames } from '../lib/preload';
import { kickDictPreload } from '../lib/offline.svelte';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model'; import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants'; import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
@@ -51,7 +52,13 @@
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list // Warm the cache for the ongoing games so opening one from the lobby is instant. The list
// just loaded, so the connection is up; the call is non-blocking and re-running it on each // just loaded, so the connection is up; the call is non-blocking and re-running it on each
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped). // lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
if (connection.online) void preloadGames(games); if (connection.online) {
void preloadGames(games);
// Warm the offline dictionaries for an eligible install (PWA + email) so a later switch to
// offline mode already has the data; a no-op elsewhere, and cheap once cached. The first
// lobby entry surfaces a notice in place of the ad banner if a fetch fails.
kickDictPreload(app.profile, true);
}
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
+4
View File
@@ -16,6 +16,7 @@
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { insideTelegram, loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram'; import { insideTelegram, loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { insideVK } from '../lib/vk'; import { insideVK } from '../lib/vk';
import { kickDictPreload } from '../lib/offline.svelte';
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid'; import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { import {
@@ -158,6 +159,9 @@
notificationsInAppOnly, notificationsInAppOnly,
variantPreferences: variantPrefs, variantPreferences: variantPrefs,
}); });
// A variant-preference change may enable a variant whose dictionary is not yet cached; warm
// it in the background for offline readiness (quietly — no in-lobby notice for a top-up).
kickDictPreload(app.profile, false);
showToast(t('profile.saved')); showToast(t('profile.saved'));
} catch (e) { } catch (e) {
handleError(e); handleError(e);