feat(loyalty): pay every game with EnakCoin

Games now spend the wallet's EnakCoin instead of the per-type tokens
(docs/prd-point-coin.md F8, K1, PC-403).

GamePlayProcessor.PlayGame charges the game's metadata.coin_cost, 1 when
it is not set; a cost that is not a whole number of at least 1 refuses the
game. In one transaction it picks the prize, takes the EnakCoin with a
GAME_SPEND row pointing at the new game_plays.id (which locks the wallet,
so a customer's plays at the same time queue up), records the play and
takes the prize from stock. The play owns its transaction, so the spin
service no longer wraps it, and the admin play endpoint is now atomic too.

The game, game prize and game play repositories go through DBFromContext
so they join that transaction. DecreaseStock now reports a prize that ran
out (ErrGamePrizeOutOfStock) instead of silently updating nothing; that,
or any other stock failure, cancels the whole play, where it used to be
only printed. The manual AddTokens rollback is gone. Not enough EnakCoin,
an inactive game or a prize that ran out answer 400 on /customer/spin
instead of 500.

game_plays.token_used is renamed coins_used (migration 000095). What a
play costs is no longer the caller's choice, so PlayGameRequest loses
token_used. Responses carry coins_used and coins_remaining; token_used and
tokens_remaining stay as deprecated copies until the apps move over, and
sort_by=token_used still sorts by coins_used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 12:20:42 +07:00
co-authored by Claude Opus 5.5
parent 694d65b6d8
commit a18bb072f5
20 changed files with 577 additions and 181 deletions
+155 -89
View File
@@ -1,6 +1,7 @@
package processor
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
@@ -8,33 +9,55 @@ import (
"context"
"errors"
"fmt"
"math"
"math/rand"
"time"
"github.com/google/uuid"
)
// ErrGamePlayRefused wraps every reason a customer cannot play right now: the game,
// its prizes or their EnakCoin. The message says which.
var ErrGamePlayRefused = errors.New("game cannot be played")
const (
gameCoinCostKey = "coin_cost"
defaultGameCoinCost = 1
)
type gameReader interface {
GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error)
}
type gamePrizeStock interface {
GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error)
DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error
}
type GamePlayProcessor struct {
gamePlayRepo repository.GamePlayRepository
gameRepo *repository.GameRepository
gamePrizeRepo *repository.GamePrizeRepository
customerTokensRepo *repository.CustomerTokensRepository
customerPointsRepo repository.CustomerPointsRepository
gamePlayRepo repository.GamePlayRepository
gameRepo gameReader
gamePrizeRepo gamePrizeStock
wallet *WalletProcessor
spendable spendableReader
tx TxRunner
}
func NewGamePlayProcessor(
gamePlayRepo repository.GamePlayRepository,
gameRepo *repository.GameRepository,
gamePrizeRepo *repository.GamePrizeRepository,
customerTokensRepo *repository.CustomerTokensRepository,
customerPointsRepo repository.CustomerPointsRepository,
gameRepo gameReader,
gamePrizeRepo gamePrizeStock,
wallet *WalletProcessor,
spendable spendableReader,
tx TxRunner,
) *GamePlayProcessor {
return &GamePlayProcessor{
gamePlayRepo: gamePlayRepo,
gameRepo: gameRepo,
gamePrizeRepo: gamePrizeRepo,
customerTokensRepo: customerTokensRepo,
customerPointsRepo: customerPointsRepo,
gamePlayRepo: gamePlayRepo,
gameRepo: gameRepo,
gamePrizeRepo: gamePrizeRepo,
wallet: wallet,
spendable: spendable,
tx: tx,
}
}
@@ -76,6 +99,10 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
}
offset := (query.Page - 1) * query.Limit
// The column was renamed; the old name still sorts the same way.
if query.SortBy == "token_used" {
query.SortBy = "coins_used"
}
// Get game plays from repository
gamePlays, total, err := p.gamePlayRepo.List(
@@ -110,101 +137,140 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
}, nil
}
// PlayGame handles the game playing logic
// PlayGame plays a game for a customer, paid with EnakCoin (docs/prd-point-coin.md
// F8, K1). Every kind of game spends the same EnakCoin, at the game's
// metadata.coin_cost. Taking the EnakCoin (GAME_SPEND), recording the play and taking
// the prize from stock happen in one transaction, so a play that cannot be completed,
// for instance because the prize just ran out, takes nothing and records nothing.
func (p *GamePlayProcessor) PlayGame(ctx context.Context, req *models.PlayGameRequest) (*models.PlayGameResponse, error) {
// Verify game exists and is active
refuse := func(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrGamePlayRefused, fmt.Sprintf(format, args...))
}
game, err := p.gameRepo.GetByID(ctx, req.GameID)
if err != nil {
return nil, fmt.Errorf("game not found: %w", err)
}
if !game.IsActive {
return nil, errors.New("game is not active")
return nil, refuse("game is not active")
}
// Convert GameType to TokenType
tokenType := entities.TokenType(game.Type)
// Check if customer has enough tokens
customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType)
cost, err := gameCoinCost(game.Metadata)
if err != nil {
return nil, fmt.Errorf("customer tokens not found: %w", err)
return nil, refuse("%s", err.Error())
}
if customerTokens.Balance < int64(req.TokenUsed) {
return nil, errors.New("insufficient tokens")
}
// Deduct tokens
err = p.customerTokensRepo.DeductTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed))
if err != nil {
return nil, fmt.Errorf("failed to deduct tokens: %w", err)
}
// Get available prizes
availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID)
if err != nil {
return nil, fmt.Errorf("failed to get available prizes: %w", err)
}
if len(availablePrizes) == 0 {
return nil, errors.New("no prizes available")
}
// Convert entities to models for prize selection
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
for i, prize := range availablePrizes {
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
}
// Select prize based on weight
selectedPrize := p.selectPrizeByWeight(prizeResponses)
// Generate random seed for audit
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
// Create game play record
gamePlay := &models.CreateGamePlayRequest{
GameID: req.GameID,
CustomerID: req.CustomerID,
TokenUsed: req.TokenUsed,
RandomSeed: &randomSeed,
}
gamePlayEntity := mappers.ToGamePlayEntity(gamePlay)
if selectedPrize != nil {
gamePlayEntity.PrizeID = &selectedPrize.ID
}
err = p.gamePlayRepo.Create(ctx, gamePlayEntity)
if err != nil {
// Rollback token deduction
p.customerTokensRepo.AddTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed))
return nil, fmt.Errorf("failed to create game play: %w", err)
}
// Decrease prize stock if prize was won
if selectedPrize != nil {
err = p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
var gamePlay *entities.GamePlay
var selectedPrize *models.GamePrizeResponse
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID)
if err != nil {
// Log error but don't fail the transaction
fmt.Printf("Warning: failed to decrease prize stock: %v\n", err)
return fmt.Errorf("failed to get available prizes: %w", err)
}
}
if len(availablePrizes) == 0 {
return refuse("no prizes available")
}
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
for i, prize := range availablePrizes {
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
}
selectedPrize = p.selectPrizeByWeight(prizeResponses)
// Get updated token balance
updatedTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType)
// Random seed kept for audit.
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
gamePlay = &entities.GamePlay{
ID: uuid.New(),
GameID: req.GameID,
CustomerID: req.CustomerID,
CoinsUsed: int(cost),
RandomSeed: &randomSeed,
}
if selectedPrize != nil {
gamePlay.PrizeID = &selectedPrize.ID
}
// The EnakCoin go first: the debit locks the wallet, so plays by the same
// customer at the same time queue up instead of spending the same balance.
_, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
CustomerID: req.CustomerID,
Currency: constants.WalletCurrencyCoin,
Type: constants.WalletTxTypeGameSpend,
Amount: cost,
ReferenceType: constants.WalletRefTypeGamePlay,
ReferenceID: gamePlay.ID,
Description: gameSpendDescription(game.Name, selectedPrize),
Metadata: entities.Metadata{"game_id": game.ID.String(), "coin_cost": cost},
}})
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
return refuse("not enough EnakCoin: this game costs %d", cost)
}
if err != nil {
return err
}
if err := p.gamePlayRepo.Create(ctx, gamePlay); err != nil {
return err
}
if selectedPrize != nil {
err := p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
if errors.Is(err, repository.ErrGamePrizeOutOfStock) {
return refuse("the prize just ran out, please play again")
}
if err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to get updated token balance: %w", err)
return nil, err
}
balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now())
if err != nil {
return nil, err
}
return &models.PlayGameResponse{
GamePlay: *mappers.ToGamePlayResponse(gamePlayEntity),
PrizeWon: selectedPrize,
TokensRemaining: updatedTokens.Balance,
GamePlay: *mappers.ToGamePlayResponse(gamePlay),
PrizeWon: selectedPrize,
CoinsRemaining: balances[constants.WalletCurrencyCoin],
}, nil
}
// gameCoinCost is what one play of a game costs, from games.metadata.coin_cost: a
// whole number of EnakCoin, 1 when it is not set (F8).
func gameCoinCost(metadata entities.Metadata) (int64, error) {
raw, ok := metadata[gameCoinCostKey]
if !ok || raw == nil {
return defaultGameCoinCost, nil
}
var cost int64
switch v := raw.(type) {
case float64:
if v != math.Trunc(v) {
return 0, fmt.Errorf("the game's coin_cost %v is not a whole number", v)
}
cost = int64(v)
case int:
cost = int64(v)
case int64:
cost = v
default:
return 0, fmt.Errorf("the game's coin_cost %v is not a number", raw)
}
if cost < 1 {
return 0, fmt.Errorf("the game's coin_cost must be at least 1, got %d", cost)
}
return cost, nil
}
// gameSpendDescription is what the customer's history shows for a play, frozen when it
// happens (§8.1): "Main Spin Wheel: dapat Voucher 10rb".
func gameSpendDescription(gameName string, prize *models.GamePrizeResponse) string {
description := "Main " + gameName
if prize != nil {
description += ": dapat " + prize.Name
}
return truncateRunes(description, walletDescriptionLimit)
}
// selectPrizeByWeight selects a prize based on weight distribution
func (p *GamePlayProcessor) selectPrizeByWeight(prizes []models.GamePrizeResponse) *models.GamePrizeResponse {
if len(prizes) == 0 {
@@ -0,0 +1,69 @@
package processor
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// Plays against Postgres, so the game repositories really join the transaction: a
// refused play leaves no game_plays row and no GAME_SPEND.
func TestPlayGame_AgainstPostgres(t *testing.T) {
db, _, player, _ := walletMoveDB(t)
gameID, prizeID := uuid.New(), uuid.New()
require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 2}')`, gameID).Error)
require.NoError(t, db.Exec(`INSERT INTO game_prizes (id, game_id, name, weight, stock) VALUES (?, ?, 'Tumbler', 1, 1)`, prizeID, gameID).Error)
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id = ?)`, player)
db.Exec(`DELETE FROM game_plays WHERE game_id = ?`, gameID)
db.Exec(`DELETE FROM game_prizes WHERE game_id = ?`, gameID)
db.Exec(`DELETE FROM games WHERE id = ?`, gameID)
})
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
in := earn(player, 3, nil)
in.Currency = constants.WalletCurrencyCoin
_, err := wallet.Credit(ctx, in)
return err
}))
p := NewGamePlayProcessor(repository.NewGamePlayRepository(db), repository.NewGameRepository(db), repository.NewGamePrizeRepository(db),
wallet, repository.NewWalletQueryRepository(db), txm)
play := func() (*models.PlayGameResponse, error) {
return p.PlayGame(context.Background(), &models.PlayGameRequest{GameID: gameID, CustomerID: player})
}
res, err := play()
require.NoError(t, err)
assert.Equal(t, int64(1), res.CoinsRemaining)
assert.Equal(t, 2, res.GamePlay.CoinsUsed)
var spend struct {
ReferenceID uuid.UUID
Amount int64
}
require.NoError(t, db.Raw(`SELECT reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spend).Error)
assert.Equal(t, res.GamePlay.ID, spend.ReferenceID)
assert.Equal(t, int64(-2), spend.Amount)
// The only prize is gone, then the EnakCoin are too short: neither play leaves a row.
_, err = play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
require.NoError(t, db.Exec(`UPDATE game_prizes SET stock = 5 WHERE id = ?`, prizeID).Error)
_, err = play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
var plays, spends int64
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM game_plays WHERE game_id = ? AND coins_used = 2`, gameID).Scan(&plays).Error)
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spends).Error)
assert.Equal(t, int64(1), plays)
assert.Equal(t, int64(1), spends)
}
@@ -0,0 +1,245 @@
package processor
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// snapshot saves everything the fake wallet holds and returns what puts it back, so
// a test transaction can roll back like a real one.
func (f *walletRepoFake) snapshot() func() {
wallets := make(map[uuid.UUID]*entities.CustomerWallet, len(f.wallets))
for id, w := range f.wallets {
c := *w
wallets[id] = &c
}
transactions := make([]*entities.WalletTransaction, len(f.transactions))
for i, t := range f.transactions {
c := *t
transactions[i] = &c
}
lots := make([]*entities.WalletLot, len(f.lots))
for i, l := range f.lots {
c := *l
lots[i] = &c
}
allocations := append([]entities.WalletLotAllocation(nil), f.allocations...)
return func() {
f.wallets, f.transactions, f.lots, f.allocations = wallets, transactions, lots, allocations
}
}
// rollbackTx runs fn in place and, when it fails, puts back what each save saved.
type rollbackTx struct{ saves []func() func() }
func (r rollbackTx) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
restores := make([]func(), 0, len(r.saves))
for _, save := range r.saves {
restores = append(restores, save())
}
err := fn(ctx)
if err != nil {
for _, restore := range restores {
restore()
}
}
return err
}
type gameFake struct{ games map[uuid.UUID]*entities.Game }
func (f gameFake) GetByID(_ context.Context, id uuid.UUID) (*entities.Game, error) {
g, ok := f.games[id]
if !ok {
return nil, errors.New("record not found")
}
return g, nil
}
type prizeStockFake struct {
prizes []entities.GamePrize
stockErr error
}
func (f *prizeStockFake) GetAvailablePrizes(_ context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
var out []entities.GamePrize
for _, p := range f.prizes {
if p.GameID == gameID && p.Stock > 0 {
out = append(out, p)
}
}
return out, nil
}
func (f *prizeStockFake) DecreaseStock(_ context.Context, id uuid.UUID, amount int) error {
if f.stockErr != nil {
return f.stockErr
}
for i := range f.prizes {
if f.prizes[i].ID == id {
if f.prizes[i].Stock < amount {
return repository.ErrGamePrizeOutOfStock
}
f.prizes[i].Stock -= amount
return nil
}
}
return repository.ErrGamePrizeOutOfStock
}
func (f *prizeStockFake) snapshot() func() {
prizes := append([]entities.GamePrize(nil), f.prizes...)
return func() { f.prizes = prizes }
}
// gamePlayRepoFake records plays; the other methods of the interface are not used.
type gamePlayRepoFake struct {
repository.GamePlayRepository
plays []entities.GamePlay
}
func (f *gamePlayRepoFake) Create(_ context.Context, gp *entities.GamePlay) error {
f.plays = append(f.plays, *gp)
return nil
}
func (f *gamePlayRepoFake) snapshot() func() {
plays := append([]entities.GamePlay(nil), f.plays...)
return func() { f.plays = plays }
}
type gameTestEnv struct {
*walletMoveEnv
game *entities.Game
prize entities.GamePrize
stock *prizeStockFake
plays *gamePlayRepoFake
player uuid.UUID
p *GamePlayProcessor
}
func newGameTestEnv(t *testing.T, metadata entities.Metadata) *gameTestEnv {
e := &gameTestEnv{walletMoveEnv: newWalletMoveEnv(t)}
e.game = &entities.Game{ID: uuid.New(), Name: "Spin Wheel", Type: entities.GameTypeSpin, IsActive: true, Metadata: metadata}
e.prize = entities.GamePrize{ID: uuid.New(), GameID: e.game.ID, Name: "Voucher 10rb", Weight: 1, Stock: 5}
e.stock = &prizeStockFake{prizes: []entities.GamePrize{e.prize}}
e.plays = &gamePlayRepoFake{}
e.player = e.member("Budi", "081234561234")
tx := rollbackTx{saves: []func() func(){e.repo.snapshot, e.stock.snapshot, e.plays.snapshot}}
e.p = NewGamePlayProcessor(e.plays, gameFake{games: map[uuid.UUID]*entities.Game{e.game.ID: e.game}}, e.stock, e.walletTestEnv.p, e, tx)
return e
}
func (e *gameTestEnv) play() (*models.PlayGameResponse, error) {
return e.p.PlayGame(e.ctx, &models.PlayGameRequest{GameID: e.game.ID, CustomerID: e.player})
}
func TestPlayGame_SpendsTheGamesCoinCost(t *testing.T) {
// coin_cost comes back from JSONB as a float64.
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
e.earnCoins(t, e.player, 10, nil)
res, err := e.play()
require.NoError(t, err)
assert.Equal(t, int64(7), res.CoinsRemaining)
require.NotNil(t, res.PrizeWon)
assert.Equal(t, e.prize.ID, res.PrizeWon.ID)
require.Len(t, e.plays.plays, 1)
play := e.plays.plays[0]
assert.Equal(t, 3, play.CoinsUsed)
assert.Equal(t, e.prize.ID, *play.PrizeID)
assert.Equal(t, 4, e.stock.prizes[0].Stock)
spend := e.repo.transactions[len(e.repo.transactions)-1]
assert.Equal(t, constants.WalletTxTypeGameSpend, spend.Type)
assert.Equal(t, constants.WalletCurrencyCoin, spend.Currency)
assert.Equal(t, int64(-3), spend.Amount)
assert.Equal(t, constants.WalletRefTypeGamePlay, spend.ReferenceType)
assert.Equal(t, play.ID, spend.ReferenceID, "GAME_SPEND points at the game play")
assert.Equal(t, "Main Spin Wheel: dapat Voucher 10rb", spend.Description)
}
func TestPlayGame_CostsOneCoinByDefault(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 2, nil)
res, err := e.play()
require.NoError(t, err)
assert.Equal(t, int64(1), res.CoinsRemaining)
assert.Equal(t, 1, e.plays.plays[0].CoinsUsed)
}
func TestPlayGame_NotEnoughCoinsRecordsNothing(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
e.earnCoins(t, e.player, 2, nil)
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
assert.Empty(t, e.plays.plays)
assert.Equal(t, 5, e.stock.prizes[0].Stock)
assert.Equal(t, int64(2), e.coinBalance(t, e.player))
}
func TestPlayGame_PrizeStockFailureCancelsThePlay(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 5, nil)
rows := len(e.repo.transactions)
// Another play took the last one between reading the prizes and taking it.
e.stock.stockErr = repository.ErrGamePrizeOutOfStock
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
// Anything else going wrong with the stock cancels it too.
e.stock.stockErr = errors.New("connection reset")
_, err = e.play()
assert.Error(t, err)
assert.Empty(t, e.plays.plays)
assert.Len(t, e.repo.transactions, rows, "no GAME_SPEND is left behind")
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
}
func TestPlayGame_RefusesWhatCannotBePlayed(t *testing.T) {
for name, metadata := range map[string]entities.Metadata{
"zero": {"coin_cost": float64(0)},
"fraction": {"coin_cost": 1.5},
"not number": {"coin_cost": "2"},
} {
t.Run(name, func(t *testing.T) {
e := newGameTestEnv(t, metadata)
e.earnCoins(t, e.player, 5, nil)
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
})
}
t.Run("inactive", func(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 5, nil)
e.game.IsActive = false
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
})
t.Run("no prizes left", func(t *testing.T) {
e := newGameTestEnv(t, entities.Metadata{})
e.earnCoins(t, e.player, 5, nil)
e.stock.prizes[0].Stock = 0
_, err := e.play()
assert.ErrorIs(t, err, ErrGamePlayRefused)
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
})
}