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>
306 lines
8.7 KiB
Go
306 lines
8.7 KiB
Go
package processor
|
|
|
|
import (
|
|
"apskel-pos-be/internal/constants"
|
|
"apskel-pos-be/internal/entities"
|
|
"apskel-pos-be/internal/mappers"
|
|
"apskel-pos-be/internal/models"
|
|
"apskel-pos-be/internal/repository"
|
|
"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 gameReader
|
|
gamePrizeRepo gamePrizeStock
|
|
wallet *WalletProcessor
|
|
spendable spendableReader
|
|
tx TxRunner
|
|
}
|
|
|
|
func NewGamePlayProcessor(
|
|
gamePlayRepo repository.GamePlayRepository,
|
|
gameRepo gameReader,
|
|
gamePrizeRepo gamePrizeStock,
|
|
wallet *WalletProcessor,
|
|
spendable spendableReader,
|
|
tx TxRunner,
|
|
) *GamePlayProcessor {
|
|
return &GamePlayProcessor{
|
|
gamePlayRepo: gamePlayRepo,
|
|
gameRepo: gameRepo,
|
|
gamePrizeRepo: gamePrizeRepo,
|
|
wallet: wallet,
|
|
spendable: spendable,
|
|
tx: tx,
|
|
}
|
|
}
|
|
|
|
// CreateGamePlay creates a new game play record
|
|
func (p *GamePlayProcessor) CreateGamePlay(ctx context.Context, req *models.CreateGamePlayRequest) (*models.GamePlayResponse, error) {
|
|
// Convert request to entity
|
|
gamePlay := mappers.ToGamePlayEntity(req)
|
|
|
|
// Create game play
|
|
err := p.gamePlayRepo.Create(ctx, gamePlay)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create game play: %w", err)
|
|
}
|
|
|
|
return mappers.ToGamePlayResponse(gamePlay), nil
|
|
}
|
|
|
|
// GetGamePlay retrieves a game play by ID
|
|
func (p *GamePlayProcessor) GetGamePlay(ctx context.Context, id uuid.UUID) (*models.GamePlayResponse, error) {
|
|
gamePlay, err := p.gamePlayRepo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("game play not found: %w", err)
|
|
}
|
|
|
|
return mappers.ToGamePlayResponse(gamePlay), nil
|
|
}
|
|
|
|
// ListGamePlays retrieves game plays with pagination and filtering
|
|
func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.ListGamePlaysQuery) (*models.PaginatedResponse[models.GamePlayResponse], error) {
|
|
// Set default values
|
|
if query.Page <= 0 {
|
|
query.Page = 1
|
|
}
|
|
if query.Limit <= 0 {
|
|
query.Limit = 10
|
|
}
|
|
if query.Limit > 100 {
|
|
query.Limit = 100
|
|
}
|
|
|
|
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(
|
|
ctx,
|
|
offset,
|
|
query.Limit,
|
|
query.Search,
|
|
query.GameID,
|
|
query.CustomerID,
|
|
query.PrizeID,
|
|
query.SortBy,
|
|
query.SortOrder,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list game plays: %w", err)
|
|
}
|
|
|
|
// Convert to responses
|
|
responses := mappers.ToGamePlayResponsesFromPointers(gamePlays)
|
|
|
|
// Calculate pagination info
|
|
totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit))
|
|
|
|
return &models.PaginatedResponse[models.GamePlayResponse]{
|
|
Data: responses,
|
|
Pagination: models.Pagination{
|
|
Page: query.Page,
|
|
Limit: query.Limit,
|
|
Total: total,
|
|
TotalPages: totalPages,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// 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) {
|
|
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, refuse("game is not active")
|
|
}
|
|
cost, err := gameCoinCost(game.Metadata)
|
|
if err != nil {
|
|
return nil, refuse("%s", err.Error())
|
|
}
|
|
|
|
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 {
|
|
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)
|
|
|
|
// 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, err
|
|
}
|
|
|
|
balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &models.PlayGameResponse{
|
|
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 {
|
|
return nil
|
|
}
|
|
|
|
// Calculate total weight
|
|
totalWeight := 0
|
|
for _, prize := range prizes {
|
|
totalWeight += prize.Weight
|
|
}
|
|
|
|
if totalWeight == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Generate random number
|
|
rand.Seed(time.Now().UnixNano())
|
|
randomNumber := rand.Intn(totalWeight)
|
|
|
|
// Select prize based on cumulative weight
|
|
currentWeight := 0
|
|
for _, prize := range prizes {
|
|
currentWeight += prize.Weight
|
|
if randomNumber < currentWeight {
|
|
return &prize
|
|
}
|
|
}
|
|
|
|
// Fallback to last prize
|
|
return &prizes[len(prizes)-1]
|
|
}
|