From d2d6955cbffb596cc15fac3809d46e12909a6ce9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 05:35:55 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(admin):=20admin=20grant=20=E2=80=94=20?= =?UTF-8?q?raw=20benefits=20and=20by-product=20reward=20bundles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /_gm user card gains a Grant panel: grant raw benefit atoms (hints / no-ads days / forever) or a defined value product (a reward bundle, including an archived one), origin-picked. Both write an admin_grant ledger row via payments.Grant / GrantProduct; the by-product grant records the source product_id + snapshot. Both refuse a chips atom (never grant currency) or a tournament atom (no credit target yet); chips/tournament products are also kept out of the by-product picker. Tests: the console grant end to end (raw, by-product, refuse a chips pack, CSRF-guarded). --- backend/README.md | 6 +- .../templates/pages/user_detail.gohtml | 20 ++++ backend/internal/adminconsole/views.go | 21 ++++ backend/internal/inttest/admin_grant_test.go | 79 +++++++++++++++ backend/internal/payments/service.go | 52 ++++++++++ backend/internal/payments/store_wallet.go | 24 +++++ .../internal/server/handlers_admin_catalog.go | 95 +++++++++++++++++++ .../internal/server/handlers_admin_console.go | 3 + docs/PAYMENTS.md | 6 +- docs/PAYMENTS_ru.md | 7 +- 10 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 backend/internal/inttest/admin_grant_test.go diff --git a/backend/README.md b/backend/README.md index bf3adcf..083dcc3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -146,7 +146,11 @@ funding segment, benefits per origin, the recorded refund risk, and the append-o (`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create / edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only, -backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The shared wire +backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The user card +also carries an admin **grant** panel: grant raw benefit atoms (hints / no-ads days / forever) or a +defined **value product** (a reward bundle, including an archived one), origin-picked; both write an +`admin_grant` ledger row via `payments.Grant` / `GrantProduct` and **refuse** a chips or `tournament` +atom (never grant currency; no tournament target yet). The shared wire contracts live in the sibling [`../pkg`](../pkg) module. **Account linking & merge** (`/api/v1/user/link/*`). `internal/link` diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index d79888e..98a425a 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -85,6 +85,26 @@ {{else}}

no ledger entries

{{end}} {{else}}

payments not enabled

{{end}} +

Grant benefits

+{{if .Grant.Present}} +

A zero-price admin sale of a value — never chips. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.

+
+ + + + +
+
+{{if .Grant.Products}} +

Grant a product

+
+ + +
+
+{{else}}

no grantable products — create a value product in the catalog

{{end}} +{{else}}

payments not enabled

{{end}} +

Roles

{{$id := .ID}} {{if .Roles}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index c1d5e66..2a34c3e 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -198,6 +198,9 @@ type UserDetailView struct { // Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present // is false when the payments domain is unwired. Finance FinanceView + // Grant is the admin-grant panel (origin picker + grantable products). Present is false when the + // payments domain is unwired. + Grant GrantFormView } // FinanceView is the account's payments picture on the user card: chip balances per funding @@ -701,3 +704,21 @@ type ProductFormView struct { PriceChip int64 Transacted bool } + +// GrantFormView is the admin-grant panel on the user card: the origin picker and the grantable +// products (value bundles — hints / no-ads days — including archived ones; chips and tournament +// products are excluded). Present is false when the payments domain is unwired. +type GrantFormView struct { + Present bool + Origins []string + Products []GrantProductOption +} + +// GrantProductOption is one grantable product in the by-product picker: its id, title, an atom +// summary, and whether it is archived (the common case for a non-public reward bundle). +type GrantProductOption struct { + ID string + Title string + Summary string + Archived bool +} diff --git a/backend/internal/inttest/admin_grant_test.go b/backend/internal/inttest/admin_grant_test.go new file mode 100644 index 0000000..b690b2f --- /dev/null +++ b/backend/internal/inttest/admin_grant_test.go @@ -0,0 +1,79 @@ +//go:build integration + +package inttest + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/payments" +) + +// benefitFor returns the account's benefit on the given origin from its statement. +func benefitFor(t *testing.T, pay *payments.Service, id uuid.UUID, origin payments.Source) payments.OriginBenefit { + t.Helper() + stmt, err := pay.AccountStatement(context.Background(), id) + if err != nil { + t.Fatalf("statement: %v", err) + } + for _, b := range stmt.Benefits { + if b.Origin == origin { + return b + } + } + return payments.OriginBenefit{} +} + +// TestConsoleAdminGrant drives the admin grant: a raw benefit grant, a by-product grant of a reward +// bundle, and a refusal to grant a chips pack; the create is CSRF-guarded. +func TestConsoleAdminGrant(t *testing.T) { + ctx := context.Background() + srv, _, pay := bannerServer(t) + h := srv.Handler() + id := provisionAccount(t) + const origin = "http://admin.test" + base := "http://admin.test/_gm/users/" + id.String() + + // CSRF: a grant without the origin header is refused. + if code, _ := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=1", ""); code != http.StatusForbidden { + t.Fatalf("grant without origin = %d, want 403", code) + } + + // Raw grant: 5 hints + 30 no-ads days to the direct origin. + if code, body := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=5&noads=30", origin); code != http.StatusOK || !strings.Contains(body, "Granted") { + t.Fatalf("raw grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted")) + } + if b := benefitFor(t, pay, id, payments.SourceDirect); b.Hints != 5 || b.AdsPaidUntil.IsZero() { + t.Fatalf("after raw grant: hints=%d adsUntil-zero=%v, want 5 hints + a no-ads term", b.Hints, b.AdsPaidUntil.IsZero()) + } + + // By-product grant: an archived reward bundle (3 hints) to vk. + reward, err := pay.CreateProduct(ctx, payments.ProductInput{ + Title: "reward-3-hints", Atoms: []payments.AtomLine{{Atom: "hints", Quantity: 3}}, + }, false) + if err != nil { + t.Fatalf("create reward: %v", err) + } + if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=vk&product_id="+reward.String(), origin); code != http.StatusOK || !strings.Contains(body, "Granted") { + t.Fatalf("product grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted")) + } + if b := benefitFor(t, pay, id, payments.SourceVK); b.Hints != 3 { + t.Fatalf("after product grant: vk hints=%d, want 3", b.Hints) + } + + // A chips pack cannot be granted. + pack, err := pay.CreateProduct(ctx, payments.ProductInput{ + Title: "grant-pack", Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 100}}, + Prices: []payments.PriceLine{{Method: "direct", Currency: payments.CurrencyRUB, Amount: 14900}}, + }, true) + if err != nil { + t.Fatalf("create pack: %v", err) + } + if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=direct&product_id="+pack.String(), origin); code != http.StatusOK || !strings.Contains(body, "cannot grant chips") { + t.Fatalf("chips grant = %d, has 'cannot grant chips' = %v", code, strings.Contains(body, "cannot grant chips")) + } +} diff --git a/backend/internal/payments/service.go b/backend/internal/payments/service.go index 5a8b177..a0c55ee 100644 --- a/backend/internal/payments/service.go +++ b/backend/internal/payments/service.go @@ -156,6 +156,41 @@ func (s *Service) Grant(ctx context.Context, accountID uuid.UUID, origin Source, return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock()) } +// GrantProduct grants a product's benefit atoms (hints, no-ads days) to an origin as a zero-price +// admin sale, recording the source product on the ledger row (auditable to it). It refuses a +// product carrying the chips atom (never granted — D16) or the tournament atom (no credit target +// yet), and one whose atoms yield no grantable benefit. +func (s *Service) GrantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID) error { + if !origin.Valid() { + return fmt.Errorf("payments: invalid grant origin %q", origin) + } + in, err := s.store.productInput(ctx, productID) + if err != nil { + return err + } + var d benefitDelta + for _, a := range in.Atoms { + switch a.Atom { + case "hints": + d.hintsAdd += a.Quantity + case "noads_days": + d.noAdsDays += a.Quantity + case atomChips: + return ErrCannotGrantChips + case "tournament": + return ErrCannotGrantTournament + } + } + if d.zero() { + return ErrNothingToGrant + } + snapshot, err := marshalGrantProduct(productID, in.Title, d) + if err != nil { + return err + } + return s.store.grantProduct(ctx, accountID, origin, productID, d, snapshot, s.clock()) +} + // MergeTx merges the secondary account's segments and benefits into the primary inside the // caller's transaction (the account-merge flow). The caller invalidates the affected caches // after committing (Invalidate). @@ -244,3 +279,20 @@ func marshalGrant(d benefitDelta) ([]byte, error) { } return b, nil } + +// marshalGrantProduct builds the snapshot for an admin grant-by-product: the source product and the +// benefit atoms it granted (price 0). +func marshalGrantProduct(productID uuid.UUID, title string, d benefitDelta) ([]byte, error) { + atoms := map[string]int{} + if d.hintsAdd > 0 { + atoms["hints"] = d.hintsAdd + } + if d.noAdsDays > 0 { + atoms["noads_days"] = d.noAdsDays + } + b, err := json.Marshal(purchaseSnapshot{ProductID: productID.String(), Title: title, Atoms: atoms, PriceChips: 0}) + if err != nil { + return nil, fmt.Errorf("payments: marshal product grant snapshot: %w", err) + } + return b, nil +} diff --git a/backend/internal/payments/store_wallet.go b/backend/internal/payments/store_wallet.go index 59cff1b..d348cd3 100644 --- a/backend/internal/payments/store_wallet.go +++ b/backend/internal/payments/store_wallet.go @@ -26,6 +26,14 @@ var ( // ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it // cannot be bought with chips. ErrNotAValue = errors.New("payments: product is not a chip-priced value") + // ErrCannotGrantChips means an admin grant targeted a product carrying the chips atom — the + // admin never grants currency (a gifted balance would bypass the cash desk, D16). + ErrCannotGrantChips = errors.New("payments: cannot grant chips") + // ErrCannotGrantTournament means an admin grant targeted a product carrying the tournament atom, + // which has no credit target until the tournament stage. + ErrCannotGrantTournament = errors.New("payments: cannot grant a tournament atom yet") + // ErrNothingToGrant means the product's atoms yield no grantable benefit (hints / no-ads days). + ErrNothingToGrant = errors.New("payments: product has nothing to grant") ) // withTx runs fn inside a transaction on db, rolling back on error or panic. @@ -312,6 +320,22 @@ func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d return nil } +// grantProduct is grant with the source product recorded on the ledger row (product_id), for an +// admin grant-by-product — the benefit is the product's atoms, the ledger stays auditable to it. +func (s *Store) grantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error { + err := withTx(ctx, s.db, func(tx *sql.Tx) error { + if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, &productID, nil, nil, nil, snapshot, now); err != nil { + return err + } + return applyBenefitTx(ctx, tx, accountID, origin, d, now) + }) + if err != nil { + return err + } + s.cache.invalidate(accountID) + return nil +} + // consumeHint decrements one hint from the first applicable origin (in the given priority order) // that has one, with a guarded update. It returns whether a hint was spent. func (s *Store) consumeHint(ctx context.Context, accountID uuid.UUID, origins []Source, now time.Time) (bool, error) { diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go index bae1644..aca135b 100644 --- a/backend/internal/server/handlers_admin_catalog.go +++ b/backend/internal/server/handlers_admin_catalog.go @@ -1,11 +1,14 @@ package server import ( + "context" "errors" + "fmt" "strconv" "strings" "github.com/gin-gonic/gin" + "github.com/google/uuid" "scrabble/backend/internal/adminconsole" "scrabble/backend/internal/payments" @@ -181,3 +184,95 @@ func (s *Server) consoleDeleteProductAction(c *gin.Context) { } s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack) } + +// consoleGrant grants raw benefit atoms (hints / no-ads days / forever) to a chosen origin — a +// zero-price admin sale. +func (s *Server) consoleGrant(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + if s.payments == nil { + s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back) + return + } + origin := payments.Source(c.PostForm("origin")) + hints, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("hints"))) + noads, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("noads"))) + forever := c.PostForm("forever") != "" + if err := s.payments.Grant(c.Request.Context(), id, origin, hints, noads, forever); err != nil { + s.renderConsoleMessage(c, "Grant failed", err.Error(), back) + return + } + s.publishBannerChange(id) + s.renderConsoleMessage(c, "Granted", "the benefit was granted", back) +} + +// consoleGrantProduct grants a value product's atoms (a reward bundle, possibly archived) to a +// chosen origin. It refuses a product carrying chips or the tournament atom (payments enforces it). +func (s *Server) consoleGrantProduct(c *gin.Context) { + id, ok := s.consoleUUID(c, "/_gm/users") + if !ok { + return + } + back := "/_gm/users/" + id.String() + if s.payments == nil { + s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back) + return + } + origin := payments.Source(c.PostForm("origin")) + productID, err := uuid.Parse(strings.TrimSpace(c.PostForm("product_id"))) + if err != nil { + s.renderConsoleMessage(c, "Grant failed", "choose a product", back) + return + } + if err := s.payments.GrantProduct(c.Request.Context(), id, origin, productID); err != nil { + s.renderConsoleMessage(c, "Grant failed", err.Error(), back) + return + } + s.publishBannerChange(id) + s.renderConsoleMessage(c, "Granted", "the product was granted", back) +} + +// grantForm builds the admin-grant panel: the origin picker and the grantable products (value +// bundles, including archived ones — chips and tournament products are excluded). +func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView { + fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}} + products, err := s.payments.AdminCatalog(ctx) + if err != nil { + return fv + } + for _, p := range products { + if grantableProduct(p) { + fv.Products = append(fv.Products, adminconsole.GrantProductOption{ + ID: p.ID.String(), Title: p.Title, Summary: atomSummary(p.Atoms), Archived: !p.Active, + }) + } + } + return fv +} + +// grantableProduct reports whether a product can be admin-granted: it carries at least one benefit +// atom (hints / no-ads days) and no chips or tournament atom. +func grantableProduct(p payments.AdminProduct) bool { + benefit := false + for _, a := range p.Atoms { + switch a.Atom { + case "chips", "tournament": + return false + case "hints", "noads_days": + benefit = true + } + } + return benefit +} + +// atomSummary renders a product's atoms as "hints×5, noads_days×30". +func atomSummary(atoms []payments.AtomLine) string { + parts := make([]string, 0, len(atoms)) + for _, a := range atoms { + parts = append(parts, fmt.Sprintf("%s×%d", a.Atom, a.Quantity)) + } + return strings.Join(parts, ", ") +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 1853fd7..ef997a2 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -58,6 +58,8 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.POST("/users/:id/grant-role", s.consoleGrantRole) gm.POST("/users/:id/revoke-role", s.consoleRevokeRole) gm.POST("/users/:id/remove-email", s.consoleRemoveEmail) + gm.POST("/users/:id/grant", s.consoleGrant) + gm.POST("/users/:id/grant-product", s.consoleGrantProduct) gm.POST("/users/:id/delete", s.consoleDeleteUser) gm.GET("/reasons", s.consoleReasons) gm.POST("/reasons", s.consoleCreateReason) @@ -451,6 +453,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) { } else { s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err)) } + view.Grant = s.grantForm(ctx) } s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) } diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index 15e6744..eadad8f 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -319,10 +319,12 @@ cache. Identity-presence (which segments are awake, §6) is supplied by the call here, so unlink/re-link takes effect immediately. **Admin rewards.** An admin grants **concrete values only** (no-ads / hints) — **never -chips** (a gifted currency balance = a store cash-desk bypass). The admin **picks the +chips** (a gifted currency balance = a store cash-desk bypass). It grants either raw atoms or a +**defined value product** (a reward bundle, which may be archived — hidden from the store but +grantable); both refuse a `chips` or `tournament` atom. The admin **picks the origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume = low risk, `origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0 -chips — full audit of rewards. +chips (the by-product grant records the source `product_id` + snapshot) — full audit of rewards. **Per-user financial report** in the admin console `/_gm` — segment balances, payments, spends, grants, refunds, full history — as an extension of the existing user card diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 10ae6b0..66d6363 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -318,10 +318,13 @@ in-process кэш сегментов и бенефитов по ключу-ак вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу. **Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы / -подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ +подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Выдаёт либо +сырыми атомами, либо **готовым продуктом-ценностью** (набор-награда, возможно архивный — скрыт +из магазина, но выдаётся); оба отказывают на атоме `chips` или `tournament`. Админ **выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk` точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала -типа `admin_grant`, цена 0 Фишек — полный аудит наград. +типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) — +полный аудит наград. **Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты, гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`, From 82648a439848eb25f8e08d825ce2402327a94848 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 06:26:49 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(game):=20show=20granted/bought=20hints?= =?UTF-8?q?=20in-game=20=E2=80=94=20finish=20the=20D31=20wire=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-game hint badge ignored the payments hint wallet: loading a game clobbered app.profile.hintBalance with the deprecated StateView.wallet_balance (zeroed in the D31 domain removal but left in the protocol as a dead 0, then synced over the real balance at game load). Finish the removal honestly. StateView carries the per-game allowance alone (hints_remaining); the purchasable wallet lives solely on the profile and the client adds it (lib/hints). Removed StateView.wallet_balance across every layer — backend StateView/DTO, notify.PlayerState (live events), gateway StateResp + wire.StateView, the client model/codec/mock/localgame — and dropped the now-unused wallet arg from hintsRemaining. The FBS field is tombstoned `(deprecated)` (not deleted) so the vtable slots after it stay stable across a rolling deploy; no accessor is generated. HintResult keeps wallet_balance (the real post-spend payments balance the client adopts into the profile). The StateView type no longer has walletBalance, so the clobber cannot return without a compile error. Tests: hintsRemaining (2-arg); hints.hintsLeft (allowance + live wallet, no strip); the game state/hint integration (allowance-only HintsRemaining); gateway transcode; FBS regen. --- backend/internal/game/eventwire.go | 1 - backend/internal/game/helpers_test.go | 16 ++++++------ backend/internal/game/service.go | 18 ++++++-------- backend/internal/game/types.go | 10 +++----- backend/internal/inttest/game_test.go | 13 +++++----- backend/internal/notify/encode.go | 1 - backend/internal/notify/payload.go | 1 - backend/internal/server/dto.go | 2 -- gateway/internal/backendclient/api.go | 1 - gateway/internal/transcode/encode.go | 1 - gateway/internal/transcode/transcode_test.go | 6 ++--- pkg/fbs/scrabble.fbs | 18 ++++++-------- pkg/fbs/scrabblefb/StateView.go | 15 ----------- pkg/wire/build.go | 2 -- ui/src/game/Game.svelte | 10 ++++---- ui/src/gen/fbs/scrabblefb/state-view.ts | 12 +-------- ui/src/lib/codec.ts | 1 - ui/src/lib/gamecache.test.ts | 2 +- ui/src/lib/gamedelta.test.ts | 8 +++--- ui/src/lib/hints.test.ts | 26 +++++++++----------- ui/src/lib/hints.ts | 23 +++++++---------- ui/src/lib/localgame/source.ts | 1 - ui/src/lib/mock/client.ts | 9 +++---- ui/src/lib/model.ts | 7 ++---- ui/src/lib/preload.test.ts | 2 +- 25 files changed, 76 insertions(+), 130 deletions(-) diff --git a/backend/internal/game/eventwire.go b/backend/internal/game/eventwire.go index 6b65685..c9d2b31 100644 --- a/backend/internal/game/eventwire.go +++ b/backend/internal/game/eventwire.go @@ -74,7 +74,6 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play Rack: rack, BagLen: v.BagLen, HintsRemaining: v.HintsRemaining, - WalletBalance: v.WalletBalance, } if includeAlphabet { tab, err := engine.AlphabetTable(v.Game.Variant) diff --git a/backend/internal/game/helpers_test.go b/backend/internal/game/helpers_test.go index 0cca088..c19b994 100644 --- a/backend/internal/game/helpers_test.go +++ b/backend/internal/game/helpers_test.go @@ -55,16 +55,16 @@ func TestPayloadExchangeRoundTrip(t *testing.T) { } func TestHintsRemaining(t *testing.T) { - cases := []struct{ allowance, used, wallet, want int }{ - {1, 0, 3, 4}, - {1, 1, 3, 3}, - {1, 2, 3, 3}, // used past allowance clamps to 0 - {0, 0, 5, 5}, - {2, 1, 0, 1}, + cases := []struct{ allowance, used, want int }{ + {1, 0, 1}, + {1, 1, 0}, + {1, 2, 0}, // used past allowance clamps to 0 + {3, 1, 2}, + {0, 0, 0}, } for _, c := range cases { - if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want { - t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want) + if got := hintsRemaining(c.allowance, c.used); got != c.want { + t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want) } } } diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 5c47d0c..b6c90f8 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -1207,7 +1207,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint return HintResult{}, err } used++ - return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil + return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used), WalletBalance: walletAfter}, nil } // Candidates returns the to-move player's legal plays for a seated player on @@ -1290,11 +1290,9 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID) Seat: seat, Rack: g.Hand(seat), BagLen: g.BagLen(), - // The hint wallet moved to payments (svc.hintWallet); the deprecated accounts.hint_balance - // is no longer read, so the wire wallet is 0 and HintsRemaining is the per-seat allowance. - // The client adds the profile's payments hint balance on top (lib/hints.hintsLeft). - HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, 0), - WalletBalance: 0, + // HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile + // (payments) and the client adds it (lib/hints.hintsLeft). + HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed), // vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn). HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()), }, nil @@ -1775,10 +1773,10 @@ func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, e return svc.registry.DictBytes(variant, version) } -// hintsRemaining is a player's remaining hint budget: the unspent per-game -// allowance plus the profile wallet. -func hintsRemaining(allowance, used, wallet int) int { - return max(0, allowance-used) + wallet +// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate, +// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft). +func hintsRemaining(allowance, used int) int { + return max(0, allowance-used) } // allowedTimeout reports whether d is one of the offered move clocks. diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 26b173e..bdade84 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -211,10 +211,9 @@ type MoveResult struct { BagLen int } -// HintResult is a revealed hint and the requesting player's remaining hint -// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is -// the global wallet alone, so the client can keep its live wallet authoritative and -// re-derive the per-game allowance (HintsRemaining - WalletBalance). +// HintResult is a revealed hint with the per-seat allowance remaining (HintsRemaining) and the +// purchasable hint wallet after spending one (WalletBalance, from payments). The client adopts +// WalletBalance into the profile so the badge stays live across games (lib/hints). type HintResult struct { Move engine.MoveRecord HintsRemaining int @@ -241,9 +240,6 @@ type StateView struct { Rack []string BagLen int HintsRemaining int - // WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds - // it in with the per-game allowance), so the client keeps the wallet live across games. - WalletBalance int // HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left // until the idle hint unlocks (the robot's last move plus the idle window, from the server clock); // 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 423805b..18fe3f9 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -424,13 +424,13 @@ func TestHintPolicy(t *testing.T) { t.Fatalf("first hint: %v", err) } // The allowance is spent before the wallet: with an empty wallet, the state now reports no - // hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0. + // per-game allowance left (HintsRemaining is the allowance alone; the wallet lives on the profile). st, err := svc.GameState(ctx, g.ID, seats[0]) if err != nil { t.Fatalf("state: %v", err) } - if st.HintsRemaining != 0 || st.WalletBalance != 0 { - t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance) + if st.HintsRemaining != 0 { + t.Errorf("after allowance hint: hints=%d, want 0", st.HintsRemaining) } if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) { t.Fatalf("second hint = %v, want ErrNoHintsLeft", err) @@ -444,9 +444,10 @@ func TestHintPolicy(t *testing.T) { if err != nil { t.Fatalf("wallet hint: %v", err) } - // The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone. - if res.HintsRemaining != 1 || res.WalletBalance != 1 { - t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance) + // The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped + // 2->1 and WalletBalance carries it (the client adopts it into the profile). + if res.HintsRemaining != 0 || res.WalletBalance != 1 { + t.Errorf("wallet hint: hints=%d wallet=%d, want 0/1", res.HintsRemaining, res.WalletBalance) } // game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total // that feeds the player's lifetime hint statistics, not just the allowance. diff --git a/backend/internal/notify/encode.go b/backend/internal/notify/encode.go index 5c03192..d2b1914 100644 --- a/backend/internal/notify/encode.go +++ b/backend/internal/notify/encode.go @@ -83,7 +83,6 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT Rack: s.Rack, BagLen: s.BagLen, HintsRemaining: s.HintsRemaining, - WalletBalance: s.WalletBalance, Alphabet: alphabet, }) } diff --git a/backend/internal/notify/payload.go b/backend/internal/notify/payload.go index 18c9e27..abb4c95 100644 --- a/backend/internal/notify/payload.go +++ b/backend/internal/notify/payload.go @@ -56,7 +56,6 @@ type PlayerState struct { Rack []int BagLen int HintsRemaining int - WalletBalance int Alphabet []AlphabetLetter } diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 7549539..634d583 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -177,7 +177,6 @@ type stateDTO struct { Rack []int `json:"rack"` BagLen int `json:"bag_len"` HintsRemaining int `json:"hints_remaining"` - WalletBalance int `json:"wallet_balance"` // HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human // game / first move / not your turn). The client anchors a monotonic countdown to it. HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"` @@ -334,7 +333,6 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) { Rack: rack, BagLen: v.BagLen, HintsRemaining: v.HintsRemaining, - WalletBalance: v.WalletBalance, HintUnlockLeftSeconds: v.HintUnlockLeftSeconds, } if includeAlphabet { diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index b07759c..e31b533 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -197,7 +197,6 @@ type StateResp struct { Rack []int `json:"rack"` BagLen int `json:"bag_len"` HintsRemaining int `json:"hints_remaining"` - WalletBalance int `json:"wallet_balance"` HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"` Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"` } diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 46e3aa3..77a9c83 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -405,7 +405,6 @@ func toWireState(s backendclient.StateResp) wire.StateView { Rack: s.Rack, BagLen: s.BagLen, HintsRemaining: s.HintsRemaining, - WalletBalance: s.WalletBalance, HintUnlockLeftSeconds: s.HintUnlockLeftSeconds, Alphabet: alphabet, } diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index 32a2ca2..267b60e 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) { if r.URL.Path != "/api/v1/user/games/g-1/state" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`)) + _, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"hint_unlock_left_seconds":1200}`)) }) defer cleanup() @@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) { t.Fatalf("handler: %v", err) } st := fb.GetRootAsStateView(payload, 0) - if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 { - t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds()) + if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.HintUnlockLeftSeconds() != 1200 { + t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.HintUnlockLeftSeconds()) } game := st.Game(nil) if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 6189134..5530869 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -330,12 +330,11 @@ table StateView { bag_len:int; hints_remaining:int; alphabet:[AlphabetEntry]; - // wallet_balance is the requesting player's global hint-wallet balance, sent apart from - // hints_remaining (which folds the wallet in with the per-game allowance) so the client can - // separate the two: the per-game allowance remaining is hints_remaining - wallet_balance, and - // the wallet is a single global figure the client keeps live across games (added trailing — - // backward-compatible). - wallet_balance:int; + // wallet_balance is deprecated (D31): the purchasable hint wallet moved to the profile (payments), + // and hints_remaining now carries the per-game allowance alone. The field is tombstoned rather than + // deleted so the vtable slots of the fields after it stay stable across a rolling deploy (an old + // SPA served before the deploy must keep reading a new gateway correctly). No accessor is generated. + wallet_balance:int (deprecated); // hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks // (the robot's last move plus the idle window, computed from the SERVER clock online / the device // clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move @@ -393,10 +392,9 @@ table ComplaintRequest { note:string; } -// HintResult is the top-ranked move plus the remaining hint budget. wallet_balance is the -// global hint-wallet balance after spending (see StateView.wallet_balance), so the client -// refreshes its live wallet and re-derives the per-game allowance (added trailing — -// backward-compatible). +// HintResult is the top-ranked move plus hints_remaining (the per-game allowance alone) and +// wallet_balance (the purchasable hint wallet after spending, from payments). The client adopts +// wallet_balance into the profile so the badge stays live across games (see lib/hints). table HintResult { move:MoveRecord; hints_remaining:int; diff --git a/pkg/fbs/scrabblefb/StateView.go b/pkg/fbs/scrabblefb/StateView.go index 2957e44..9cf7ae9 100644 --- a/pkg/fbs/scrabblefb/StateView.go +++ b/pkg/fbs/scrabblefb/StateView.go @@ -144,18 +144,6 @@ func (rcv *StateView) AlphabetLength() int { return 0 } -func (rcv *StateView) WalletBalance() int32 { - o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) - if o != 0 { - return rcv._tab.GetInt32(o + rcv._tab.Pos) - } - return 0 -} - -func (rcv *StateView) MutateWalletBalance(n int32) bool { - return rcv._tab.MutateInt32Slot(16, n) -} - func (rcv *StateView) HintUnlockLeftSeconds() int32 { o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) if o != 0 { @@ -195,9 +183,6 @@ func StateViewAddAlphabet(builder *flatbuffers.Builder, alphabet flatbuffers.UOf func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } -func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) { - builder.PrependInt32Slot(6, walletBalance, 0) -} func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) { builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0) } diff --git a/pkg/wire/build.go b/pkg/wire/build.go index 51f1081..1412ee7 100644 --- a/pkg/wire/build.go +++ b/pkg/wire/build.go @@ -92,7 +92,6 @@ type StateView struct { Rack []int BagLen int HintsRemaining int - WalletBalance int HintUnlockLeftSeconds int Alphabet []AlphabetEntry } @@ -256,7 +255,6 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT { fb.StateViewAddRack(b, rack) fb.StateViewAddBagLen(b, int32(s.BagLen)) fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining)) - fb.StateViewAddWalletBalance(b, int32(s.WalletBalance)) fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds)) if hasAlphabet { fb.StateViewAddAlphabet(b, alphabet) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f6ab404..bf16d99 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -251,7 +251,8 @@ view = st; // Anchor the vs_ai idle-hint countdown to the freshly fetched seconds-left (0 = open / non-vs_ai). armHintGate(st.game.vsAi ? (st.hintUnlockLeftSeconds ?? 0) : 0); - syncWallet(st.walletBalance); + // The game state no longer carries the hint wallet (it lives on the profile); do not touch + // app.profile.hintBalance here — that would clobber the authoritative payments balance. // Seed the unread flag from the authoritative state (the live stream only raises it). seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages); moves = hist.moves; @@ -833,10 +834,9 @@ seat: r.move.player, rack: r.rack, bagLen: r.bagLen, - // A move is not a hint, so the per-game allowance and the wallet are unchanged: carry both - // forward (their difference is the stable allowance; the badge adds the live wallet). + // A move is not a hint, so the per-game allowance is unchanged: carry it forward. The badge + // adds the live wallet from the profile. hintsRemaining: view?.hintsRemaining ?? 0, - walletBalance: view?.walletBalance ?? 0, }; // The move result is an authoritative per-viewer view: a nudge the actor just answered by // moving is already cleared server-side, so reconcile the unread flag from it. @@ -1047,7 +1047,7 @@ recenter++; } if (isCoarse() && !landscape) zoomed = true; - view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance }; + view = { ...view, hintsRemaining: h.hintsRemaining }; syncWallet(h.walletBalance); // The hint is the engine's own top-ranked, fully scored legal move: reuse it as the // preview instead of a redundant evaluate (same engine call, same placement). Cancel any diff --git a/ui/src/gen/fbs/scrabblefb/state-view.ts b/ui/src/gen/fbs/scrabblefb/state-view.ts index 6d546fd..91eb112 100644 --- a/ui/src/gen/fbs/scrabblefb/state-view.ts +++ b/ui/src/gen/fbs/scrabblefb/state-view.ts @@ -69,11 +69,6 @@ alphabetLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } -walletBalance():number { - const offset = this.bb!.__offset(this.bb_pos, 16); - return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; -} - hintUnlockLeftSeconds():number { const offset = this.bb!.__offset(this.bb_pos, 18); return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; @@ -131,10 +126,6 @@ static startAlphabetVector(builder:flatbuffers.Builder, numElems:number) { builder.startVector(4, numElems, 4); } -static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) { - builder.addFieldInt32(6, walletBalance, 0); -} - static addHintUnlockLeftSeconds(builder:flatbuffers.Builder, hintUnlockLeftSeconds:number) { builder.addFieldInt32(7, hintUnlockLeftSeconds, 0); } @@ -144,7 +135,7 @@ static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset { return offset; } -static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number, hintUnlockLeftSeconds:number):flatbuffers.Offset { +static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, hintUnlockLeftSeconds:number):flatbuffers.Offset { StateView.startStateView(builder); StateView.addGame(builder, gameOffset); StateView.addSeat(builder, seat); @@ -152,7 +143,6 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse StateView.addBagLen(builder, bagLen); StateView.addHintsRemaining(builder, hintsRemaining); StateView.addAlphabet(builder, alphabetOffset); - StateView.addWalletBalance(builder, walletBalance); StateView.addHintUnlockLeftSeconds(builder, hintUnlockLeftSeconds); return StateView.endStateView(builder); } diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index f1724f9..f99fde5 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -575,7 +575,6 @@ function decodeStateViewTable(v: fb.StateView): StateView { rack, bagLen: v.bagLen(), hintsRemaining: v.hintsRemaining(), - walletBalance: v.walletBalance(), hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(), }; } diff --git a/ui/src/lib/gamecache.test.ts b/ui/src/lib/gamecache.test.ts index 6b8e4fd..5cc4788 100644 --- a/ui/src/lib/gamecache.test.ts +++ b/ui/src/lib/gamecache.test.ts @@ -23,7 +23,7 @@ function gameView(id: string): GameView { } function view(id: string, rack: string[] = ['A', 'B']): StateView { - return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; + return { game: gameView(id), seat: 0, rack, bagLen: 50, hintsRemaining: 1 }; } function move(player: number): MoveRecord { diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index 67fcaf0..7c6be9a 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -28,7 +28,7 @@ function move(player: number): MoveRecord { } function cache(moveCount: number, seat = 0, over = false): CachedGame { - const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; + const view: StateView = { game: gameView(moveCount, over), seat, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }; return { view, moves: [] }; } @@ -38,7 +38,7 @@ function delta(moveCount: number, player: number, bagLen = 47): MoveDelta { describe('seedInitialState', () => { it('wraps an initial view with an empty journal', () => { - const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2, walletBalance: 0 }; + const view: StateView = { game: gameView(0), seat: 1, rack: ['x'], bagLen: 80, hintsRemaining: 2 }; expect(seedInitialState(view)).toEqual({ view, moves: [] }); }); }); @@ -153,12 +153,12 @@ describe('applyOpponentJoined', () => { { seat: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: 'opp', displayName: 'Opp', score: 0, hintsUsed: 0, isWinner: false }, ] }; - return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0, walletBalance: 0 }; + return { game, seat: 0, rack: ['x'], bagLen: 90, hintsRemaining: 0 }; } it("adopts the joined seats and status while preserving the cached rack and moves", () => { // The cached open game is still "searching": empty seats, status open, the starter's own rack. - const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }, moves: [move(0)] }; + const cached: CachedGame = { view: { game: { ...gameView(2), status: 'open', seats: [] }, seat: 0, rack: ['a', 'b'], bagLen: 50, hintsRemaining: 1 }, moves: [move(0)] }; const res = applyOpponentJoined(cached, joinedState()); expect(res?.view.game.status).toBe('active'); expect(res?.view.game.seats).toHaveLength(2); diff --git a/ui/src/lib/hints.test.ts b/ui/src/lib/hints.test.ts index cb81f4b..9f83d66 100644 --- a/ui/src/lib/hints.test.ts +++ b/ui/src/lib/hints.test.ts @@ -1,33 +1,31 @@ import { describe, expect, it } from 'vitest'; import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from './hints'; -// view carries only the two fields hintsLeft reads. -const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance }); +// view carries only the field hintsLeft reads (the per-game allowance). +const view = (hintsRemaining: number) => ({ hintsRemaining }); describe('hintsLeft', () => { it('is zero without a view', () => { expect(hintsLeft(null, 5)).toBe(0); }); - it('adds the per-game allowance to the live wallet (fresh view)', () => { - // hints_remaining 4 = allowance 1 + wallet 3; live wallet matches the snapshot → 1 + 3. - expect(hintsLeft(view(4, 3), 3)).toBe(4); + it('adds the per-game allowance to the live wallet', () => { + expect(hintsLeft(view(1), 3)).toBe(4); // allowance 1 + wallet 3 }); - it('reflects the LIVE wallet, not the per-game snapshot (the staleness fix)', () => { - // The view was fetched when the wallet was 3 (allowance 1), but a wallet hint was since spent - // in another game, so the live wallet is 2: the count must drop to 1 + 2 = 3, not stay at 4. - expect(hintsLeft(view(4, 3), 2)).toBe(3); + it('reflects the LIVE wallet, not the view (the staleness fix)', () => { + // A wallet hint spent in another game since this view was fetched → the live wallet is 2, so the + // count is 1 + 2 = 3. The wallet is always passed live (from the profile), never read off the view. + expect(hintsLeft(view(1), 2)).toBe(3); }); it('shows just the wallet when the per-game allowance is used up', () => { - // allowance 0 (hints_remaining 3 == snapshot wallet 3); live wallet 3 → 0 + 3. - expect(hintsLeft(view(3, 3), 3)).toBe(3); + expect(hintsLeft(view(0), 3)).toBe(3); // allowance 0 + wallet 3 }); - it('clamps a non-negative allowance and wallet', () => { - expect(hintsLeft(view(2, 3), 0)).toBe(0); - expect(hintsLeft(view(1, 0), -5)).toBe(1); + it('clamps negatives', () => { + expect(hintsLeft(view(1), -5)).toBe(1); // a negative wallet clamps to 0 + expect(hintsLeft(view(-2), 3)).toBe(3); // a negative allowance clamps to 0 }); }); diff --git a/ui/src/lib/hints.ts b/ui/src/lib/hints.ts index 9b200c7..07726bd 100644 --- a/ui/src/lib/hints.ts +++ b/ui/src/lib/hints.ts @@ -1,28 +1,23 @@ // Hint-count derivation, kept out of the .svelte component so it is unit-testable. // -// The badge shows the per-game hint allowance remaining plus the player's global hint -// wallet. The server's hints_remaining folds the two together, but the wallet is global — -// shared across every game — so caching the combined number per game makes it go stale the -// moment a wallet hint is spent in another game. We therefore split it: the per-game -// allowance is hints_remaining - wallet_balance (both from the same fetch, so it is stable -// and cacheable), and the wallet is read live from the global profile, never the per-game -// snapshot. +// The badge shows the per-game hint allowance remaining plus the player's global hint wallet. The +// view's hints_remaining is the per-game allowance alone; the wallet is global (shared across every +// game) and read live from the profile (payments), never the per-game snapshot — so a wallet hint +// spent in another game stays reflected here. import type { StateView } from './model'; /** - * hintsLeft is the hint count for the badge: the per-game allowance remaining (the view's - * hints_remaining minus the wallet snapshot baked into that same view) plus the live global - * wallet balance. Passing the live wallet (not view.walletBalance) is what keeps the count - * correct when a wallet hint was spent in another game since this view was fetched. + * hintsLeft is the hint count for the badge: the per-game allowance remaining (view.hintsRemaining) + * plus the live global wallet balance (passed in from the profile, so it stays correct when a wallet + * hint was spent in another game since this view was fetched). */ export function hintsLeft( - view: Pick | null, + view: Pick | null, walletBalance: number, ): number { if (!view) return 0; - const allowance = Math.max(0, view.hintsRemaining - view.walletBalance); - return allowance + Math.max(0, walletBalance); + return Math.max(0, view.hintsRemaining) + Math.max(0, walletBalance); } /** HINT_GATE_MS is the idle time a vs_ai player must be stuck on a turn before a hint unlocks. */ diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 5f890ed..a10e93d 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -435,7 +435,6 @@ export class LocalSource implements GameLoopSource { rack, bagLen: entry.game.bagLength, hintsRemaining: 1, - walletBalance: 0, hintUnlockLeftSeconds: this.hintUnlockLeft(entry), locked, }; diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 0e6363c..b8c1393 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -408,10 +408,9 @@ export class MockGateway implements GatewayClient { seat: this.mySeat(g), rack: [...g.rack], bagLen: g.bagLen, - // g.hintsRemaining is the per-game allowance; the wallet is the shared profile balance. - // hints_remaining folds the two together (as the backend does), walletBalance is the wallet. - hintsRemaining: g.hintsRemaining + this.profile.hintBalance, - walletBalance: this.profile.hintBalance, + // hintsRemaining is the per-game allowance alone; the wallet lives on the profile and the + // client adds it (lib/hints). + hintsRemaining: g.hintsRemaining, }; } @@ -530,7 +529,7 @@ export class MockGateway implements GatewayClient { score: valueForLetter(g.view.variant, letter), total: 0, }, - hintsRemaining: g.hintsRemaining + this.profile.hintBalance, + hintsRemaining: g.hintsRemaining, walletBalance: this.profile.hintBalance, }; } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 96ee9f7..d309e42 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -78,17 +78,14 @@ export interface MoveRecord { total: number; } -/** A seated player's private view of a game. hintsRemaining folds the per-game allowance - * together with the global wallet; walletBalance is the wallet alone, so the client can - * derive the per-game allowance (hintsRemaining - walletBalance) and keep the wallet live - * across games (see lib/hints). */ +/** A seated player's private view of a game. hintsRemaining is the per-game allowance alone; the + * purchasable hint wallet lives on the profile (payments), and the client adds it (see lib/hints). */ export interface StateView { game: GameView; seat: number; rack: string[]; bagLen: number; hintsRemaining: number; - walletBalance: number; /** For a vs_ai game, the seconds until the idle hint unlocks (computed by the source: the SERVER * clock online, the device clock offline) — 0/undefined while open (the human's first move, or a * non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt, diff --git a/ui/src/lib/preload.test.ts b/ui/src/lib/preload.test.ts index 9a53d18..6f2ab7e 100644 --- a/ui/src/lib/preload.test.ts +++ b/ui/src/lib/preload.test.ts @@ -35,7 +35,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView { } function stateView(id: string): StateView { - return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1, walletBalance: 0 }; + return { game: gameView(id), seat: 0, rack: ['A', 'B'], bagLen: 50, hintsRemaining: 1 }; } beforeEach(() => {