feat(admin): admin grant — raw benefits and by-product reward bundles #232
+5
-1
@@ -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 /
|
(`/_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
|
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,
|
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.
|
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`
|
||||||
|
|||||||
@@ -85,6 +85,26 @@
|
|||||||
{{else}}<p class="note">no ledger entries</p>{{end}}
|
{{else}}<p class="note">no ledger entries</p>{{end}}
|
||||||
{{else}}<p class="note">payments not enabled</p>{{end}}
|
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel"><h2>Grant benefits</h2>
|
||||||
|
{{if .Grant.Present}}
|
||||||
|
<p class="note">A zero-price admin sale of a value — <strong>never chips</strong>. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.</p>
|
||||||
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant">
|
||||||
|
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||||
|
<label>Hints <input type="number" name="hints" min="0" value="0"></label>
|
||||||
|
<label>No-ads days <input type="number" name="noads" min="0" value="0"></label>
|
||||||
|
<label><input type="checkbox" name="forever" value="true"> No-ads forever</label>
|
||||||
|
<div><button type="submit">Grant</button></div>
|
||||||
|
</form>
|
||||||
|
{{if .Grant.Products}}
|
||||||
|
<h3>Grant a product</h3>
|
||||||
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant-product">
|
||||||
|
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
||||||
|
<label>Product <select name="product_id">{{range .Grant.Products}}<option value="{{.ID}}">{{.Title}} ({{.Summary}}){{if .Archived}} — archived{{end}}</option>{{end}}</select></label>
|
||||||
|
<div><button type="submit">Grant product</button></div>
|
||||||
|
</form>
|
||||||
|
{{else}}<p class="note">no grantable products — create a value product in the <a href="/_gm/catalog">catalog</a></p>{{end}}
|
||||||
|
{{else}}<p class="note">payments not enabled</p>{{end}}
|
||||||
|
</section>
|
||||||
<section class="panel"><h2>Roles</h2>
|
<section class="panel"><h2>Roles</h2>
|
||||||
{{$id := .ID}}
|
{{$id := .ID}}
|
||||||
{{if .Roles}}
|
{{if .Roles}}
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ type UserDetailView struct {
|
|||||||
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
||||||
// is false when the payments domain is unwired.
|
// is false when the payments domain is unwired.
|
||||||
Finance FinanceView
|
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
|
// FinanceView is the account's payments picture on the user card: chip balances per funding
|
||||||
@@ -701,3 +704,21 @@ type ProductFormView struct {
|
|||||||
PriceChip int64
|
PriceChip int64
|
||||||
Transacted bool
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
|
|||||||
Rack: rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
WalletBalance: v.WalletBalance,
|
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||||
|
|||||||
@@ -55,16 +55,16 @@ func TestPayloadExchangeRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHintsRemaining(t *testing.T) {
|
func TestHintsRemaining(t *testing.T) {
|
||||||
cases := []struct{ allowance, used, wallet, want int }{
|
cases := []struct{ allowance, used, want int }{
|
||||||
{1, 0, 3, 4},
|
{1, 0, 1},
|
||||||
{1, 1, 3, 3},
|
{1, 1, 0},
|
||||||
{1, 2, 3, 3}, // used past allowance clamps to 0
|
{1, 2, 0}, // used past allowance clamps to 0
|
||||||
{0, 0, 5, 5},
|
{3, 1, 2},
|
||||||
{2, 1, 0, 1},
|
{0, 0, 0},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := hintsRemaining(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) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
|
t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1207,7 +1207,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
|||||||
return HintResult{}, err
|
return HintResult{}, err
|
||||||
}
|
}
|
||||||
used++
|
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
|
// 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,
|
Seat: seat,
|
||||||
Rack: g.Hand(seat),
|
Rack: g.Hand(seat),
|
||||||
BagLen: g.BagLen(),
|
BagLen: g.BagLen(),
|
||||||
// The hint wallet moved to payments (svc.hintWallet); the deprecated accounts.hint_balance
|
// HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile
|
||||||
// is no longer read, so the wire wallet is 0 and HintsRemaining is the per-seat allowance.
|
// (payments) and the client adds it (lib/hints.hintsLeft).
|
||||||
// The client adds the profile's payments hint balance on top (lib/hints.hintsLeft).
|
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed),
|
||||||
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
|
||||||
@@ -1775,10 +1773,10 @@ func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, e
|
|||||||
return svc.registry.DictBytes(variant, version)
|
return svc.registry.DictBytes(variant, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hintsRemaining is a player's remaining hint budget: the unspent per-game
|
// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate,
|
||||||
// allowance plus the profile wallet.
|
// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft).
|
||||||
func hintsRemaining(allowance, used, wallet int) int {
|
func hintsRemaining(allowance, used int) int {
|
||||||
return max(0, allowance-used) + wallet
|
return max(0, allowance-used)
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedTimeout reports whether d is one of the offered move clocks.
|
// allowedTimeout reports whether d is one of the offered move clocks.
|
||||||
|
|||||||
@@ -211,10 +211,9 @@ type MoveResult struct {
|
|||||||
BagLen int
|
BagLen int
|
||||||
}
|
}
|
||||||
|
|
||||||
// HintResult is a revealed hint and the requesting player's remaining hint
|
// HintResult is a revealed hint with the per-seat allowance remaining (HintsRemaining) and the
|
||||||
// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
|
// purchasable hint wallet after spending one (WalletBalance, from payments). The client adopts
|
||||||
// the global wallet alone, so the client can keep its live wallet authoritative and
|
// WalletBalance into the profile so the badge stays live across games (lib/hints).
|
||||||
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
|
|
||||||
type HintResult struct {
|
type HintResult struct {
|
||||||
Move engine.MoveRecord
|
Move engine.MoveRecord
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
@@ -241,9 +240,6 @@ type StateView struct {
|
|||||||
Rack []string
|
Rack []string
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining 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
|
// 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);
|
// 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
|
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -424,13 +424,13 @@ func TestHintPolicy(t *testing.T) {
|
|||||||
t.Fatalf("first hint: %v", err)
|
t.Fatalf("first hint: %v", err)
|
||||||
}
|
}
|
||||||
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
|
// 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])
|
st, err := svc.GameState(ctx, g.ID, seats[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("state: %v", err)
|
t.Fatalf("state: %v", err)
|
||||||
}
|
}
|
||||||
if st.HintsRemaining != 0 || st.WalletBalance != 0 {
|
if st.HintsRemaining != 0 {
|
||||||
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
|
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) {
|
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
|
||||||
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
|
||||||
@@ -444,9 +444,10 @@ func TestHintPolicy(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("wallet hint: %v", err)
|
t.Fatalf("wallet hint: %v", err)
|
||||||
}
|
}
|
||||||
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
|
// The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped
|
||||||
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
|
// 2->1 and WalletBalance carries it (the client adopts it into the profile).
|
||||||
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
|
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
|
// 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.
|
// that feeds the player's lifetime hint statistics, not just the allowance.
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
|
|||||||
Rack: s.Rack,
|
Rack: s.Rack,
|
||||||
BagLen: s.BagLen,
|
BagLen: s.BagLen,
|
||||||
HintsRemaining: s.HintsRemaining,
|
HintsRemaining: s.HintsRemaining,
|
||||||
WalletBalance: s.WalletBalance,
|
|
||||||
Alphabet: alphabet,
|
Alphabet: alphabet,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ type PlayerState struct {
|
|||||||
Rack []int
|
Rack []int
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
WalletBalance int
|
|
||||||
Alphabet []AlphabetLetter
|
Alphabet []AlphabetLetter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
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
|
// 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
|
// caller's transaction (the account-merge flow). The caller invalidates the affected caches
|
||||||
// after committing (Invalidate).
|
// after committing (Invalidate).
|
||||||
@@ -244,3 +279,20 @@ func marshalGrant(d benefitDelta) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return b, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ var (
|
|||||||
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
|
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
|
||||||
// cannot be bought with chips.
|
// cannot be bought with chips.
|
||||||
ErrNotAValue = errors.New("payments: product is not a chip-priced value")
|
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.
|
// 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
|
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)
|
// 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.
|
// 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) {
|
func (s *Store) consumeHint(ctx context.Context, accountID uuid.UUID, origins []Source, now time.Time) (bool, error) {
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ type stateDTO struct {
|
|||||||
Rack []int `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
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
|
// 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.
|
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
||||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||||
@@ -334,7 +333,6 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
|||||||
Rack: rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
WalletBalance: v.WalletBalance,
|
|
||||||
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"scrabble/backend/internal/adminconsole"
|
"scrabble/backend/internal/adminconsole"
|
||||||
"scrabble/backend/internal/payments"
|
"scrabble/backend/internal/payments"
|
||||||
@@ -181,3 +184,95 @@ func (s *Server) consoleDeleteProductAction(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack)
|
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, ", ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||||
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
|
||||||
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
|
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.POST("/users/:id/delete", s.consoleDeleteUser)
|
||||||
gm.GET("/reasons", s.consoleReasons)
|
gm.GET("/reasons", s.consoleReasons)
|
||||||
gm.POST("/reasons", s.consoleCreateReason)
|
gm.POST("/reasons", s.consoleCreateReason)
|
||||||
@@ -451,6 +453,7 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
} else {
|
} else {
|
||||||
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
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)
|
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -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.
|
here, so unlink/re-link takes effect immediately.
|
||||||
|
|
||||||
**Admin rewards.** An admin grants **concrete values only** (no-ads / hints) — **never
|
**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** 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
|
`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,
|
**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
|
spends, grants, refunds, full history — as an extension of the existing user card
|
||||||
|
|||||||
+5
-2
@@ -318,10 +318,13 @@ in-process кэш сегментов и бенефитов по ключу-ак
|
|||||||
вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.
|
вызывающий, здесь не кэшируется, поэтому отвязка/повторная привязка действует сразу.
|
||||||
|
|
||||||
**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы /
|
**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы /
|
||||||
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ
|
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Выдаёт либо
|
||||||
|
сырыми атомами, либо **готовым продуктом-ценностью** (набор-награда, возможно архивный — скрыт
|
||||||
|
из магазина, но выдаётся); оба отказывают на атоме `chips` или `tournament`. Админ
|
||||||
**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk`
|
**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk`
|
||||||
точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала
|
точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала
|
||||||
типа `admin_grant`, цена 0 Фишек — полный аудит наград.
|
типа `admin_grant`, цена 0 Фишек (грант по продукту пишет исходный `product_id` + снапшот) —
|
||||||
|
полный аудит наград.
|
||||||
|
|
||||||
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
||||||
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
||||||
|
|||||||
@@ -197,7 +197,6 @@ type StateResp struct {
|
|||||||
Rack []int `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
|
||||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -405,7 +405,6 @@ func toWireState(s backendclient.StateResp) wire.StateView {
|
|||||||
Rack: s.Rack,
|
Rack: s.Rack,
|
||||||
BagLen: s.BagLen,
|
BagLen: s.BagLen,
|
||||||
HintsRemaining: s.HintsRemaining,
|
HintsRemaining: s.HintsRemaining,
|
||||||
WalletBalance: s.WalletBalance,
|
|
||||||
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
|
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
|
||||||
Alphabet: alphabet,
|
Alphabet: alphabet,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
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()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
t.Fatalf("handler: %v", err)
|
t.Fatalf("handler: %v", err)
|
||||||
}
|
}
|
||||||
st := fb.GetRootAsStateView(payload, 0)
|
st := fb.GetRootAsStateView(payload, 0)
|
||||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 {
|
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || 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())
|
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)
|
game := st.Game(nil)
|
||||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
||||||
|
|||||||
+8
-10
@@ -330,12 +330,11 @@ table StateView {
|
|||||||
bag_len:int;
|
bag_len:int;
|
||||||
hints_remaining:int;
|
hints_remaining:int;
|
||||||
alphabet:[AlphabetEntry];
|
alphabet:[AlphabetEntry];
|
||||||
// wallet_balance is the requesting player's global hint-wallet balance, sent apart from
|
// wallet_balance is deprecated (D31): the purchasable hint wallet moved to the profile (payments),
|
||||||
// hints_remaining (which folds the wallet in with the per-game allowance) so the client can
|
// and hints_remaining now carries the per-game allowance alone. The field is tombstoned rather than
|
||||||
// separate the two: the per-game allowance remaining is hints_remaining - wallet_balance, and
|
// deleted so the vtable slots of the fields after it stay stable across a rolling deploy (an old
|
||||||
// the wallet is a single global figure the client keeps live across games (added trailing —
|
// SPA served before the deploy must keep reading a new gateway correctly). No accessor is generated.
|
||||||
// backward-compatible).
|
wallet_balance:int (deprecated);
|
||||||
wallet_balance:int;
|
|
||||||
// hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks
|
// 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
|
// (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
|
// 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;
|
note:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HintResult is the top-ranked move plus the remaining hint budget. wallet_balance is the
|
// HintResult is the top-ranked move plus hints_remaining (the per-game allowance alone) and
|
||||||
// global hint-wallet balance after spending (see StateView.wallet_balance), so the client
|
// wallet_balance (the purchasable hint wallet after spending, from payments). The client adopts
|
||||||
// refreshes its live wallet and re-derives the per-game allowance (added trailing —
|
// wallet_balance into the profile so the badge stays live across games (see lib/hints).
|
||||||
// backward-compatible).
|
|
||||||
table HintResult {
|
table HintResult {
|
||||||
move:MoveRecord;
|
move:MoveRecord;
|
||||||
hints_remaining:int;
|
hints_remaining:int;
|
||||||
|
|||||||
@@ -144,18 +144,6 @@ func (rcv *StateView) AlphabetLength() int {
|
|||||||
return 0
|
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 {
|
func (rcv *StateView) HintUnlockLeftSeconds() int32 {
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||||
if o != 0 {
|
if o != 0 {
|
||||||
@@ -195,9 +183,6 @@ func StateViewAddAlphabet(builder *flatbuffers.Builder, alphabet flatbuffers.UOf
|
|||||||
func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
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) {
|
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
|
||||||
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
|
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ type StateView struct {
|
|||||||
Rack []int
|
Rack []int
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
WalletBalance int
|
|
||||||
HintUnlockLeftSeconds int
|
HintUnlockLeftSeconds int
|
||||||
Alphabet []AlphabetEntry
|
Alphabet []AlphabetEntry
|
||||||
}
|
}
|
||||||
@@ -256,7 +255,6 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
|
|||||||
fb.StateViewAddRack(b, rack)
|
fb.StateViewAddRack(b, rack)
|
||||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||||
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
|
||||||
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
|
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
|
||||||
if hasAlphabet {
|
if hasAlphabet {
|
||||||
fb.StateViewAddAlphabet(b, alphabet)
|
fb.StateViewAddAlphabet(b, alphabet)
|
||||||
|
|||||||
@@ -251,7 +251,8 @@
|
|||||||
view = st;
|
view = st;
|
||||||
// Anchor the vs_ai idle-hint countdown to the freshly fetched seconds-left (0 = open / non-vs_ai).
|
// 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);
|
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).
|
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
||||||
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
|
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
|
||||||
moves = hist.moves;
|
moves = hist.moves;
|
||||||
@@ -833,10 +834,9 @@
|
|||||||
seat: r.move.player,
|
seat: r.move.player,
|
||||||
rack: r.rack,
|
rack: r.rack,
|
||||||
bagLen: r.bagLen,
|
bagLen: r.bagLen,
|
||||||
// A move is not a hint, so the per-game allowance and the wallet are unchanged: carry both
|
// A move is not a hint, so the per-game allowance is unchanged: carry it forward. The badge
|
||||||
// forward (their difference is the stable allowance; the badge adds the live wallet).
|
// adds the live wallet from the profile.
|
||||||
hintsRemaining: view?.hintsRemaining ?? 0,
|
hintsRemaining: view?.hintsRemaining ?? 0,
|
||||||
walletBalance: view?.walletBalance ?? 0,
|
|
||||||
};
|
};
|
||||||
// The move result is an authoritative per-viewer view: a nudge the actor just answered by
|
// 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.
|
// moving is already cleared server-side, so reconcile the unread flag from it.
|
||||||
@@ -1047,7 +1047,7 @@
|
|||||||
recenter++;
|
recenter++;
|
||||||
}
|
}
|
||||||
if (isCoarse() && !landscape) zoomed = true;
|
if (isCoarse() && !landscape) zoomed = true;
|
||||||
view = { ...view, hintsRemaining: h.hintsRemaining, walletBalance: h.walletBalance };
|
view = { ...view, hintsRemaining: h.hintsRemaining };
|
||||||
syncWallet(h.walletBalance);
|
syncWallet(h.walletBalance);
|
||||||
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
|
// 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
|
// preview instead of a redundant evaluate (same engine call, same placement). Cancel any
|
||||||
|
|||||||
@@ -69,11 +69,6 @@ alphabetLength():number {
|
|||||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
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 {
|
hintUnlockLeftSeconds():number {
|
||||||
const offset = this.bb!.__offset(this.bb_pos, 18);
|
const offset = this.bb!.__offset(this.bb_pos, 18);
|
||||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
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);
|
builder.startVector(4, numElems, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
|
|
||||||
builder.addFieldInt32(6, walletBalance, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
static addHintUnlockLeftSeconds(builder:flatbuffers.Builder, hintUnlockLeftSeconds:number) {
|
static addHintUnlockLeftSeconds(builder:flatbuffers.Builder, hintUnlockLeftSeconds:number) {
|
||||||
builder.addFieldInt32(7, hintUnlockLeftSeconds, 0);
|
builder.addFieldInt32(7, hintUnlockLeftSeconds, 0);
|
||||||
}
|
}
|
||||||
@@ -144,7 +135,7 @@ static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
|
|||||||
return 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.startStateView(builder);
|
||||||
StateView.addGame(builder, gameOffset);
|
StateView.addGame(builder, gameOffset);
|
||||||
StateView.addSeat(builder, seat);
|
StateView.addSeat(builder, seat);
|
||||||
@@ -152,7 +143,6 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse
|
|||||||
StateView.addBagLen(builder, bagLen);
|
StateView.addBagLen(builder, bagLen);
|
||||||
StateView.addHintsRemaining(builder, hintsRemaining);
|
StateView.addHintsRemaining(builder, hintsRemaining);
|
||||||
StateView.addAlphabet(builder, alphabetOffset);
|
StateView.addAlphabet(builder, alphabetOffset);
|
||||||
StateView.addWalletBalance(builder, walletBalance);
|
|
||||||
StateView.addHintUnlockLeftSeconds(builder, hintUnlockLeftSeconds);
|
StateView.addHintUnlockLeftSeconds(builder, hintUnlockLeftSeconds);
|
||||||
return StateView.endStateView(builder);
|
return StateView.endStateView(builder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,7 +575,6 @@ function decodeStateViewTable(v: fb.StateView): StateView {
|
|||||||
rack,
|
rack,
|
||||||
bagLen: v.bagLen(),
|
bagLen: v.bagLen(),
|
||||||
hintsRemaining: v.hintsRemaining(),
|
hintsRemaining: v.hintsRemaining(),
|
||||||
walletBalance: v.walletBalance(),
|
|
||||||
hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(),
|
hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function gameView(id: string): GameView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function view(id: string, rack: string[] = ['A', 'B']): StateView {
|
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 {
|
function move(player: number): MoveRecord {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function move(player: number): MoveRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cache(moveCount: number, seat = 0, over = false): CachedGame {
|
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: [] };
|
return { view, moves: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ function delta(moveCount: number, player: number, bagLen = 47): MoveDelta {
|
|||||||
|
|
||||||
describe('seedInitialState', () => {
|
describe('seedInitialState', () => {
|
||||||
it('wraps an initial view with an empty journal', () => {
|
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: [] });
|
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: 0, accountId: 'me', displayName: 'Me', score: 0, hintsUsed: 0, isWinner: false },
|
||||||
{ seat: 1, accountId: 'opp', displayName: 'Opp', 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", () => {
|
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.
|
// 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());
|
const res = applyOpponentJoined(cached, joinedState());
|
||||||
expect(res?.view.game.status).toBe('active');
|
expect(res?.view.game.status).toBe('active');
|
||||||
expect(res?.view.game.seats).toHaveLength(2);
|
expect(res?.view.game.seats).toHaveLength(2);
|
||||||
|
|||||||
+12
-14
@@ -1,33 +1,31 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from './hints';
|
import { hintsLeft, hintGateRemainingMs, hintLockMinutes } from './hints';
|
||||||
|
|
||||||
// view carries only the two fields hintsLeft reads.
|
// view carries only the field hintsLeft reads (the per-game allowance).
|
||||||
const view = (hintsRemaining: number, walletBalance: number) => ({ hintsRemaining, walletBalance });
|
const view = (hintsRemaining: number) => ({ hintsRemaining });
|
||||||
|
|
||||||
describe('hintsLeft', () => {
|
describe('hintsLeft', () => {
|
||||||
it('is zero without a view', () => {
|
it('is zero without a view', () => {
|
||||||
expect(hintsLeft(null, 5)).toBe(0);
|
expect(hintsLeft(null, 5)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds the per-game allowance to the live wallet (fresh view)', () => {
|
it('adds the per-game allowance to the live wallet', () => {
|
||||||
// hints_remaining 4 = allowance 1 + wallet 3; live wallet matches the snapshot → 1 + 3.
|
expect(hintsLeft(view(1), 3)).toBe(4); // allowance 1 + wallet 3
|
||||||
expect(hintsLeft(view(4, 3), 3)).toBe(4);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reflects the LIVE wallet, not the per-game snapshot (the staleness fix)', () => {
|
it('reflects the LIVE wallet, not the view (the staleness fix)', () => {
|
||||||
// The view was fetched when the wallet was 3 (allowance 1), but a wallet hint was since spent
|
// A wallet hint spent in another game since this view was fetched → the live wallet is 2, so the
|
||||||
// in another game, so the live wallet is 2: the count must drop to 1 + 2 = 3, not stay at 4.
|
// count is 1 + 2 = 3. The wallet is always passed live (from the profile), never read off the view.
|
||||||
expect(hintsLeft(view(4, 3), 2)).toBe(3);
|
expect(hintsLeft(view(1), 2)).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows just the wallet when the per-game allowance is used up', () => {
|
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(0), 3)).toBe(3); // allowance 0 + wallet 3
|
||||||
expect(hintsLeft(view(3, 3), 3)).toBe(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps a non-negative allowance and wallet', () => {
|
it('clamps negatives', () => {
|
||||||
expect(hintsLeft(view(2, 3), 0)).toBe(0);
|
expect(hintsLeft(view(1), -5)).toBe(1); // a negative wallet clamps to 0
|
||||||
expect(hintsLeft(view(1, 0), -5)).toBe(1);
|
expect(hintsLeft(view(-2), 3)).toBe(3); // a negative allowance clamps to 0
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+9
-14
@@ -1,28 +1,23 @@
|
|||||||
// Hint-count derivation, kept out of the .svelte component so it is unit-testable.
|
// 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
|
// The badge shows the per-game hint allowance remaining plus the player's global hint wallet. The
|
||||||
// wallet. The server's hints_remaining folds the two together, but the wallet is global —
|
// view's hints_remaining is the per-game allowance alone; the wallet is global (shared across every
|
||||||
// shared across every game — so caching the combined number per game makes it go stale the
|
// game) and read live from the profile (payments), never the per-game snapshot — so a wallet hint
|
||||||
// moment a wallet hint is spent in another game. We therefore split it: the per-game
|
// spent in another game stays reflected here.
|
||||||
// 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.
|
|
||||||
|
|
||||||
import type { StateView } from './model';
|
import type { StateView } from './model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* hintsLeft is the hint count for the badge: the per-game allowance remaining (the view's
|
* hintsLeft is the hint count for the badge: the per-game allowance remaining (view.hintsRemaining)
|
||||||
* hints_remaining minus the wallet snapshot baked into that same view) plus the live global
|
* plus the live global wallet balance (passed in from the profile, so it stays correct when a wallet
|
||||||
* wallet balance. Passing the live wallet (not view.walletBalance) is what keeps the count
|
* hint was spent in another game since this view was fetched).
|
||||||
* correct when a wallet hint was spent in another game since this view was fetched.
|
|
||||||
*/
|
*/
|
||||||
export function hintsLeft(
|
export function hintsLeft(
|
||||||
view: Pick<StateView, 'hintsRemaining' | 'walletBalance'> | null,
|
view: Pick<StateView, 'hintsRemaining'> | null,
|
||||||
walletBalance: number,
|
walletBalance: number,
|
||||||
): number {
|
): number {
|
||||||
if (!view) return 0;
|
if (!view) return 0;
|
||||||
const allowance = Math.max(0, view.hintsRemaining - view.walletBalance);
|
return Math.max(0, view.hintsRemaining) + Math.max(0, walletBalance);
|
||||||
return allowance + 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. */
|
/** HINT_GATE_MS is the idle time a vs_ai player must be stuck on a turn before a hint unlocks. */
|
||||||
|
|||||||
@@ -435,7 +435,6 @@ export class LocalSource implements GameLoopSource {
|
|||||||
rack,
|
rack,
|
||||||
bagLen: entry.game.bagLength,
|
bagLen: entry.game.bagLength,
|
||||||
hintsRemaining: 1,
|
hintsRemaining: 1,
|
||||||
walletBalance: 0,
|
|
||||||
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
|
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
|
||||||
locked,
|
locked,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -408,10 +408,9 @@ export class MockGateway implements GatewayClient {
|
|||||||
seat: this.mySeat(g),
|
seat: this.mySeat(g),
|
||||||
rack: [...g.rack],
|
rack: [...g.rack],
|
||||||
bagLen: g.bagLen,
|
bagLen: g.bagLen,
|
||||||
// g.hintsRemaining is the per-game allowance; the wallet is the shared profile balance.
|
// hintsRemaining is the per-game allowance alone; the wallet lives on the profile and the
|
||||||
// hints_remaining folds the two together (as the backend does), walletBalance is the wallet.
|
// client adds it (lib/hints).
|
||||||
hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
|
hintsRemaining: g.hintsRemaining,
|
||||||
walletBalance: this.profile.hintBalance,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +529,7 @@ export class MockGateway implements GatewayClient {
|
|||||||
score: valueForLetter(g.view.variant, letter),
|
score: valueForLetter(g.view.variant, letter),
|
||||||
total: 0,
|
total: 0,
|
||||||
},
|
},
|
||||||
hintsRemaining: g.hintsRemaining + this.profile.hintBalance,
|
hintsRemaining: g.hintsRemaining,
|
||||||
walletBalance: this.profile.hintBalance,
|
walletBalance: this.profile.hintBalance,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-5
@@ -78,17 +78,14 @@ export interface MoveRecord {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A seated player's private view of a game. hintsRemaining folds the per-game allowance
|
/** A seated player's private view of a game. hintsRemaining is the per-game allowance alone; the
|
||||||
* together with the global wallet; walletBalance is the wallet alone, so the client can
|
* purchasable hint wallet lives on the profile (payments), and the client adds it (see lib/hints). */
|
||||||
* derive the per-game allowance (hintsRemaining - walletBalance) and keep the wallet live
|
|
||||||
* across games (see lib/hints). */
|
|
||||||
export interface StateView {
|
export interface StateView {
|
||||||
game: GameView;
|
game: GameView;
|
||||||
seat: number;
|
seat: number;
|
||||||
rack: string[];
|
rack: string[];
|
||||||
bagLen: number;
|
bagLen: number;
|
||||||
hintsRemaining: number;
|
hintsRemaining: number;
|
||||||
walletBalance: number;
|
|
||||||
/** For a vs_ai game, the seconds until the idle hint unlocks (computed by the source: the SERVER
|
/** 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
|
* 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,
|
* non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function gameView(id: string, status: GameView['status'] = 'active'): GameView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stateView(id: string): StateView {
|
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(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user