Release v1.14.0 — monetization launch (E5-E8) #237

Merged
developer merged 54 commits from development into master 2026-07-10 10:11:02 +00:00
34 changed files with 877 additions and 220 deletions
Showing only changes of commit 00d1bd33e3 - Show all commits
+18 -6
View File
@@ -35,7 +35,7 @@ status — without re-deriving decisions.
| E3 | Wallet UI | 1 | DONE | | E3 | Wallet UI | 1 | DONE |
| E4 | Durability (PITR) | 2 | DONE | | E4 | Durability (PITR) | 2 | DONE |
| E5 | Payment intake | 2 | DONE | | E5 | Payment intake | 2 | DONE |
| E6 | Ads | 2 | WIP | | E6 | Ads | 2 | DONE |
| E7 | Admin & reports | 2 | TODO | | E7 | Admin & reports | 2 | TODO |
| E8 | Guest limits | — | TODO | | E8 | Guest limits | — | TODO |
| E9 | Tournament fee | future | TODO | | E9 | Tournament fee | future | TODO |
@@ -618,7 +618,7 @@ force-recreate when the Caddyfile changes.
## E6 — Ads ## 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. mechanics: PAYMENTS §10.
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**, **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** 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 — (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 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` `CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). Delivered on
/ `hint_balance` drop (D31) and the **interstitial** are the next slice. `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 **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 (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` - **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 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; 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 offline banner-only; respect VK's own frequency caps. Cooldown state is **client-mirrored**
(per user) or client-mirrored from a server value — pick and document at implementation. (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 - **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 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. single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed.
+8 -6
View File
@@ -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` + 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 `/_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 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 the resolved, weighted campaign feed for an **eligible** viewer (no active **no-ads** benefit
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs applicable in the current context and no **`no_banner`** role; the message language picked by
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire `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. contracts live in the sibling [`../pkg`](../pkg) module.
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link` **Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is 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 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 the two are merged in one transaction (`internal/accountmerge`) — stats summed,
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred, identities/games/chat/complaints transferred,
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a 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. 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 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. 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). former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
Rate-limit observability: the gateway posts its periodic rejection Rate-limit observability: the gateway posts its periodic rejection
+1 -56
View File
@@ -43,9 +43,7 @@ var ErrNotFound = errors.New("account: not found")
// local-time window (in TimeZone) during which the player is asleep, so the // 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 // 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, // 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 // computed in internal/robot, not from a robot account's away window.)
// is the player's wallet of purchasable hints, spent after a game's per-seat
// allowance.
type Account struct { type Account struct {
ID uuid.UUID ID uuid.UUID
DisplayName string DisplayName string
@@ -53,7 +51,6 @@ type Account struct {
TimeZone string TimeZone string
AwayStart time.Time AwayStart time.Time
AwayEnd time.Time AwayEnd time.Time
HintBalance int
BlockChat bool BlockChat bool
BlockFriendRequests bool BlockFriendRequests bool
// VariantPreferences is the set of game variants (engine.Variant stable labels: // 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 // true (the default): the platform side-service skips out-of-app push for the
// account. // account.
NotificationsInAppOnly bool 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 // 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 // uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid. // 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 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 // 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 // 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 // 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, TimeZone: row.TimeZone,
AwayStart: row.AwayStart, AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd, AwayEnd: row.AwayEnd,
HintBalance: int(row.HintBalance),
BlockChat: row.BlockChat, BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests, BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest, IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly, NotificationsInAppOnly: row.NotificationsInAppOnly,
PaidAccount: row.PaidAccount,
MergedInto: mergedInto, MergedInto: mergedInto,
FlaggedHighRateAt: flaggedHighRateAt, FlaggedHighRateAt: flaggedHighRateAt,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
@@ -10,8 +10,6 @@
<li><b>Timezone</b> {{.TimeZone}}</li> <li><b>Timezone</b> {{.TimeZone}}</li>
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li> <li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li> <li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
<li><b>Hint wallet</b> {{.HintBalance}}</li>
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}} {{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}} {{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
<li><b>Created</b> {{.CreatedAt}}</li> <li><b>Created</b> {{.CreatedAt}}</li>
@@ -21,10 +19,6 @@
<button type="submit">Clear high-rate flag</button> <button type="submit">Clear high-rate flag</button>
</form> </form>
{{end}} {{end}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/grant-hints">
<label>Add hints <input type="number" name="amount" min="1" max="{{.HintGrantMax}}" value="1"></label>
<button type="submit">Grant</button>
</form>
</section> </section>
<section class="panel"><h2>Statistics</h2> <section class="panel"><h2>Statistics</h2>
{{if .HasStats}} {{if .HasStats}}
+4 -9
View File
@@ -149,7 +149,6 @@ type UserDetailView struct {
TimeZone string TimeZone string
Guest bool Guest bool
NotificationsInAppOnly bool NotificationsInAppOnly bool
PaidAccount bool
// MergedInto is the primary account id when this account has been retired by a // MergedInto is the primary account id when this account has been retired by a
// merge, or empty for a live account. // merge, or empty for a live account.
MergedInto string MergedInto string
@@ -165,14 +164,10 @@ type UserDetailView struct {
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp, // FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action. // empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string FlaggedHighRateAt string
HintBalance int CreatedAt string
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the HasStats bool
// server's maxHintGrant), passed through so the policy value lives in one place. Stats StatsRow
HintGrantMax int 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 gates the "Erase email" action; set when the account carries an email identity.
HasEmail bool HasEmail bool
Games []GameRow Games []GameRow
+9 -10
View File
@@ -1272,10 +1272,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
if !ok { if !ok {
return StateView{}, ErrNotAPlayer return StateView{}, ErrNotAPlayer
} }
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return StateView{}, err
}
unlock := svc.locks.lock(gameID) unlock := svc.locks.lock(gameID)
defer unlock() defer unlock()
@@ -1290,12 +1286,15 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
} }
} }
return StateView{ return StateView{
Game: pre, Game: pre,
Seat: seat, Seat: seat,
Rack: g.Hand(seat), Rack: g.Hand(seat),
BagLen: g.BagLen(), BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance), // The hint wallet moved to payments (svc.hintWallet); the deprecated accounts.hint_balance
WalletBalance: acc.HintBalance, // 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). // vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()), HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
}, nil }, nil
-71
View File
@@ -4,7 +4,6 @@ package inttest
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "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 // 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. // 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) { func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
@@ -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 { type profileBanner struct {
Banner *struct { Banner *struct {
Campaigns []struct { Campaigns []struct {
@@ -203,6 +203,12 @@ type profileBanner struct {
HoldMs int `json:"hold_ms"` HoldMs int `json:"hold_ms"`
} `json:"timings"` } `json:"timings"`
} `json:"banner"` } `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 // 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 // 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. // banner block too, so the client's profile keeps the banner instead of losing it until reload.
func TestBannerSurvivesProfileUpdate(t *testing.T) { func TestBannerSurvivesProfileUpdate(t *testing.T) {
@@ -440,10 +521,4 @@ func TestBannerUrgentBypassesEligibility(t *testing.T) {
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil { if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err) 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())
} }
@@ -140,6 +140,12 @@ func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, am
// them. // them.
const providerVKAds = "vk_ads" 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 // 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 // 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. // VK-only, D28). The client uses it to gate the "watch for chips" button.
+14
View File
@@ -385,6 +385,20 @@ type RewardOutcome struct {
AlreadyCredited bool 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 // 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 // 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. // rewarded chips are limited so a player who wants more buys) — tuned in the admin.
+42 -3
View File
@@ -57,9 +57,9 @@ type bannerTimingsDTO struct {
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse { func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
r := profileResponseFor(acc) r := profileResponseFor(acc)
// Resolve the payments gate once (execution context + present sources) and feed it to both // 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 // the hint count and the banner. The profile hint balance comes from the payments benefit
// (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy // (context-aware); the deprecated accounts.hint_balance column is no longer read, so on any
// value from profileResponseFor (zeroed in production) stands. // failure the fallback from profileResponseFor is a plain 0.
cxt, present, err := s.walletGate(ctx, acc.ID) cxt, present, err := s.walletGate(ctx, acc.ID)
if err != nil { if err != nil {
s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err)) 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.Banner = s.bannerFor(ctx, acc, cxt, present)
r.Ads = s.adsFor(ctx, acc, cxt, present)
s.fillLinkedIdentities(ctx, &r, acc.ID) s.fillLinkedIdentities(ctx, &r, acc.ID)
r.DictVersions = s.currentDictVersions() r.DictVersions = s.currentDictVersions()
return r 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, // bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
// projecting each optional colour set into its three "#rrggbb" fields (empty when // projecting each optional colour set into its three "#rrggbb" fields (empty when
// the set is absent, so JSON omitempty drops them). // the set is absent, so JSON omitempty drops them).
+14 -7
View File
@@ -60,6 +60,11 @@ type profileResponse struct {
// see the banner (a free account with an empty hint wallet and without the // see the banner (a free account with an empty hint wallet and without the
// no_banner role), absent otherwise. See banner.go. // no_banner role), absent otherwise. See banner.go.
Banner *bannerDTO `json:"banner,omitempty"` 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 // 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 // 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 // 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. // profileResponseFor projects an account into its profile DTO.
func profileResponseFor(acc account.Account) profileResponse { func profileResponseFor(acc account.Account) profileResponse {
return profileResponse{ return profileResponse{
UserID: acc.ID.String(), UserID: acc.ID.String(),
DisplayName: acc.DisplayName, DisplayName: acc.DisplayName,
PreferredLanguage: acc.PreferredLanguage, PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone, TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart.Format(awayTimeLayout), AwayStart: acc.AwayStart.Format(awayTimeLayout),
AwayEnd: acc.AwayEnd.Format(awayTimeLayout), AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
HintBalance: acc.HintBalance, // 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, BlockChat: acc.BlockChat,
BlockFriendRequests: acc.BlockFriendRequests, BlockFriendRequests: acc.BlockFriendRequests,
IsGuest: acc.IsGuest, IsGuest: acc.IsGuest,
@@ -29,10 +29,6 @@ import (
// adminPageSize is the page size of the admin console's paginated lists. // adminPageSize is the page size of the admin console's paginated lists.
const adminPageSize = 50 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 // 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 // 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 // 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.GET("/users/:id", s.consoleUserDetail)
gm.POST("/users/:id/message", s.consoleUserMessage) gm.POST("/users/:id/message", s.consoleUserMessage)
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag) 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/block", s.consoleBlockUser)
gm.POST("/users/:id/unblock", s.consoleUnblockUser) gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.POST("/users/:id/grant-role", s.consoleGrantRole) gm.POST("/users/:id/grant-role", s.consoleGrantRole)
@@ -354,7 +349,6 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view := adminconsole.UserDetailView{ view := adminconsole.UserDetailView{
ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage, ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage,
TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly, 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, CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
} }
if acc.MergedInto != uuid.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()) 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 // consoleRemoveEmail deletes the account's bound email identity (and any pending
// confirmations), freeing the address. It refuses to remove the account's only // confirmations), freeing the address. It refuses to remove the account's only
// identity, which would leave it unreachable. // identity, which would leave it unreachable.
+11 -3
View File
@@ -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 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). 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**. - 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). - **`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 - Applying a **hint** triggers an interstitial **independently** of the main cooldown, with
cooldown, with its own **1-min** cooldown. 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. - Offline — banner only.
- Respect VK's own frequency caps. - Respect VK's own frequency caps.
+16
View File
@@ -175,12 +175,28 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
(соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода (соосно кулдауну подсказок). Применение **подсказки** триггерит ролик после хода
**независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только **независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только
баннер. Уважать собственные лимиты частоты VK. баннер. Уважать собственные лимиты частоты VK.
**АМЕНД (E6, по факту реализации):** гейт **зеркальный** — сервер отдаёт кулдауны и `suppressed`
в профиле (`adsFor`), клиент сам гейтит по **единому** времени последнего показа в `localStorage`
(без раунд-трипа на ход). Таймер **общий на все виды** — вид (`hint`/`move`/`vs_ai`) лишь выбирает
нужный интервал, поэтому «независимость» подсказочного кулдауна значит лишь **более короткий
интервал от последнего показа**, а не отдельный таймер: hint-ролик и move-ролик не встают подряд
(баг раздельных таймеров: после hint-ролика move-таймер оставался нулевым → следующий ход сразу
крутил рекламу). Ролик показывается **только после подтверждённого хода или подсказки****не**
после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). Interstitial —
**только VK** (как и rewarded). Частота — глобальная на все игры (localStorage на устройство).
- **D31. `paid_account` тоже deprecated** → удаление из схемы (как `hint_balance`), в - **D31. `paid_account` тоже deprecated** → удаление из схемы (как `hint_balance`), в
пользу per-origin бенефитов «без рекламы». Существующий `ads.Eligible` пользу per-origin бенефитов «без рекламы». Существующий `ads.Eligible`
(`backend/internal/ads/ads.go:107`) расширяется: баннер гасится по origin-бенефиту, (`backend/internal/ads/ads.go:107`) расширяется: баннер гасится по origin-бенефиту,
**применимому в текущем контексте**, а не по одному глобальному флагу. Legacy **применимому в текущем контексте**, а не по одному глобальному флагу. Legacy
`paid_account`/`hint_balance` в проде никем не выставлены (потока покупки не было) → `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. Каталог — конфигурируемый (БД + админка).** Базовые ценности (атомы - **D32. Каталог — конфигурируемый (БД + админка).** Базовые ценности (атомы
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод** атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
+11 -3
View File
@@ -279,12 +279,20 @@ App отдаёт только клиентский результат просм
гасится «без рекламы» (D9). Сеть с серверным verify встроится за ads-абстракцией. На тест-контуре гасится «без рекламы» (D9). Сеть с серверным verify встроится за ads-абстракцией. На тест-контуре
build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; прод всегда крутит настоящую рекламу. build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; прод всегда крутит настоящую рекламу.
**Полноэкранный ролик** (после хода), конфигурируемые серверные значения: **Полноэкранный ролик** (после подтверждённого хода), **только VK**, конфигурируемые серверные
значения. Гейт **зеркалится на клиенте**: профиль несёт кулдауны и флаг `suppressed` (тот же гейт
«без рекламы» / `no_banner`, что и у баннера, считается на сервере в `adsFor`), а клиент сам
гейтит по **единому** времени последнего показа в `localStorage` — вид лишь выбирает нужный
интервал, поэтому hint-ролик и move-ролик не встают подряд в пределах кулдауна (один общий таймер,
не по одному на вид) — без серверного раунд-трипа на каждый ход. Контурный `VITE_ADS_STUB` подменяет
ролик тем же тостом «ad fired». Значения:
- Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**. - Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**.
- **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов). - **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов).
- Применение **подсказки** триггерит ролик после хода **независимо** от основного кулдауна, - Применение **подсказки** триггерит ролик **независимо** от основного кулдауна, со своим
со своим кулдауном **1 мин**. кулдауном **1 мин**.
- Показывается **только после подтверждённого хода или подсказки** — никогда после пропуска,
обмена или сдачи (ролик за не-очковое действие только раздражает, за них не «награждаем»).
- Оффлайн — только баннер. - Оффлайн — только баннер.
- Уважать собственные лимиты частоты VK. - Уважать собственные лимиты частоты VK.
+11
View File
@@ -37,6 +37,8 @@ type ProfileResp struct {
// Banner is the advertising-banner block, present only for a viewer eligible to // 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. // see the banner. The gateway forwards it verbatim into the Profile payload.
Banner *BannerResp `json:"banner,omitempty"` 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 // Email is the confirmed email ("" when none); TelegramLinked/VkLinked report an
// attached platform identity — they drive the profile's link/unlink/change controls. // attached platform identity — they drive the profile's link/unlink/change controls.
Email string `json:"email"` Email string `json:"email"`
@@ -47,6 +49,15 @@ type ProfileResp struct {
DictVersions []DictVersion `json:"dict_versions,omitempty"` 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. // DictVersion pairs a game variant's stable label with its current dictionary version.
type DictVersion struct { type DictVersion struct {
Variant string `json:"variant"` Variant string `json:"variant"`
+13
View File
@@ -184,6 +184,16 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
} }
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector) prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
dictVersions := encodeDictVersions(b, p.DictVersions) 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.ProfileStart(b)
fb.ProfileAddUserId(b, uid) fb.ProfileAddUserId(b, uid)
fb.ProfileAddDisplayName(b, name) fb.ProfileAddDisplayName(b, name)
@@ -206,6 +216,9 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
if p.Banner != nil { if p.Banner != nil {
fb.ProfileAddBanner(b, banner) fb.ProfileAddBanner(b, banner)
} }
if p.Ads != nil {
fb.ProfileAddAds(b, ads)
}
b.Finish(fb.ProfileEnd(b)) b.Finish(fb.ProfileEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
@@ -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")
}
}
+12
View File
@@ -264,6 +264,18 @@ table Profile {
// preload the right dictionary and pin a new local game without a separate request (added // preload the right dictionary and pin a new local game without a separate request (added
// trailing — backward-compatible). // trailing — backward-compatible).
dict_versions:[DictVersion]; 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 // BlockStatus reports the caller's current manual block. The UI fetches it after any operation
+109
View File
@@ -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()
}
+17 -1
View File
@@ -231,8 +231,21 @@ func (rcv *Profile) DictVersionsLength() int {
return 0 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) { func ProfileStart(builder *flatbuffers.Builder) {
builder.StartObject(17) builder.StartObject(18)
} }
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
@@ -291,6 +304,9 @@ func ProfileAddDictVersions(builder *flatbuffers.Builder, dictVersions flatbuffe
func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4) 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 { func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+27
View File
@@ -48,6 +48,33 @@ test('placing a tile and confirming via ✅ commits the move', async ({ page })
await expect(page.locator('.make')).toBeHidden(); 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 }) => { test('a placed tile is saved as a draft and restored on reopening the game', async ({ page }) => {
await openGame(page); await openGame(page);
await page.locator('.rack .tile').first().click(); await page.locator('.rack .tile').first().click();
+21
View File
@@ -15,6 +15,7 @@
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte'; import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte'; import { offlineMode } from '../lib/offline.svelte';
import { maybeShowInterstitial } from '../lib/ads';
import { GatewayError } from '../lib/client'; import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model'; import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
@@ -93,6 +94,11 @@
let exchangeOpen = $state(false); let exchangeOpen = $state(false);
let exchangeSel = $state<number[]>([]); let exchangeSel = $state<number[]>([]);
let resignOpen = $state(false); 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); 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 // 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 // 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 // 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. // post-move game and the refilled rack — without a follow-up game.state + game.history.
function applyMoveResult(r: MoveResult) { 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 = { view = {
game: r.game, game: r.game,
seat: r.move.player, seat: r.move.player,
@@ -941,11 +950,20 @@
const sub = toSubmit(placement); const sub = toSubmit(placement);
if (!sub) return; if (!sub) return;
busy = true; 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 { try {
applyMoveResult(await source.submitPlay(id, sub.tiles, variant)); applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
if (view?.game.hotseat) await advanceHotseat(); if (view?.game.hotseat) await advanceHotseat();
haptic('success'); haptic('success');
zoomed = false; 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) { } catch (e) {
handleError(e); handleError(e);
} finally { } finally {
@@ -1009,6 +1027,9 @@
const h = await source.hint(id); const h = await source.hint(id);
if (h.move.tiles.length && view) { if (h.move.tiles.length && view) {
placement = placementFromHint(h.move.tiles, view.rack); 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: // Scroll the (zoomed) board to the hint's placement rather than the top-left:
// focus the centre of the laid tiles' bounding box. // focus the centre of the laid tiles' bounding box.
const p = placement.pending; const p = placement.pending;
+1
View File
@@ -4,6 +4,7 @@ export { AccountDeleteConfirm } from './scrabblefb/account-delete-confirm.js';
export { AccountDeleteRequestResult } from './scrabblefb/account-delete-request-result.js'; export { AccountDeleteRequestResult } from './scrabblefb/account-delete-request-result.js';
export { AccountRef } from './scrabblefb/account-ref.js'; export { AccountRef } from './scrabblefb/account-ref.js';
export { Ack } from './scrabblefb/ack.js'; export { Ack } from './scrabblefb/ack.js';
export { AdsInfo } from './scrabblefb/ads-info.js';
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js'; export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
export { BannerCampaign } from './scrabblefb/banner-campaign.js'; export { BannerCampaign } from './scrabblefb/banner-campaign.js';
export { BannerInfo } from './scrabblefb/banner-info.js'; export { BannerInfo } from './scrabblefb/banner-info.js';
+76
View File
@@ -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);
}
}
+11 -1
View File
@@ -2,6 +2,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { AdsInfo } from '../scrabblefb/ads-info.js';
import { BannerInfo } from '../scrabblefb/banner-info.js'; import { BannerInfo } from '../scrabblefb/banner-info.js';
import { DictVersion } from '../scrabblefb/dict-version.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; 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) { static startProfile(builder:flatbuffers.Builder) {
builder.startObject(17); builder.startObject(18);
} }
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -231,6 +237,10 @@ static startDictVersionsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4); 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 { static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
+140
View File
@@ -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<string, string>();
(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();
});
});
+60 -1
View File
@@ -4,7 +4,9 @@
// flow is testable without waiting for a real ad — production never sets the flag, so a real ad that // 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). // fails to load there must not credit (no stub fallback in prod).
import { executionContext } from './wallet'; 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 * 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<RewardedResult> {
} }
return { watched: await vkShowRewarded(), stub: false }; 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<boolean> {
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;
}
+36
View File
@@ -814,6 +814,42 @@ describe('codec', () => {
expect(decodeProfile(b.asUint8Array()).banner).toBeUndefined(); 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', () => { it('decodes the profile variant preferences', () => {
const b = new Builder(64); const b = new Builder(64);
const uid = b.createString('u-1'); const uid = b.createString('u-1');
+15
View File
@@ -14,6 +14,7 @@ import type {
RobotBlockEntry, RobotBlockEntry,
RobotFriendRequestEntry, RobotFriendRequestEntry,
Banner, Banner,
AdsConfig,
BannerCampaign, BannerCampaign,
BestMove, BestMove,
BestMoveTile, BestMoveTile,
@@ -461,6 +462,7 @@ export function decodeProfile(buf: Uint8Array): Profile {
notificationsInAppOnly: p.notificationsInAppOnly(), notificationsInAppOnly: p.notificationsInAppOnly(),
variantPreferences: decodeVariantPreferences(p), variantPreferences: decodeVariantPreferences(p),
banner: decodeBanner(p), banner: decodeBanner(p),
ads: decodeAds(p),
email: s(p.email()), email: s(p.email()),
telegramLinked: p.telegramLinked(), telegramLinked: p.telegramLinked(),
vkLinked: p.vkLinked(), 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; 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 // decodeBanner projects the optional advertising-banner block of a Profile, or
// undefined when the viewer is not eligible (the field is absent). // undefined when the viewer is not eligible (the field is absent).
function decodeBanner(p: fb.Profile): Banner | undefined { function decodeBanner(p: fb.Profile): Banner | undefined {
+2
View File
@@ -45,6 +45,8 @@ export const PROFILE: Profile = {
// Every variant's current dictionary version, as the backend advertises it on the profile — // Every variant's current dictionary version, as the backend advertises it on the profile —
// the offline preloader targets `variant@version`. // the offline preloader targets `variant@version`.
dictVersions: { erudit_ru: 'v1.3.0', scrabble_ru: 'v1.3.0', scrabble_en: 'v1.3.0' }, 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 // Seed social/account data for the mock (pnpm start + Playwright). The mock profile
+11
View File
@@ -226,6 +226,8 @@ export interface Profile {
variantPreferences: Variant[]; variantPreferences: Variant[];
/** The advertising-banner block, present only for a viewer eligible to see it. */ /** The advertising-banner block, present only for a viewer eligible to see it. */
banner?: Banner; 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). */ /** The account's confirmed email address ("" when none). */
email: string; email: string;
/** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */ /** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */
@@ -237,6 +239,15 @@ export interface Profile {
dictVersions: Partial<Record<Variant, string>>; dictVersions: Partial<Record<Variant, string>>;
} }
/** 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. */ /** Banner is the advertising-banner block of an eligible viewer's profile. */
export interface Banner { export interface Banner {
campaigns: BannerCampaign[]; campaigns: BannerCampaign[];
+14
View File
@@ -225,6 +225,20 @@ export async function vkShowRewarded(): Promise<boolean> {
} }
} }
/**
* 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<boolean> {
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) * vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile)
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false * the mobile in-app file delivery, where the webview ignores <a download>. Resolves false