diff --git a/PLAN.md b/PLAN.md index bf7c203..02352b3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -35,7 +35,7 @@ status — without re-deriving decisions. | E3 | Wallet UI | 1 | DONE | | E4 | Durability (PITR) | 2 | DONE | | E5 | Payment intake | 2 | DONE | -| E6 | Ads | 2 | WIP | +| E6 | Ads | 2 | DONE | | E7 | Admin & reports | 2 | TODO | | E8 | Guest limits | — | TODO | | E9 | Tournament fee | future | TODO | @@ -618,7 +618,7 @@ force-recreate when the Caddyfile changes. ## E6 — Ads -**Status:** WIP · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) · +**Status:** DONE · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) · mechanics: PAYMENTS §10. **Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**, @@ -640,8 +640,19 @@ VK returns **only `{result:true}`** (no token/signature) — client-attested is possible; the diagnostic is removed. The slice also **corrects the VK-iOS freeze to purchase-only** (rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending — Apple forbids only *buying* in-app values, not spending or earning them; `vkFrozen()` now gates only -`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). The legacy `paid_account` -/ `hint_balance` drop (D31) and the **interstitial** are the next slice. +`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). Delivered on +`feature/ads-interstitial` (the **interstitial + D31** slice): the post-move fullscreen interstitial +as a **client-mirrored** gate — the backend `adsFor` puts the config cooldowns + a `suppressed` flag +(the no-ads / `no_banner` gate, same as the banner) on the profile (`Profile.ads`), and +`ui/src/lib/ads.ts` `maybeShowInterstitial` self-gates on the last-shown time per kind in +`localStorage`, showing a VK interstitial (`vkShowInterstitial`) after a **confirmed play or a hint +only** (never a pass / exchange / resign), VK-only, offline banner-only, with the same `VITE_ADS_STUB` +toast on the contour. The slice also lands **D31 step 1 (contract-code)**: the domain no longer reads +or writes the deprecated `accounts.hint_balance` / `paid_account` columns — the `Account` fields, the +dead `account.SpendHint`, `account.GrantHints` and the admin **grant-hints** action are removed, and +the in-game hint display now comes wholly from the payments benefit (`HintsAvailable`). The **columns +stay** (no migration → image rollback is DB-safe); a later contract-PR does the `DROP` once E6 is +stable on prod. **Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video (credits chips via server verify), plus extending the existing banner suppression to @@ -657,8 +668,9 @@ per-origin. - **Interstitial** (post-move fullscreen), configurable server values (from `payments` config): global per-user cooldown across all games (default 5 min); `vs_ai` 30 min; a hint application triggers a post-move interstitial independently with its own 1-min cooldown; - offline banner-only; respect VK's own frequency caps. Cooldown state tracked server-side - (per user) or client-mirrored from a server value — pick and document at implementation. + offline banner-only; respect VK's own frequency caps. Cooldown state is **client-mirrored** + (the chosen option): the server sends the cooldowns + `suppressed` on the profile and the + client self-gates on a per-kind last-shown time in `localStorage` — no per-move round-trip. - **Banner suppression:** extend `ads.Eligible` (`backend/internal/ads/ads.go` :107) to gate on the **origin benefit applicable in the current context** (E2 interface) instead of the single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed. diff --git a/backend/README.md b/backend/README.md index 8b6fc3a..7ed70c5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -135,21 +135,23 @@ so `/internal/push-target` returns the recipient's `preferred_language` as the r language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` + `/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches -the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0 -&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs -publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire +the resolved, weighted campaign feed for an **eligible** viewer (no active **no-ads** benefit +applicable in the current context and no **`no_banner`** role; the message language picked by +`preferred_language`); changing those inputs +publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. +The same gate drives the post-move interstitial config (`Profile.ads`, `adsFor`). The shared wire contracts live in the sibling [`../pkg`](../pkg) module. **Account linking & merge** (`/api/v1/user/link/*`). `internal/link` orchestrates it: an email confirm-code or a gateway-validated Telegram identity is attached to the current account, and when the identity already has its own account -the two are merged in one transaction (`internal/accountmerge`) — stats and the hint -wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred, +the two are merged in one transaction (`internal/accountmerge`) — stats summed, +identities/games/chat/complaints transferred, friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a shared finished game's foreign keys hold); a shared **active** game blocks the merge. The current account is primary, except a guest initiator whose linked identity has a durable owner — then the durable account wins and a fresh session is minted for it. -The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the +The `accounts.merged_into`/`merged_at` columns back this. This supersedes the former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay). Rate-limit observability: the gateway posts its periodic rejection diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 7ff1018..43998c9 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -43,9 +43,7 @@ var ErrNotFound = errors.New("account: not found") // local-time window (in TimeZone) during which the player is asleep, so the // turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's // own sleep is anchored to its human opponent's timezone with a per-game drift, -// computed in internal/robot, not from a robot account's away window.) HintBalance -// is the player's wallet of purchasable hints, spent after a game's per-seat -// allowance. +// computed in internal/robot, not from a robot account's away window.) type Account struct { ID uuid.UUID DisplayName string @@ -53,7 +51,6 @@ type Account struct { TimeZone string AwayStart time.Time AwayEnd time.Time - HintBalance int BlockChat bool BlockFriendRequests bool // VariantPreferences is the set of game variants (engine.Variant stable labels: @@ -69,10 +66,6 @@ type Account struct { // true (the default): the platform side-service skips out-of-app push for the // account. NotificationsInAppOnly bool - // PaidAccount marks a lifetime one-time-payment account. It is a service field - // (no purchase flow yet); an account linking & merge ORs it so a paid status is - // never lost when accounts are consolidated. - PaidAccount bool // MergedInto is the primary account a retired (merged) secondary points at, or // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade // foreign keys of a shared finished game stay valid. @@ -563,52 +556,6 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, return modelToAccount(row), nil } -// SpendHint atomically decrements the account's hint wallet by one, returning -// true when a hint was spent and false when the balance was already empty. The -// guarded UPDATE keeps it safe under concurrent spends across the player's games. -func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) { - stmt := table.Accounts. - UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt). - SET(table.Accounts.HintBalance.SUB(postgres.Int(1)), postgres.TimestampzT(time.Now().UTC())). - WHERE( - table.Accounts.AccountID.EQ(postgres.UUID(id)). - AND(table.Accounts.HintBalance.GT(postgres.Int(0))), - ) - res, err := stmt.ExecContext(ctx, s.db) - if err != nil { - return false, fmt.Errorf("account: spend hint %s: %w", id, err) - } - n, err := res.RowsAffected() - if err != nil { - return false, fmt.Errorf("account: spend hint rows %s: %w", id, err) - } - return n > 0, nil -} - -// GrantHints adds n hints to the account's wallet and returns the new balance. n must be -// positive: the additive update can only raise the balance, never lower it, so it enforces the -// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint. -// It returns ErrNotFound when no account matches. -func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) { - if n <= 0 { - return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n) - } - stmt := table.Accounts. - UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt). - SET(table.Accounts.HintBalance.ADD(postgres.Int(int64(n))), postgres.TimestampzT(time.Now().UTC())). - WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). - RETURNING(table.Accounts.HintBalance) - - var row model.Accounts - if err := stmt.QueryContext(ctx, s.db, &row); err != nil { - if errors.Is(err, qrm.ErrNoRows) { - return 0, ErrNotFound - } - return 0, fmt.Errorf("account: grant hints %s: %w", id, err) - } - return int(row.HintBalance), nil -} - // FlagHighRate stamps the soft "suspected high-rate" marker with at, only when // the account is not already flagged — the first sustained episode wins, and a // re-flag after an operator clear starts a fresh timestamp. An infra marker, not @@ -664,12 +611,10 @@ func modelToAccount(row model.Accounts) Account { TimeZone: row.TimeZone, AwayStart: row.AwayStart, AwayEnd: row.AwayEnd, - HintBalance: int(row.HintBalance), BlockChat: row.BlockChat, BlockFriendRequests: row.BlockFriendRequests, IsGuest: row.IsGuest, NotificationsInAppOnly: row.NotificationsInAppOnly, - PaidAccount: row.PaidAccount, MergedInto: mergedInto, FlaggedHighRateAt: flaggedHighRateAt, CreatedAt: row.CreatedAt, diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 2d300d1..0a134b5 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -10,8 +10,6 @@
  • Timezone {{.TimeZone}}
  • Guest {{if .Guest}}yes{{else}}no{{end}}
  • Push {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}
  • -
  • Paid {{if .PaidAccount}}yes{{else}}no{{end}}
  • -
  • Hint wallet {{.HintBalance}}
  • {{if .MergedInto}}
  • Merged into {{.MergedInto}}
  • {{end}} {{if .FlaggedHighRateAt}}
  • High-rate flag {{.FlaggedHighRateAt}}
  • {{end}}
  • Created {{.CreatedAt}}
  • @@ -21,10 +19,6 @@ {{end}} -
    - - -

    Statistics

    {{if .HasStats}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 5abfeff..e9d84f6 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -149,7 +149,6 @@ type UserDetailView struct { TimeZone string Guest bool NotificationsInAppOnly bool - PaidAccount bool // MergedInto is the primary account id when this account has been retired by a // merge, or empty for a live account. MergedInto string @@ -165,14 +164,10 @@ type UserDetailView struct { // FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp, // empty for an unflagged account; the card shows it with the Clear action. FlaggedHighRateAt string - HintBalance int - // HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the - // server's maxHintGrant), passed through so the policy value lives in one place. - HintGrantMax int - CreatedAt string - HasStats bool - Stats StatsRow - Identities []IdentityRow + CreatedAt string + HasStats bool + Stats StatsRow + Identities []IdentityRow // HasEmail gates the "Erase email" action; set when the account carries an email identity. HasEmail bool Games []GameRow diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 4b7dbae..5c47d0c 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -1272,10 +1272,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) if !ok { return StateView{}, ErrNotAPlayer } - acc, err := svc.accounts.GetByID(ctx, accountID) - if err != nil { - return StateView{}, err - } unlock := svc.locks.lock(gameID) defer unlock() @@ -1290,12 +1286,15 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) } } return StateView{ - Game: pre, - Seat: seat, - Rack: g.Hand(seat), - BagLen: g.BagLen(), - HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance), - WalletBalance: acc.HintBalance, + Game: pre, + Seat: seat, + Rack: g.Hand(seat), + BagLen: g.BagLen(), + // The hint wallet moved to payments (svc.hintWallet); the deprecated accounts.hint_balance + // is no longer read, so the wire wallet is 0 and HintsRemaining is the per-seat allowance. + // The client adds the profile's payments hint balance on top (lib/hints.hintsLeft). + HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, 0), + WalletBalance: 0, // vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn). HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()), }, nil diff --git a/backend/internal/inttest/admin_test.go b/backend/internal/inttest/admin_test.go index a6c27b1..e78da7f 100644 --- a/backend/internal/inttest/admin_test.go +++ b/backend/internal/inttest/admin_test.go @@ -4,7 +4,6 @@ package inttest import ( "context" - "errors" "net/http" "net/http/httptest" "strings" @@ -280,76 +279,6 @@ func TestConsoleThrottledViewAndFlagClear(t *testing.T) { } } -// TestConsoleGrantHints drives the admin hint-wallet grant end to end: the card shows the form, -// the action is CSRF-guarded, a same-origin grant adds to the wallet, a second grant adds again -// (rather than replacing), the inclusive per-grant cap is accepted, and an out-of-range or -// non-numeric amount is refused without changing the balance. -func TestConsoleGrantHints(t *testing.T) { - ctx := context.Background() - accounts := account.NewStore(testDB) - id := provisionAccount(t) - srv := server.New(":0", server.Deps{ - Logger: zap.NewNop(), Accounts: accounts, Games: newGameService(), Registry: testRegistry, DictDir: dictDir(), - }) - h := srv.Handler() - base := "http://admin.test/_gm/users/" + id.String() - - if code, body := consoleDo(h, http.MethodGet, base, "", ""); code != http.StatusOK || !strings.Contains(body, "Add hints") { - t.Fatalf("user card = %d, has grant form = %v", code, strings.Contains(body, "Add hints")) - } - // The grant POST is CSRF-guarded like every console action. - if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", ""); code != http.StatusForbidden { - t.Fatalf("grant without origin = %d, want 403", code) - } - // A same-origin grant adds to the wallet. - if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 5") { - t.Fatalf("grant 5 = %d, body has 'now 5' = %v", code, strings.Contains(body, "now 5")) - } - if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 5 { - t.Fatalf("after grant 5: balance=%d err=%v, want 5", acc.HintBalance, err) - } - // A second grant adds again rather than replacing. - if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=3", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 8") { - t.Fatalf("grant 3 = %d, body has 'now 8' = %v", code, strings.Contains(body, "now 8")) - } - // An out-of-range or non-numeric amount is refused; the balance is left untouched. - for _, bad := range []string{"0", "-1", "101", "x", ""} { - if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount="+bad, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Invalid amount") { - t.Fatalf("grant %q = %d, has 'Invalid amount' = %v", bad, code, strings.Contains(body, "Invalid amount")) - } - } - if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 8 { - t.Fatalf("after invalid grants: balance=%d err=%v, want 8", acc.HintBalance, err) - } - // The inclusive per-grant cap (100) is accepted. - if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=100", "http://admin.test"); code != http.StatusOK { - t.Fatalf("grant 100 = %d, want 200", code) - } - if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 108 { - t.Fatalf("after grant 100: balance=%d err=%v, want 108", acc.HintBalance, err) - } -} - -// TestGrantHintsStore covers the wallet store method directly: an additive grant raises the -// balance, a non-positive grant is rejected, and an unknown account yields ErrNotFound. -func TestGrantHintsStore(t *testing.T) { - ctx := context.Background() - accounts := account.NewStore(testDB) - id := provisionAccount(t) - if bal, err := accounts.GrantHints(ctx, id, 4); err != nil || bal != 4 { - t.Fatalf("grant 4 = (%d, %v), want (4, nil)", bal, err) - } - if bal, err := accounts.GrantHints(ctx, id, 6); err != nil || bal != 10 { - t.Fatalf("grant 6 = (%d, %v), want (10, nil)", bal, err) - } - if _, err := accounts.GrantHints(ctx, id, 0); err == nil { - t.Error("grant 0 should be rejected (non-positive)") - } - if _, err := accounts.GrantHints(ctx, uuid.New(), 1); !errors.Is(err, account.ErrNotFound) { - t.Errorf("grant unknown account = %v, want ErrNotFound", err) - } -} - // consoleDo issues a request to h, optionally with an Origin header, and returns // the status and body. Form bodies are sent as application/x-www-form-urlencoded. func consoleDo(h http.Handler, method, target, body, origin string) (int, string) { diff --git a/backend/internal/inttest/banner_console_test.go b/backend/internal/inttest/banner_console_test.go index 259588b..66112e5 100644 --- a/backend/internal/inttest/banner_console_test.go +++ b/backend/internal/inttest/banner_console_test.go @@ -191,7 +191,7 @@ func TestBannerMessageOwnership(t *testing.T) { } } -// profileBanner is the banner block of the profile.get JSON response. +// profileBanner is the banner (and interstitial-ad) block of the profile.get JSON response. type profileBanner struct { Banner *struct { Campaigns []struct { @@ -203,6 +203,12 @@ type profileBanner struct { HoldMs int `json:"hold_ms"` } `json:"timings"` } `json:"banner"` + Ads *struct { + CooldownGlobalS int `json:"cooldown_global_s"` + CooldownVsAiS int `json:"cooldown_vs_ai_s"` + CooldownHintS int `json:"cooldown_hint_s"` + Suppressed bool `json:"suppressed"` + } `json:"ads"` } // TestBannerProfileEligibility checks the profile.get banner block follows @@ -283,6 +289,81 @@ func TestBannerProfileEligibility(t *testing.T) { } } +// TestProfileAdsConfig checks the profile.get interstitial-ad block: the seeded config cooldowns are +// carried through, and Suppressed follows the same no-ads / no_banner gate as the banner (the client +// self-gates VK-only + online on top). The no_banner role is context-independent; the no-ads benefit +// applies only in a trusted context where its segment is present. +func TestProfileAdsConfig(t *testing.T) { + ctx := context.Background() + srv, _, pay := bannerServer(t) + accounts := account.NewStore(testDB) + id := provisionAccount(t) + + get := func() profileBanner { + t.Helper() + rec := userGet(t, srv, "/api/v1/user/profile", id) + if rec.Code != http.StatusOK { + t.Fatalf("profile = %d, want 200", rec.Code) + } + var p profileBanner + if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { + t.Fatalf("decode profile: %v", err) + } + return p + } + getTG := func() profileBanner { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil) + req.Header.Set("X-User-ID", id.String()) + req.Header.Set("X-Platform", "telegram/android") + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("profile = %d, want 200", rec.Code) + } + var p profileBanner + if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { + t.Fatalf("decode profile: %v", err) + } + return p + } + + // A free account: the ads block carries the seeded config cooldowns and is not suppressed. + p := get() + if p.Ads == nil { + t.Fatal("free account: no ads block") + } + if p.Ads.CooldownGlobalS != 300 || p.Ads.CooldownVsAiS != 1800 || p.Ads.CooldownHintS != 60 { + t.Fatalf("cooldowns = %d/%d/%d, want 300/1800/60", p.Ads.CooldownGlobalS, p.Ads.CooldownVsAiS, p.Ads.CooldownHintS) + } + if p.Ads.Suppressed { + t.Fatal("free account: interstitials must not be suppressed") + } + + // The no_banner role suppresses interstitials too (context-independent). + if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil { + t.Fatalf("grant role: %v", err) + } + if p := get(); p.Ads == nil || !p.Ads.Suppressed { + t.Fatalf("no_banner role: ads=%v, want suppressed", p.Ads) + } + if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil { + t.Fatalf("revoke role: %v", err) + } + + // An active no-ads benefit suppresses interstitials in a trusted context; an untrusted context + // does not apply it (fail-closed to eligible, as for the banner). + if err := pay.Grant(ctx, id, payments.SourceTelegram, 0, 30, false); err != nil { + t.Fatalf("grant no-ads: %v", err) + } + if p := getTG(); p.Ads == nil || !p.Ads.Suppressed { + t.Fatalf("no-ads benefit (trusted): ads=%v, want suppressed", p.Ads) + } + if p := get(); p.Ads == nil || p.Ads.Suppressed { + t.Fatalf("no-ads benefit (untrusted): ads=%v, want NOT suppressed", p.Ads) + } +} + // TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the // banner block too, so the client's profile keeps the banner instead of losing it until reload. func TestBannerSurvivesProfileUpdate(t *testing.T) { @@ -440,10 +521,4 @@ func TestBannerUrgentBypassesEligibility(t *testing.T) { if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil { t.Fatalf("revoke role: %v", err) } - - // A non-empty hint wallet no longer suppresses it either. - if _, err := accounts.GrantHints(ctx, id, 5); err != nil { - t.Fatalf("grant hints: %v", err) - } - assertUrgentOnly("hints", get()) } diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index 93a58ce..2f9bedc 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -140,6 +140,12 @@ func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, am // them. const providerVKAds = "vk_ads" +// InterstitialCooldowns reports the post-move interstitial-ad cooldowns (seconds): global, vs_ai and +// the independent hint-triggered one. The client mirrors them and self-gates (client-mirrored, D30). +func (s *Service) InterstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) { + return s.store.interstitialCooldowns(ctx) +} + // RewardPayout reports the chips a rewarded-video view earns in the caller's context — the config // payout in a trusted VK context with the VK segment attached, and 0 everywhere else (rewarded is // VK-only, D28). The client uses it to gate the "watch for chips" button. diff --git a/backend/internal/payments/store_intake.go b/backend/internal/payments/store_intake.go index 659254e..3a219dd 100644 --- a/backend/internal/payments/store_intake.go +++ b/backend/internal/payments/store_intake.go @@ -385,6 +385,20 @@ type RewardOutcome struct { AlreadyCredited bool } +// interstitialCooldowns reads the post-move interstitial-ad cooldowns (seconds): the global +// per-user cooldown, the longer vs_ai one, and the independent hint-triggered one. The client mirrors +// them and self-gates (E6/D30). +func (s *Store) interstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) { + var cfg model.Config + if e := postgres.SELECT(table.Config.CooldownGlobalSeconds, table.Config.CooldownVsAiSeconds, table.Config.CooldownHintSeconds). + FROM(table.Config). + LIMIT(1). + QueryContext(ctx, s.db, &cfg); e != nil { + return 0, 0, 0, fmt.Errorf("payments: read interstitial cooldowns: %w", e) + } + return int(cfg.CooldownGlobalSeconds), int(cfg.CooldownVsAiSeconds), int(cfg.CooldownHintSeconds), nil +} + // rewardConfig reads the rewarded payout (chips per view) and the per-day and per-hour caps. The // caps are both anti-abuse (bounding a forger's free chips) and an economic conversion lever (free // rewarded chips are limited so a player who wants more buys) — tuned in the admin. diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go index 148b03f..8b68979 100644 --- a/backend/internal/server/banner.go +++ b/backend/internal/server/banner.go @@ -57,9 +57,9 @@ type bannerTimingsDTO struct { 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 now comes from the payments benefit - // (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy - // value from profileResponseFor (zeroed in production) stands. + // 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)) @@ -71,6 +71,7 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi } } r.Banner = s.bannerFor(ctx, acc, cxt, present) + r.Ads = s.adsFor(ctx, acc, cxt, present) s.fillLinkedIdentities(ctx, &r, acc.ID) r.DictVersions = s.currentDictVersions() return r @@ -177,6 +178,44 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account, cxt payment } } +// 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). diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index c96cac1..7549539 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -60,6 +60,11 @@ type profileResponse struct { // see the banner (a free account with an empty hint wallet and without the // no_banner role), absent otherwise. See banner.go. Banner *bannerDTO `json:"banner,omitempty"` + // Ads carries the post-move interstitial config for the client's client-mirrored gate: the + // cooldowns (seconds) and whether ads are suppressed in this context (no-ads / no_banner role). + // The client shows a VK interstitial after a confirmed move / hint when not suppressed and the + // cooldown has elapsed. Always present (the client also gates VK-only + online itself). + Ads *adsDTO `json:"ads,omitempty"` // Email is the account's confirmed email address ("" when none); TelegramLinked and // VkLinked report whether a platform identity is attached. They drive the profile's // link / unlink / change-email controls, and are filled outside the pure projection @@ -218,13 +223,15 @@ func sessionResponseFor(token string, acc account.Account) sessionResponse { // profileResponseFor projects an account into its profile DTO. func profileResponseFor(acc account.Account) profileResponse { return profileResponse{ - UserID: acc.ID.String(), - DisplayName: acc.DisplayName, - PreferredLanguage: acc.PreferredLanguage, - TimeZone: acc.TimeZone, - AwayStart: acc.AwayStart.Format(awayTimeLayout), - AwayEnd: acc.AwayEnd.Format(awayTimeLayout), - HintBalance: acc.HintBalance, + UserID: acc.ID.String(), + DisplayName: acc.DisplayName, + PreferredLanguage: acc.PreferredLanguage, + TimeZone: acc.TimeZone, + AwayStart: acc.AwayStart.Format(awayTimeLayout), + AwayEnd: acc.AwayEnd.Format(awayTimeLayout), + // The hint balance comes from the payments benefit; profileResponse overrides this + // with the context-aware count. This zero is the fallback when that read fails. + HintBalance: 0, BlockChat: acc.BlockChat, BlockFriendRequests: acc.BlockFriendRequests, IsGuest: acc.IsGuest, diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 48076fe..190a829 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -29,10 +29,6 @@ import ( // adminPageSize is the page size of the admin console's paginated lists. const adminPageSize = 50 -// maxHintGrant caps a single operator hint grant. Grants are additive and can never lower a -// wallet, so a fat-fingered grant cannot be undone through this form; the cap bounds one mistake. -const maxHintGrant = 100 - // registerConsole mounts the server-rendered admin console under /_gm. The gateway // puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the // backend trusts the gateway (as for all of /api) and adds only a same-origin guard @@ -56,7 +52,6 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/users/:id", s.consoleUserDetail) gm.POST("/users/:id/message", s.consoleUserMessage) gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag) - gm.POST("/users/:id/grant-hints", s.consoleGrantHints) gm.POST("/users/:id/block", s.consoleBlockUser) gm.POST("/users/:id/unblock", s.consoleUnblockUser) gm.POST("/users/:id/grant-role", s.consoleGrantRole) @@ -354,7 +349,6 @@ func (s *Server) consoleUserDetail(c *gin.Context) { view := adminconsole.UserDetailView{ ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage, TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly, - PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, HintGrantMax: maxHintGrant, CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil, } if acc.MergedInto != uuid.Nil { @@ -963,30 +957,6 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) { s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String()) } -// consoleGrantHints adds hints to a user's wallet. The grant is additive (raise-only): it tops a -// player up and can never lower what they already hold, so blocking a reduction is inherent rather -// than a separate guard. A single grant is bounded by maxHintGrant. -func (s *Server) consoleGrantHints(c *gin.Context) { - id, ok := s.consoleUUID(c, "/_gm/users") - if !ok { - return - } - back := "/_gm/users/" + id.String() - n, err := strconv.Atoi(trimForm(c, "amount")) - if err != nil || n < 1 || n > maxHintGrant { - s.renderConsoleMessage(c, "Invalid amount", fmt.Sprintf("enter a whole number of hints to add, between 1 and %d", maxHintGrant), back) - return - } - balance, err := s.accounts.GrantHints(c.Request.Context(), id, n) - if err != nil { - s.consoleError(c, err) - return - } - // A non-empty hint wallet removes the banner: nudge an open client to re-check. - s.publishBannerChange(id) - s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back) -} - // consoleRemoveEmail deletes the account's bound email identity (and any pending // confirmations), freeing the address. It refuses to remove the account's only // identity, which would leave it unreachable. diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 222e08d..15e6744 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -280,12 +280,20 @@ not suppressed by no-ads (D9). A future network that offers a server verify slot abstraction. On the test contour a build flag (`VITE_ADS_STUB`) swaps a toast for the real ad; prod always shows real ads (a failed real ad must not credit). -**Interstitial** (post-move fullscreen), configurable server-side values: +**Interstitial** (fullscreen after a confirmed play), **VK-only**, configurable server-side +values. The gate is **client-mirrored**: the profile carries the cooldowns and a `suppressed` +flag (the same no-ads / `no_banner` gate as the banner, resolved server-side in `adsFor`), and +the client self-gates on a **single shared** last-shown time in `localStorage` — the kind only +picks the required gap, so a hint ad and a move ad never fire within a cooldown of each other +(one shared timer, not one per kind) — no per-move server round-trip. The contour +`VITE_ADS_STUB` swaps the same "ad fired" toast for the real ad. Values: - Global cooldown **per user, across all games**, default **5 min**. - **`vs_ai` — 30 min** (aligned with the hint cooldown, so it does not scare casual players). -- Applying a **hint** triggers a post-move interstitial **independently** of the main - cooldown, with its own **1-min** cooldown. +- Applying a **hint** triggers an interstitial **independently** of the main cooldown, with + its own **1-min** cooldown. +- Fires **only after a confirmed play or a hint** — never after a pass, exchange or resign + (an ad for a non-scoring action would only annoy, so those are never "rewarded" with one). - Offline — banner only. - Respect VK's own frequency caps. diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index c808915..5bbab57 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -175,12 +175,28 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз (соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода **независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только баннер. Уважать собственные лимиты частоты VK. + **АМЕНД (E6, по факту реализации):** гейт **зеркальный** — сервер отдаёт кулдауны и `suppressed` + в профиле (`adsFor`), клиент сам гейтит по **единому** времени последнего показа в `localStorage` + (без раунд-трипа на ход). Таймер **общий на все виды** — вид (`hint`/`move`/`vs_ai`) лишь выбирает + нужный интервал, поэтому «независимость» подсказочного кулдауна значит лишь **более короткий + интервал от последнего показа**, а не отдельный таймер: hint-ролик и move-ролик не встают подряд + (баг раздельных таймеров: после hint-ролика move-таймер оставался нулевым → следующий ход сразу + крутил рекламу). Ролик показывается **только после подтверждённого хода или подсказки** — **не** + после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). Interstitial — + **только VK** (как и rewarded). Частота — глобальная на все игры (localStorage на устройство). - **D31. `paid_account` тоже deprecated** → удаление из схемы (как `hint_balance`), в пользу per-origin бенефитов «без рекламы». Существующий `ads.Eligible` (`backend/internal/ads/ads.go:107`) расширяется: баннер гасится по origin-бенефиту, **применимому в текущем контексте**, а не по одному глобальному флагу. Legacy `paid_account`/`hint_balance` в проде никем не выставлены (потока покупки не было) → обнуляем/игнорируем, после релиза платежей дропаем. + **АМЕНД (E6, по факту реализации — expand-contract, шаг 1 «contract-код»):** доменное + использование обеих колонок **убрано** — поля `Account.HintBalance` / `Account.PaidAccount`, + их скан, мёртвый `account.SpendHint`, `account.GrantHints` и админ-действие + «grant-hints» (роут `/_gm/users/:id/grant-hints`, форма, `UserDetailView.HintBalance`/ + `PaidAccount`); отображение подсказок в игре теперь всегда из payments (`HintsAvailable`), + профильный баланс — из payments-бенефита. **Колонки БД пока оставлены** (без миграции — + откат образа DB-safe); их `DROP` — отдельным contract-PR, когда E6 стабилен на проде. - **D32. Каталог — конфигурируемый (БД + админка).** Базовые ценности (атомы начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод** diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index f7c73ee..10ae6b0 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -279,12 +279,20 @@ App отдаёт только клиентский результат просм гасится «без рекламы» (D9). Сеть с серверным verify встроится за ads-абстракцией. На тест-контуре build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; прод всегда крутит настоящую рекламу. -**Полноэкранный ролик** (после хода), конфигурируемые серверные значения: +**Полноэкранный ролик** (после подтверждённого хода), **только VK**, конфигурируемые серверные +значения. Гейт **зеркалится на клиенте**: профиль несёт кулдауны и флаг `suppressed` (тот же гейт +«без рекламы» / `no_banner`, что и у баннера, считается на сервере в `adsFor`), а клиент сам +гейтит по **единому** времени последнего показа в `localStorage` — вид лишь выбирает нужный +интервал, поэтому hint-ролик и move-ролик не встают подряд в пределах кулдауна (один общий таймер, +не по одному на вид) — без серверного раунд-трипа на каждый ход. Контурный `VITE_ADS_STUB` подменяет +ролик тем же тостом «ad fired». Значения: - Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**. - **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов). -- Применение **подсказки** триггерит ролик после хода **независимо** от основного кулдауна, - со своим кулдауном **1 мин**. +- Применение **подсказки** триггерит ролик **независимо** от основного кулдауна, со своим + кулдауном **1 мин**. +- Показывается **только после подтверждённого хода или подсказки** — никогда после пропуска, + обмена или сдачи (ролик за не-очковое действие только раздражает, за них не «награждаем»). - Оффлайн — только баннер. - Уважать собственные лимиты частоты VK. diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 28b929b..b07759c 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -37,6 +37,8 @@ type ProfileResp struct { // Banner is the advertising-banner block, present only for a viewer eligible to // see the banner. The gateway forwards it verbatim into the Profile payload. Banner *BannerResp `json:"banner,omitempty"` + // Ads is the post-move interstitial config (cooldowns + suppressed) for the client-mirrored gate. + Ads *AdsResp `json:"ads,omitempty"` // Email is the confirmed email ("" when none); TelegramLinked/VkLinked report an // attached platform identity — they drive the profile's link/unlink/change controls. Email string `json:"email"` @@ -47,6 +49,15 @@ type ProfileResp struct { DictVersions []DictVersion `json:"dict_versions,omitempty"` } +// AdsResp is the post-move interstitial config in the profile: the client-mirrored cooldowns +// (seconds) and whether ads are suppressed in the caller's context. +type AdsResp struct { + CooldownGlobalS int `json:"cooldown_global_s"` + CooldownVsAiS int `json:"cooldown_vs_ai_s"` + CooldownHintS int `json:"cooldown_hint_s"` + Suppressed bool `json:"suppressed"` +} + // DictVersion pairs a game variant's stable label with its current dictionary version. type DictVersion struct { Variant string `json:"variant"` diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 858c68a..46e3aa3 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -184,6 +184,16 @@ func encodeProfile(p backendclient.ProfileResp) []byte { } prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector) dictVersions := encodeDictVersions(b, p.DictVersions) + // The interstitial-ad config table (scalars only), built before Profile is opened. + var ads flatbuffers.UOffsetT + if p.Ads != nil { + fb.AdsInfoStart(b) + fb.AdsInfoAddCooldownGlobalS(b, int32(p.Ads.CooldownGlobalS)) + fb.AdsInfoAddCooldownVsAiS(b, int32(p.Ads.CooldownVsAiS)) + fb.AdsInfoAddCooldownHintS(b, int32(p.Ads.CooldownHintS)) + fb.AdsInfoAddSuppressed(b, p.Ads.Suppressed) + ads = fb.AdsInfoEnd(b) + } fb.ProfileStart(b) fb.ProfileAddUserId(b, uid) fb.ProfileAddDisplayName(b, name) @@ -206,6 +216,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte { if p.Banner != nil { fb.ProfileAddBanner(b, banner) } + if p.Ads != nil { + fb.ProfileAddAds(b, ads) + } b.Finish(fb.ProfileEnd(b)) return b.FinishedBytes() } diff --git a/gateway/internal/transcode/transcode_ads_test.go b/gateway/internal/transcode/transcode_ads_test.go new file mode 100644 index 0000000..810f977 --- /dev/null +++ b/gateway/internal/transcode/transcode_ads_test.go @@ -0,0 +1,65 @@ +package transcode_test + +import ( + "context" + "net/http" + "testing" + + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// TestProfileGetEncodesAds verifies the gateway forwards the backend's interstitial-ad block +// into the Profile payload: the three cooldowns and the suppressed flag the client-mirrored gate +// reads. The encode is not exercised by the mock e2e (it bypasses the codec), so a dropped field +// would only surface here. +func TestProfileGetEncodesAds(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v1/user/profile" { + t.Errorf("unexpected %s %q", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` + + `"ads":{"cooldown_global_s":300,"cooldown_vs_ai_s":1800,"cooldown_hint_s":60,"suppressed":true}}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgProfileGet) + if !ok { + t.Fatal("profile.get not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + + ads := fb.GetRootAsProfile(payload, 0).Ads(nil) + if ads == nil { + t.Fatal("profile carries no ads block") + } + if ads.CooldownGlobalS() != 300 || ads.CooldownVsAiS() != 1800 || ads.CooldownHintS() != 60 { + t.Errorf("cooldowns = %d/%d/%d, want 300/1800/60", ads.CooldownGlobalS(), ads.CooldownVsAiS(), ads.CooldownHintS()) + } + if !ads.Suppressed() { + t.Error("suppressed = false, want true") + } +} + +// TestProfileGetNoAds verifies a profile without an ads block encodes none (the backend omits it +// only on an internal failure; normally the block is always present). +func TestProfileGetNoAds(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgProfileGet) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if p := fb.GetRootAsProfile(payload, 0); p.Ads(nil) != nil { + t.Error("profile without an ads block unexpectedly carries one") + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 124cfb8..6189134 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -264,6 +264,18 @@ table Profile { // preload the right dictionary and pin a new local game without a separate request (added // trailing — backward-compatible). dict_versions:[DictVersion]; + // ads carries the post-move interstitial config (cooldowns + suppressed) for the client-mirrored + // gate (added trailing — backward-compatible). + ads:AdsInfo; +} + +// AdsInfo is the post-move interstitial config: the client-mirrored cooldowns (seconds) and whether +// ads are suppressed in the caller's context (a no-ads benefit here, or the no_banner role). +table AdsInfo { + cooldown_global_s:int; + cooldown_vs_ai_s:int; + cooldown_hint_s:int; + suppressed:bool; } // BlockStatus reports the caller's current manual block. The UI fetches it after any operation diff --git a/pkg/fbs/scrabblefb/AdsInfo.go b/pkg/fbs/scrabblefb/AdsInfo.go new file mode 100644 index 0000000..375457f --- /dev/null +++ b/pkg/fbs/scrabblefb/AdsInfo.go @@ -0,0 +1,109 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type AdsInfo struct { + _tab flatbuffers.Table +} + +func GetRootAsAdsInfo(buf []byte, offset flatbuffers.UOffsetT) *AdsInfo { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AdsInfo{} + x.Init(buf, n+offset) + return x +} + +func FinishAdsInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAdsInfo(buf []byte, offset flatbuffers.UOffsetT) *AdsInfo { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AdsInfo{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAdsInfoBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AdsInfo) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AdsInfo) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AdsInfo) CooldownGlobalS() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *AdsInfo) MutateCooldownGlobalS(n int32) bool { + return rcv._tab.MutateInt32Slot(4, n) +} + +func (rcv *AdsInfo) CooldownVsAiS() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *AdsInfo) MutateCooldownVsAiS(n int32) bool { + return rcv._tab.MutateInt32Slot(6, n) +} + +func (rcv *AdsInfo) CooldownHintS() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *AdsInfo) MutateCooldownHintS(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + +func (rcv *AdsInfo) Suppressed() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *AdsInfo) MutateSuppressed(n bool) bool { + return rcv._tab.MutateBoolSlot(10, n) +} + +func AdsInfoStart(builder *flatbuffers.Builder) { + builder.StartObject(4) +} +func AdsInfoAddCooldownGlobalS(builder *flatbuffers.Builder, cooldownGlobalS int32) { + builder.PrependInt32Slot(0, cooldownGlobalS, 0) +} +func AdsInfoAddCooldownVsAiS(builder *flatbuffers.Builder, cooldownVsAiS int32) { + builder.PrependInt32Slot(1, cooldownVsAiS, 0) +} +func AdsInfoAddCooldownHintS(builder *flatbuffers.Builder, cooldownHintS int32) { + builder.PrependInt32Slot(2, cooldownHintS, 0) +} +func AdsInfoAddSuppressed(builder *flatbuffers.Builder, suppressed bool) { + builder.PrependBoolSlot(3, suppressed, false) +} +func AdsInfoEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index 82f33d0..6b402e1 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -231,8 +231,21 @@ func (rcv *Profile) DictVersionsLength() int { return 0 } +func (rcv *Profile) Ads(obj *AdsInfo) *AdsInfo { + o := flatbuffers.UOffsetT(rcv._tab.Offset(38)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(AdsInfo) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + func ProfileStart(builder *flatbuffers.Builder) { - builder.StartObject(17) + builder.StartObject(18) } func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) @@ -291,6 +304,9 @@ func ProfileAddDictVersions(builder *flatbuffers.Builder, dictVersions flatbuffe func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func ProfileAddAds(builder *flatbuffers.Builder, ads flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(17, flatbuffers.UOffsetT(ads), 0) +} func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index fd2512e..ec2f1b3 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -48,6 +48,33 @@ test('placing a tile and confirming via ✅ commits the move', async ({ page }) await expect(page.locator('.make')).toBeHidden(); }); +// Regression: taking a hint must NOT fire the post-move interstitial. Firing on the hint interrupted +// placing the preview and reverted the board on the ad's close while the hint stayed spent (the +// reported bug). In mock mode the ad stub shows an "ad fired" toast, standing in for a real VK ad. +test('taking a hint does not fire the interstitial', async ({ page }) => { + await openGame(page); + // Take a hint (the control confirms on a second tap). It produces a legal preview, so the ✅ + // control appears — proof the hint fired. + const hint = page.getByRole('button', { name: 'Hint' }); + await hint.click(); + await hint.click(); + await expect(page.locator('.make')).toBeVisible(); + // A spurious ad would have toasted by now; none may. + await page.waitForTimeout(400); + await expect(page.getByText('ad fired')).toHaveCount(0); +}); + +// The interstitial fires once the move is CONFIRMED (never on the hint / pass / exchange / resign). +test('confirming a move fires the interstitial', async ({ page }) => { + await openGame(page); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + await expect(page.locator('[data-cell].pending')).toHaveCount(1); + + await page.locator('.make').click(); + await expect(page.getByText('ad fired')).toBeVisible(); +}); + test('a placed tile is saved as a draft and restored on reopening the game', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index e76709f..f6ab404 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -15,6 +15,7 @@ import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; import { offlineMode } from '../lib/offline.svelte'; + import { maybeShowInterstitial } from '../lib/ads'; import { GatewayError } from '../lib/client'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; @@ -93,6 +94,11 @@ let exchangeOpen = $state(false); let exchangeSel = $state([]); let resignOpen = $state(false); + // hintUsedThisTurn marks that a hint was applied on the current turn, so a confirmed play earns + // the hint-kind interstitial (its own 1-min cooldown) instead of the plain move one. Set in + // doHint when the hint's tiles land, read in commit, and cleared on any turn boundary + // (applyMoveResult) so a hint-then-pass does not leak into the next turn's move. + let hintUsedThisTurn = $state(false); let drag = $state<{ letter: string; blank: boolean; x: number; y: number; touch: boolean } | null>(null); // Landscape (wide) layout: when the viewport is wider than tall the game switches to a // two-column layout — the board fills the right side as a square fitted to the height (no @@ -819,6 +825,9 @@ // applyMoveResult renders the actor's own just-committed move from the response — the move, the // post-move game and the refilled rack — without a follow-up game.state + game.history. function applyMoveResult(r: MoveResult) { + // A turn boundary (play / pass / exchange / resign): clear the hint marker so it never leaks + // into the next turn. commit captures it before this runs. + hintUsedThisTurn = false; view = { game: r.game, seat: r.move.player, @@ -941,11 +950,20 @@ const sub = toSubmit(placement); if (!sub) return; busy = true; + // Capture the hint marker before applyMoveResult clears it: a move played off a hint earns the + // hint-kind interstitial (own cooldown), a plain move the move-kind one. + const usedHint = hintUsedThisTurn; try { applyMoveResult(await source.submitPlay(id, sub.tiles, variant)); if (view?.game.hotseat) await advanceHotseat(); haptic('success'); zoomed = false; + // A confirmed move may trigger a post-move interstitial (VK, frequency-gated, client-mirrored). + // Only submitPlay earns one — never a pass / exchange / resign. Fire-and-forget. + void maybeShowInterstitial(app.profile?.ads, usedHint ? 'hint' : 'move', { + vsAi: !!view?.game.vsAi, + online: connection.online && !offlineMode.active, + }); } catch (e) { handleError(e); } finally { @@ -1009,6 +1027,9 @@ const h = await source.hint(id); if (h.move.tiles.length && view) { placement = placementFromHint(h.move.tiles, view.rack); + // Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit), + // not now — showing it here would interrupt placing the preview and revert the board on close. + hintUsedThisTurn = true; // Scroll the (zoomed) board to the hint's placement rather than the top-left: // focus the centre of the laid tiles' bounding box. const p = placement.pending; diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 7633964..d9055d9 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -4,6 +4,7 @@ export { AccountDeleteConfirm } from './scrabblefb/account-delete-confirm.js'; export { AccountDeleteRequestResult } from './scrabblefb/account-delete-request-result.js'; export { AccountRef } from './scrabblefb/account-ref.js'; export { Ack } from './scrabblefb/ack.js'; +export { AdsInfo } from './scrabblefb/ads-info.js'; export { AlphabetEntry } from './scrabblefb/alphabet-entry.js'; export { BannerCampaign } from './scrabblefb/banner-campaign.js'; export { BannerInfo } from './scrabblefb/banner-info.js'; diff --git a/ui/src/gen/fbs/scrabblefb/ads-info.ts b/ui/src/gen/fbs/scrabblefb/ads-info.ts new file mode 100644 index 0000000..35b7cb2 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/ads-info.ts @@ -0,0 +1,76 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class AdsInfo { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):AdsInfo { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsAdsInfo(bb:flatbuffers.ByteBuffer, obj?:AdsInfo):AdsInfo { + return (obj || new AdsInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsAdsInfo(bb:flatbuffers.ByteBuffer, obj?:AdsInfo):AdsInfo { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new AdsInfo()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +cooldownGlobalS():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +cooldownVsAiS():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +cooldownHintS():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +suppressed():boolean { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +static startAdsInfo(builder:flatbuffers.Builder) { + builder.startObject(4); +} + +static addCooldownGlobalS(builder:flatbuffers.Builder, cooldownGlobalS:number) { + builder.addFieldInt32(0, cooldownGlobalS, 0); +} + +static addCooldownVsAiS(builder:flatbuffers.Builder, cooldownVsAiS:number) { + builder.addFieldInt32(1, cooldownVsAiS, 0); +} + +static addCooldownHintS(builder:flatbuffers.Builder, cooldownHintS:number) { + builder.addFieldInt32(2, cooldownHintS, 0); +} + +static addSuppressed(builder:flatbuffers.Builder, suppressed:boolean) { + builder.addFieldInt8(3, +suppressed, +false); +} + +static endAdsInfo(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createAdsInfo(builder:flatbuffers.Builder, cooldownGlobalS:number, cooldownVsAiS:number, cooldownHintS:number, suppressed:boolean):flatbuffers.Offset { + AdsInfo.startAdsInfo(builder); + AdsInfo.addCooldownGlobalS(builder, cooldownGlobalS); + AdsInfo.addCooldownVsAiS(builder, cooldownVsAiS); + AdsInfo.addCooldownHintS(builder, cooldownHintS); + AdsInfo.addSuppressed(builder, suppressed); + return AdsInfo.endAdsInfo(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/profile.ts b/ui/src/gen/fbs/scrabblefb/profile.ts index 13ca0ed..06e536e 100644 --- a/ui/src/gen/fbs/scrabblefb/profile.ts +++ b/ui/src/gen/fbs/scrabblefb/profile.ts @@ -2,6 +2,7 @@ import * as flatbuffers from 'flatbuffers'; +import { AdsInfo } from '../scrabblefb/ads-info.js'; import { BannerInfo } from '../scrabblefb/banner-info.js'; import { DictVersion } from '../scrabblefb/dict-version.js'; @@ -135,8 +136,13 @@ dictVersionsLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +ads(obj?:AdsInfo):AdsInfo|null { + const offset = this.bb!.__offset(this.bb_pos, 38); + return offset ? (obj || new AdsInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + static startProfile(builder:flatbuffers.Builder) { - builder.startObject(17); + builder.startObject(18); } static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { @@ -231,6 +237,10 @@ static startDictVersionsVector(builder:flatbuffers.Builder, numElems:number) { builder.startVector(4, numElems, 4); } +static addAds(builder:flatbuffers.Builder, adsOffset:flatbuffers.Offset) { + builder.addFieldOffset(17, adsOffset, 0); +} + static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; diff --git a/ui/src/lib/ads.test.ts b/ui/src/lib/ads.test.ts new file mode 100644 index 0000000..4e5d739 --- /dev/null +++ b/ui/src/lib/ads.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AdsConfig } from './model'; + +// Mock ads.ts's three impure imports so the client-mirrored gate is observable without a VK +// bridge, a wallet context or the Svelte toast store. ./model is type-only (erased). +const mocks = vi.hoisted(() => ({ + vkShowInterstitial: vi.fn(), + vkRewardedReady: vi.fn(), + vkShowRewarded: vi.fn(), + executionContext: vi.fn(), + showToast: vi.fn(), +})); +vi.mock('./vk', () => ({ + vkShowInterstitial: mocks.vkShowInterstitial, + vkRewardedReady: mocks.vkRewardedReady, + vkShowRewarded: mocks.vkShowRewarded, +})); +vi.mock('./wallet', () => ({ executionContext: mocks.executionContext })); +vi.mock('./app.svelte', () => ({ showToast: mocks.showToast })); + +import { maybeShowInterstitial } from './ads'; + +const ADS: AdsConfig = { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false }; +const ONLINE = { vsAi: false, online: true }; +// A realistic epoch base: the never-shown last time is 0, so the first show needs now >> cooldown +// (as with a real Date.now); anchoring at 0 would gate the very first call (now - 0 < cooldown). +const BASE = 1_700_000_000_000; + +beforeEach(() => { + // A minimal in-memory localStorage (node has none) so the per-kind last-shown gate persists + // across calls within a test. + const store = new Map(); + (globalThis as unknown as { localStorage: Storage }).localStorage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + key: () => null, + length: 0, + } as Storage; + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(BASE); + mocks.executionContext.mockReturnValue('vk'); + mocks.vkShowInterstitial.mockResolvedValue(true); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +describe('maybeShowInterstitial (client-mirrored gate)', () => { + it('does not show when the config is absent', async () => { + expect(await maybeShowInterstitial(undefined, 'move', ONLINE)).toBe(false); + expect(mocks.vkShowInterstitial).not.toHaveBeenCalled(); + }); + + it('does not show when suppressed (no-ads / no_banner)', async () => { + expect(await maybeShowInterstitial({ ...ADS, suppressed: true }, 'move', ONLINE)).toBe(false); + expect(mocks.showToast).not.toHaveBeenCalled(); + expect(mocks.vkShowInterstitial).not.toHaveBeenCalled(); + }); + + it('does not show when offline', async () => { + expect(await maybeShowInterstitial(ADS, 'move', { vsAi: false, online: false })).toBe(false); + expect(mocks.vkShowInterstitial).not.toHaveBeenCalled(); + }); + + it('does not show outside VK (real ad path)', async () => { + mocks.executionContext.mockReturnValue('web'); + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false); + expect(mocks.vkShowInterstitial).not.toHaveBeenCalled(); + }); + + it('shows a VK interstitial inside VK when the cooldown allows', async () => { + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); + expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(1); + expect(mocks.showToast).not.toHaveBeenCalled(); + }); + + it('respects the cooldown: a second move within the window is gated', async () => { + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); + vi.setSystemTime(BASE + 299_000); // +299s < the 300s global cooldown + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false); + vi.setSystemTime(BASE + 300_000); // the cooldown has now elapsed + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); + expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(2); + }); + + it('shares one timer across kinds: a hint uses the shorter gap from the last ad', async () => { + // A move ad, then a hint: the hint is held until its own (shorter) 60s gap from THAT ad has + // elapsed — not fired at once (a per-kind timer used to let it fire immediately). + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); + vi.setSystemTime(BASE + 59_000); + expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(false); + vi.setSystemTime(BASE + 60_000); // the hint's 60s gap from the last ad has elapsed + expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(true); + }); + + it('does not stack a move ad onto a just-shown hint ad (the reported bug)', async () => { + // A vs_ai hint-move fires the hint ad; a plain vs_ai move 30s later must NOT fire — the shared + // timer holds it for the vs_ai gap. Separate per-kind timers let it fire at once (the bug). + const vsAi = { vsAi: true, online: true }; + expect(await maybeShowInterstitial(ADS, 'hint', vsAi)).toBe(true); + vi.setSystemTime(BASE + 30_000); // 30s later, far under the 1800s vs_ai gap + expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(false); + expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(1); // only the hint ad fired + }); + + it('a hint-pushed ad restarts the vs_ai gap, so the next plain move is not over-served', async () => { + // The scenario the owner flagged: a vs_ai ad, then 20 min later a hinted move pushes an ad on + // its short gap, then a plain move 10 min after that must NOT fire — the shared timer restarted + // the 30-min vs_ai gap at the hint ad, so 10 min is not enough (it fires only 30 min after it). + const vsAi = { vsAi: true, online: true }; + expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(true); // t0 + vi.setSystemTime(BASE + 20 * 60_000); // +20 min: a hint pushes an ad on its 1-min gap + expect(await maybeShowInterstitial(ADS, 'hint', vsAi)).toBe(true); + vi.setSystemTime(BASE + 30 * 60_000); // +10 min after the hint ad + expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(false); + vi.setSystemTime(BASE + 50 * 60_000); // +30 min after the hint ad + expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(true); + }); + + it('uses the longer vs_ai cooldown for a vs_ai move', async () => { + expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true); + vi.setSystemTime(BASE + 1_799_000); // +1799s < the 1800s vs_ai cooldown + expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(false); + vi.setSystemTime(BASE + 1_800_000); + expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true); + }); + + it('shows the test-stub toast instead of a real ad when the stub flag is set', async () => { + vi.stubEnv('VITE_ADS_STUB', '1'); + mocks.executionContext.mockReturnValue('web'); // the stub bypasses the VK gate + expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); + expect(mocks.showToast).toHaveBeenCalledWith('ad fired'); + expect(mocks.vkShowInterstitial).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/lib/ads.ts b/ui/src/lib/ads.ts index f68a442..ff555a7 100644 --- a/ui/src/lib/ads.ts +++ b/ui/src/lib/ads.ts @@ -4,7 +4,9 @@ // flow is testable without waiting for a real ad — production never sets the flag, so a real ad that // fails to load there must not credit (no stub fallback in prod). import { executionContext } from './wallet'; -import { vkRewardedReady, vkShowRewarded } from './vk'; +import { vkRewardedReady, vkShowRewarded, vkShowInterstitial } from './vk'; +import { showToast } from './app.svelte'; +import type { AdsConfig } from './model'; /** * adsStubEnabled reports the contour test stub (VITE_ADS_STUB=1). Production never sets it, so it @@ -46,3 +48,60 @@ export async function showRewarded(): Promise { } return { watched: await vkShowRewarded(), stub: false }; } + +// The post-move interstitial gate is client-mirrored: the server sends the cooldowns + suppressed in +// the profile (Profile.ads); the client tracks the last-shown time in localStorage and self-gates. +// Last-shown is per-device (cleared with storage) — acceptable for a frequency gate. +// +// It is a SINGLE shared timestamp across kinds, not one per kind: the kind only selects the required +// gap (hint's short cooldown vs the move / vs_ai one), while the wait is always measured from the +// last interstitial of ANY kind. A per-kind timer let a hint ad and a move ad stack — after a +// hint-move ad the move timer was still zero, so the next plain move fired an ad immediately. +const INTERSTITIAL_LAST_KEY = 'ads.interstitial.last'; + +// interstitialLast reads the last-shown epoch millis of any interstitial (0 when absent/corrupt, or +// when a pre-shared-timer value — an object — is still stored: it decodes to 0 and self-heals). +function interstitialLast(): number { + try { + const raw = localStorage.getItem(INTERSTITIAL_LAST_KEY); + if (raw) { + const n = Number(raw); + return Number.isFinite(n) ? n : 0; + } + } catch { + /* a corrupt or unavailable store simply resets the gate */ + } + return 0; +} + +function recordInterstitial(now: number): void { + try { + localStorage.setItem(INTERSTITIAL_LAST_KEY, String(now)); + } catch { + /* ignore a write failure — the worst case is one extra ad next time */ + } +} + +/** + * maybeShowInterstitial shows a post-move (`kind='move'`, global / vs_ai cooldown) or post-hint + * (`kind='hint'`, its own short cooldown) interstitial when the client-mirrored gate allows: not + * suppressed (no-ads), online, inside VK (the stub bypasses VK), and the required gap has elapsed + * since the last interstitial of any kind. The kind picks the gap; the timer is shared, so a hint ad + * and a move ad never fire within a cooldown of each other. The stub (contour test) shows an "ad + * fired" toast. Returns whether an ad was shown. Fire-and-forget — the caller does not await it. + */ +export async function maybeShowInterstitial( + ads: AdsConfig | undefined, + kind: 'move' | 'hint', + opts: { vsAi: boolean; online: boolean }, +): Promise { + if (!ads || ads.suppressed || !opts.online) return false; + const stub = adsStubEnabled(); + if (!stub && executionContext() !== 'vk') return false; + const cooldownS = kind === 'hint' ? ads.cooldownHintS : opts.vsAi ? ads.cooldownVsAiS : ads.cooldownGlobalS; + const now = Date.now(); + if (now - interstitialLast() < cooldownS * 1000) return false; + const shown = stub ? (showToast('ad fired'), true) : await vkShowInterstitial(); + if (shown) recordInterstitial(now); + return shown; +} diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 3bc24df..537890b 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -814,6 +814,42 @@ describe('codec', () => { expect(decodeProfile(b.asUint8Array()).banner).toBeUndefined(); }); + it('decodes the profile interstitial-ad config', () => { + const b = new Builder(64); + const uid = b.createString('u-1'); + const ads = fb.AdsInfo.createAdsInfo(b, 300, 1800, 60, false); + fb.Profile.startProfile(b); + fb.Profile.addUserId(b, uid); + fb.Profile.addAds(b, ads); + b.finish(fb.Profile.endProfile(b)); + expect(decodeProfile(b.asUint8Array()).ads).toEqual({ + cooldownGlobalS: 300, + cooldownVsAiS: 1800, + cooldownHintS: 60, + suppressed: false, + }); + }); + + it('carries the suppressed flag through the ad config', () => { + const b = new Builder(64); + const uid = b.createString('u-1'); + const ads = fb.AdsInfo.createAdsInfo(b, 300, 1800, 60, true); + fb.Profile.startProfile(b); + fb.Profile.addUserId(b, uid); + fb.Profile.addAds(b, ads); + b.finish(fb.Profile.endProfile(b)); + expect(decodeProfile(b.asUint8Array()).ads?.suppressed).toBe(true); + }); + + it('leaves the profile ads undefined when absent', () => { + 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()).ads).toBeUndefined(); + }); + it('decodes the profile variant preferences', () => { const b = new Builder(64); const uid = b.createString('u-1'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index f229873..f1724f9 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -14,6 +14,7 @@ import type { RobotBlockEntry, RobotFriendRequestEntry, Banner, + AdsConfig, BannerCampaign, BestMove, BestMoveTile, @@ -461,6 +462,7 @@ export function decodeProfile(buf: Uint8Array): Profile { notificationsInAppOnly: p.notificationsInAppOnly(), variantPreferences: decodeVariantPreferences(p), banner: decodeBanner(p), + ads: decodeAds(p), email: s(p.email()), telegramLinked: p.telegramLinked(), vkLinked: p.vkLinked(), @@ -497,6 +499,19 @@ function bannerTriple(bg: string | null, fg: string | null, link: string | null) return bg && fg && link ? { bg, fg, link } : null; } +// decodeAds projects the optional post-move interstitial config of a Profile, or undefined when +// absent (an old backend); the client then shows no interstitial. +function decodeAds(p: fb.Profile): AdsConfig | undefined { + const a = p.ads(); + if (!a) return undefined; + return { + cooldownGlobalS: a.cooldownGlobalS(), + cooldownVsAiS: a.cooldownVsAiS(), + cooldownHintS: a.cooldownHintS(), + suppressed: a.suppressed(), + }; +} + // decodeBanner projects the optional advertising-banner block of a Profile, or // undefined when the viewer is not eligible (the field is absent). function decodeBanner(p: fb.Profile): Banner | undefined { diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index ba5c112..143e4d2 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -45,6 +45,8 @@ export const PROFILE: Profile = { // 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' }, + // Post-move interstitial config (short cooldowns so the mock stub is exercisable); not suppressed. + ads: { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false }, }; // 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 2d02a8c..96ee9f7 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -226,6 +226,8 @@ export interface Profile { variantPreferences: Variant[]; /** The advertising-banner block, present only for a viewer eligible to see it. */ banner?: Banner; + /** The post-move interstitial-ad config for the client-mirrored gate (cooldowns + suppressed). */ + ads?: AdsConfig; /** The account's confirmed email address ("" when none). */ email: string; /** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */ @@ -237,6 +239,15 @@ export interface Profile { dictVersions: Partial>; } +/** The post-move interstitial-ad config for the client-mirrored gate: the cooldowns (seconds) and + * whether ads are suppressed in the current context (a no-ads benefit here, or the no_banner role). */ +export interface AdsConfig { + cooldownGlobalS: number; + cooldownVsAiS: number; + cooldownHintS: number; + suppressed: boolean; +} + /** Banner is the advertising-banner block of an eligible viewer's profile. */ export interface Banner { campaigns: BannerCampaign[]; diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index bca6746..68d6a5f 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -225,6 +225,20 @@ export async function vkShowRewarded(): Promise { } } +/** + * vkShowInterstitial shows a between-screens interstitial ad (VKWebAppShowNativeAds, interstitial + * format) and reports whether it was shown. It carries no reward — it is the post-move fullscreen ad. + * A rejected or unavailable call reports false. + */ +export async function vkShowInterstitial(): Promise { + try { + const data = await (await bridge()).send('VKWebAppShowNativeAds', { ad_format: 'interstitial' }); + return !!(data as { result?: boolean }).result; + } catch { + return false; + } +} + /** * vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) — * the mobile in-app file delivery, where the webview ignores . Resolves false