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
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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': 'Инфо',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user