package server import ( "context" "github.com/google/uuid" "go.uber.org/zap" "scrabble/backend/internal/account" "scrabble/backend/internal/ads" "scrabble/backend/internal/engine" "scrabble/backend/internal/notify" "scrabble/backend/internal/payments" ) // bannerDTO is the advertising-banner block attached to an eligible viewer's // profile: the campaigns to rotate (each already resolved to the viewer's bot // language) and the global display timings the client rotator reads. It is // absent for a viewer who should see no banner. type bannerDTO struct { Campaigns []bannerCampaignDTO `json:"campaigns"` Timings bannerTimingsDTO `json:"timings"` } // bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show // weight (for the client's smooth weighted round-robin) and its messages, in // display order, already resolved to one language. The override_* colours are // present only for a campaign that carries a colour override: override_bg/fg/link // paint every theme, and the *_dark trio further overrides the dark theme (the // client resolves the cascade). They are absent for a plain campaign, which keeps // the neutral theme tokens. type bannerCampaignDTO struct { Weight int `json:"weight"` Messages []string `json:"messages"` OverrideBg string `json:"override_bg,omitempty"` OverrideFg string `json:"override_fg,omitempty"` OverrideLink string `json:"override_link,omitempty"` OverrideBgDark string `json:"override_bg_dark,omitempty"` OverrideFgDark string `json:"override_fg_dark,omitempty"` OverrideLinkDark string `json:"override_link_dark,omitempty"` } // bannerTimingsDTO mirrors ads.Timings on the wire. type bannerTimingsDTO struct { HoldMs int `json:"hold_ms"` EdgePauseMs int `json:"edge_pause_ms"` ScrollPxPerSec int `json:"scroll_px_per_sec"` FadeOutMs int `json:"fade_out_ms"` GapMs int `json:"gap_ms"` FadeInMs int `json:"fade_in_ms"` } // profileResponse builds the account's profile DTO with the advertising-banner block attached when // the viewer is eligible. Every endpoint returning the caller's own profile (get, update, link) // uses it, so the banner never drops off the client's profile after a non-get profile change (e.g. // a language switch). func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse { r := profileResponseFor(acc) // Resolve the payments gate once (execution context + present sources) and feed it to both // the hint count and the banner. The profile hint balance comes from the payments benefit // (context-aware); the deprecated accounts.hint_balance column is no longer read, so on any // failure the fallback from profileResponseFor is a plain 0. cxt, present, err := s.walletGate(ctx, acc.ID) if err != nil { s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err)) } else if s.payments != nil { if hints, herr := s.payments.HintsAvailable(ctx, acc.ID, cxt, present); herr == nil { r.HintBalance = hints } else { s.log.Warn("profile: hint balance read failed", zap.String("account", acc.ID.String()), zap.Error(herr)) } } r.Banner = s.bannerFor(ctx, acc, cxt, present) r.Ads = s.adsFor(ctx, acc, cxt, present) r.GameLimits = s.gameLimitsDTOFor(acc.IsGuest) 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 // warning so the profile response still succeeds. func (s *Server) fillLinkedIdentities(ctx context.Context, r *profileResponse, accountID uuid.UUID) { ids, err := s.accounts.Identities(ctx, accountID) if err != nil { s.log.Warn("profile: identities read failed", zap.String("account", accountID.String()), zap.Error(err)) return } for _, id := range ids { switch id.Kind { case account.KindEmail: if id.Confirmed { r.Email = id.ExternalID } case account.KindTelegram: r.TelegramLinked = true case account.KindVK: r.VkLinked = true } } } // bannerFor builds the advertising-banner block for the account's profile, or // nil when the ads service is not configured or the viewer is not eligible to // see a banner. The message language follows the account's bot (service) // language, falling back to its interface language and then English. A failure // reading roles or campaigns is logged and treated as "no banner" so the profile // response still succeeds. func (s *Server) bannerFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *bannerDTO { if s.ads == nil { return nil } set, timings, urgent, err := s.ads.ActiveSet(ctx, bannerLang(acc)) if err != nil { s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err)) return nil } // An urgent campaign is shown to every viewer; otherwise the banner is suppressed by the // no_banner role or by an active no-ads benefit applicable in the viewer's context (the // payments gate — a hint balance no longer suppresses the banner). An untrusted platform is // fail-closed by the gate, so it does not suppress the banner. if !urgent { hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner) if err != nil { s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err)) return nil } adFree := false if s.payments != nil { if adFree, err = s.payments.AdFree(ctx, acc.ID, cxt, present); err != nil { s.log.Warn("banner: ad-free check failed", zap.String("account", acc.ID.String()), zap.Error(err)) return nil } } if hasNoBanner || adFree { return nil } } campaigns := make([]bannerCampaignDTO, 0, len(set)) for _, c := range set { campaigns = append(campaigns, bannerCampaignFromActive(c)) } return &bannerDTO{ Campaigns: campaigns, Timings: bannerTimingsDTO{ HoldMs: timings.HoldMs, EdgePauseMs: timings.EdgePauseMs, ScrollPxPerSec: timings.ScrollPxPerSec, FadeOutMs: timings.FadeOutMs, GapMs: timings.GapMs, FadeInMs: timings.FadeInMs, }, } } // adsDTO is the post-move interstitial config in the profile: the client-mirrored cooldowns // (seconds) and whether ads are suppressed in the context (a no-ads benefit applicable here, or the // no_banner role). The client shows a VK interstitial after a confirmed move / hint only when not // suppressed and the mirrored cooldown has elapsed. type adsDTO struct { CooldownGlobalS int `json:"cooldown_global_s"` CooldownVsAiS int `json:"cooldown_vs_ai_s"` CooldownHintS int `json:"cooldown_hint_s"` Suppressed bool `json:"suppressed"` } // adsFor builds the profile interstitial-ad config: the cooldowns and whether ads are suppressed // here (the same no-ads / no_banner gate as the banner). A read failure logs and yields a suppressed // block (fail-safe: no interstitial), so the profile still succeeds. func (s *Server) adsFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *adsDTO { if s.payments == nil { return nil } global, vsAi, hint, err := s.payments.InterstitialCooldowns(ctx) if err != nil { s.log.Warn("profile: ad cooldowns read failed", zap.String("account", acc.ID.String()), zap.Error(err)) return &adsDTO{Suppressed: true} } suppressed := false if adFree, aerr := s.payments.AdFree(ctx, acc.ID, cxt, present); aerr != nil { s.log.Warn("profile: ad-free read failed", zap.String("account", acc.ID.String()), zap.Error(aerr)) suppressed = true // fail-safe: suppress the interstitial when eligibility is unknown } else { suppressed = adFree } if !suppressed { if noBanner, berr := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner); berr == nil { suppressed = noBanner } } return &adsDTO{CooldownGlobalS: global, CooldownVsAiS: vsAi, CooldownHintS: hint, Suppressed: suppressed} } // bannerCampaignFromActive flattens a resolved campaign into its wire DTO, // projecting each optional colour set into its three "#rrggbb" fields (empty when // the set is absent, so JSON omitempty drops them). func bannerCampaignFromActive(c ads.ActiveCampaign) bannerCampaignDTO { d := bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages} if c.OverrideAll != nil { d.OverrideBg, d.OverrideFg, d.OverrideLink = c.OverrideAll.Bg, c.OverrideAll.Fg, c.OverrideAll.Link } if c.OverrideDark != nil { d.OverrideBgDark, d.OverrideFgDark, d.OverrideLinkDark = c.OverrideDark.Bg, c.OverrideDark.Fg, c.OverrideDark.Link } return d } // bannerLang resolves the message language for a viewer: their interface language // (Russian, or English by default). func bannerLang(acc account.Account) string { if acc.PreferredLanguage == "ru" { return "ru" } return "en" } // publishBannerChange emits the in-app "banner eligibility may have changed" // re-poll signal to the account, so an open client re-fetches profile.get and // shows or hides the banner without a reload. Best-effort (notify.Nop when no // notifier is wired). func (s *Server) publishBannerChange(id uuid.UUID) { s.notifier.Publish(notify.BannerChanged(id)) }