Release v1.14.0 — monetization launch (E5-E8) #237
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user