diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go index f86088d..8e5934d 100644 --- a/backend/internal/server/banner.go +++ b/backend/internal/server/banner.go @@ -8,6 +8,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/ads" + "scrabble/backend/internal/engine" "scrabble/backend/internal/notify" ) @@ -56,9 +57,34 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi r := profileResponseFor(acc) r.Banner = s.bannerFor(ctx, acc) s.fillLinkedIdentities(ctx, &r, acc.ID) + r.DictVersions = s.currentDictVersions() return r } +// currentDictVersions reports the current dictionary version of every game variant with a +// resident dictionary, as engine.Variant stable labels. It backs profileResponse.DictVersions +// so an offline-capable client preloads the matching dawg per variant off the cold-start +// profile. A variant without a loaded dictionary is omitted (Registry.Latest reports +// ErrUnknownVariant); the returned slice is nil only when no variant is resident. +func (s *Server) currentDictVersions() []dictVersion { + if s.registry == nil { + return nil // no dictionary registry wired (e.g. a minimal test server): advertise none + } + variants := []engine.Variant{engine.VariantEnglish, engine.VariantRussianScrabble, engine.VariantErudit} + out := make([]dictVersion, 0, len(variants)) + for _, v := range variants { + version, _, err := s.registry.Latest(v) + if err != nil { + continue + } + out = append(out, dictVersion{Variant: v.String(), Version: version}) + } + if len(out) == 0 { + return nil + } + return out +} + // fillLinkedIdentities sets the profile's confirmed email address and platform-linked // flags from the account's identities, so the client offers the right link / unlink / // change-email controls. A read failure leaves them zero (no controls), logged as a diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 071e966..08620d8 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -63,6 +63,20 @@ type profileResponse struct { Email string `json:"email"` TelegramLinked bool `json:"telegram_linked"` VkLinked bool `json:"vk_linked"` + // DictVersions lists the current dictionary version per game variant (engine.Variant + // stable labels). An installed PWA preparing for offline play reads it to preload the + // right dawg per enabled variant off this cold-start response, without an extra request. + // Filled outside the pure projection (it reads the dictionary registry), so it is empty + // for callers that build the DTO without a Server. See Server.profileResponse. + DictVersions []dictVersion `json:"dict_versions,omitempty"` +} + +// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current +// dictionary version. profileResponse.DictVersions carries the set so an offline-capable +// client preloads the matching dawg per variant. +type dictVersion struct { + Variant string `json:"variant"` + Version string `json:"version"` } // tileDTO is one placed (or to-place) tile. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 9aa357a..0eca8d4 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -42,6 +42,15 @@ type ProfileResp struct { Email string `json:"email"` TelegramLinked bool `json:"telegram_linked"` VkLinked bool `json:"vk_linked"` + // DictVersions is the current dictionary version per game variant, forwarded verbatim + // into the Profile payload so an offline-capable client preloads the matching dawg. + DictVersions []DictVersion `json:"dict_versions,omitempty"` +} + +// DictVersion pairs a game variant's stable label with its current dictionary version. +type DictVersion struct { + Variant string `json:"variant"` + Version string `json:"version"` } // BannerResp is the advertising-banner block of an eligible viewer's profile: the diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 3258ba4..7ced0cc 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -95,6 +95,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte { banner = encodeBanner(b, *p.Banner) } prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector) + dictVersions := encodeDictVersions(b, p.DictVersions) fb.ProfileStart(b) fb.ProfileAddUserId(b, uid) fb.ProfileAddDisplayName(b, name) @@ -111,6 +112,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte { fb.ProfileAddEmail(b, email) fb.ProfileAddTelegramLinked(b, p.TelegramLinked) fb.ProfileAddVkLinked(b, p.VkLinked) + if dictVersions != 0 { + fb.ProfileAddDictVersions(b, dictVersions) + } if p.Banner != nil { fb.ProfileAddBanner(b, banner) } @@ -118,6 +122,31 @@ func encodeProfile(p backendclient.ProfileResp) []byte { return b.FinishedBytes() } +// encodeDictVersions builds the Profile's dict_versions vector — one DictVersion table per +// variant/version pair — and returns its offset, or 0 when there are none (the field is then +// omitted). Every child table and its strings are created before the vector is opened, per the +// FlatBuffers rule against nesting a table under one still being built; the caller invokes it +// before ProfileStart for the same reason. +func encodeDictVersions(b *flatbuffers.Builder, dvs []backendclient.DictVersion) flatbuffers.UOffsetT { + if len(dvs) == 0 { + return 0 + } + offsets := make([]flatbuffers.UOffsetT, len(dvs)) + for i, dv := range dvs { + variant := b.CreateString(dv.Variant) + version := b.CreateString(dv.Version) + fb.DictVersionStart(b) + fb.DictVersionAddVariant(b, variant) + fb.DictVersionAddVersion(b, version) + offsets[i] = fb.DictVersionEnd(b) + } + fb.ProfileStartDictVersionsVector(b, len(offsets)) + for i := len(offsets) - 1; i >= 0; i-- { + b.PrependUOffsetT(offsets[i]) + } + return b.EndVector(len(offsets)) +} + // optString creates a FlatBuffers string for a non-empty value, or 0 to omit the // optional field (the client then reads it as absent). func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 636ab8f..5a9e01b 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -223,6 +223,13 @@ table BannerInfo { // suppresses out-of-app platform push, leaving only the in-app live stream. banner // carries the advertising-banner block for an eligible viewer, absent otherwise // (all added trailing — backward-compatible). +// DictVersion is one variant's current dictionary version, carried on the profile so an offline +// client learns it from an existing cold-start request (no extra call for a rarely-used feature). +table DictVersion { + variant:string; + version:string; +} + table Profile { user_id:string; display_name:string; @@ -245,6 +252,10 @@ table Profile { email:string; telegram_linked:bool; vk_linked:bool; + // dict_versions carries each variant's current dictionary version so an offline client can + // preload the right dictionary and pin a new local game without a separate request (added + // trailing — backward-compatible). + dict_versions:[DictVersion]; } // BlockStatus reports the caller's current manual block. The UI fetches it after any operation diff --git a/pkg/fbs/scrabblefb/DictVersion.go b/pkg/fbs/scrabblefb/DictVersion.go new file mode 100644 index 0000000..31e60e9 --- /dev/null +++ b/pkg/fbs/scrabblefb/DictVersion.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type DictVersion struct { + _tab flatbuffers.Table +} + +func GetRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &DictVersion{} + x.Init(buf, n+offset) + return x +} + +func FinishDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &DictVersion{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *DictVersion) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *DictVersion) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *DictVersion) Variant() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *DictVersion) Version() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func DictVersionStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func DictVersionAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0) +} +func DictVersionAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(version), 0) +} +func DictVersionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index 3bb715b..82f33d0 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -211,8 +211,28 @@ func (rcv *Profile) MutateVkLinked(n bool) bool { return rcv._tab.MutateBoolSlot(34, n) } +func (rcv *Profile) DictVersions(obj *DictVersion, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(36)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *Profile) DictVersionsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(36)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + func ProfileStart(builder *flatbuffers.Builder) { - builder.StartObject(16) + builder.StartObject(17) } func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) @@ -265,6 +285,12 @@ func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool) func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) { builder.PrependBoolSlot(15, vkLinked, false) } +func ProfileAddDictVersions(builder *flatbuffers.Builder, dictVersions flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(16, flatbuffers.UOffsetT(dictVersions), 0) +} +func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index bc2d768..37e4cc7 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -17,6 +17,7 @@ export { ChatPostRequest } from './scrabblefb/chat-post-request.js'; export { CheckWordRequest } from './scrabblefb/check-word-request.js'; export { ComplaintRequest } from './scrabblefb/complaint-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 { DraftView } from './scrabblefb/draft-view.js'; export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js'; diff --git a/ui/src/gen/fbs/scrabblefb/dict-version.ts b/ui/src/gen/fbs/scrabblefb/dict-version.ts new file mode 100644 index 0000000..63b07e9 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/dict-version.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/profile.ts b/ui/src/gen/fbs/scrabblefb/profile.ts index 84d880c..13ca0ed 100644 --- a/ui/src/gen/fbs/scrabblefb/profile.ts +++ b/ui/src/gen/fbs/scrabblefb/profile.ts @@ -3,6 +3,7 @@ import * as flatbuffers from 'flatbuffers'; import { BannerInfo } from '../scrabblefb/banner-info.js'; +import { DictVersion } from '../scrabblefb/dict-version.js'; export class Profile { @@ -124,8 +125,18 @@ vkLinked():boolean { 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) { - builder.startObject(16); + builder.startObject(17); } static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { @@ -204,6 +215,22 @@ static addVkLinked(builder:flatbuffers.Builder, vkLinked:boolean) { 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 { const offset = builder.endObject(); return offset; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index bd8bb59..f0f716a 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -712,6 +712,28 @@ describe('codec', () => { 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', () => { const buf = encodeUpdateProfile({ displayName: 'Kaya', diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 8e62f08..3b5cc12 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -364,9 +364,25 @@ export function decodeProfile(buf: Uint8Array): Profile { email: s(p.email()), telegramLinked: p.telegramLinked(), 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> { + const out: Partial> = {}; + 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 // the player enabled in Settings). function decodeVariantPreferences(p: fb.Profile): Variant[] { diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 5fdfa86..ba5c112 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -42,6 +42,9 @@ export const PROFILE: Profile = { email: 'you@example.com', telegramLinked: 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 diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index a9f978e..b0bdfca 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -161,6 +161,10 @@ export interface Profile { /** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */ telegramLinked: 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>; } /** Banner is the advertising-banner block of an eligible viewer's profile. */