2025-09-17 19:30:17 +07:00
|
|
|
package processor
|
|
|
|
|
|
|
|
|
|
import (
|
2026-09-30 15:31:11 +07:00
|
|
|
"apskel-pos-be/internal/constants"
|
2025-09-17 19:30:17 +07:00
|
|
|
"apskel-pos-be/internal/entities"
|
|
|
|
|
"apskel-pos-be/internal/mappers"
|
|
|
|
|
"apskel-pos-be/internal/models"
|
|
|
|
|
"apskel-pos-be/internal/repository"
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
2026-09-30 15:31:11 +07:00
|
|
|
"math"
|
2025-09-17 19:30:17 +07:00
|
|
|
"math/rand"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-30 15:31:11 +07:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 19:30:17 +07:00
|
|
|
type GamePlayProcessor struct {
|
2026-09-30 15:31:11 +07:00
|
|
|
gamePlayRepo repository.GamePlayRepository
|
|
|
|
|
gameRepo gameReader
|
|
|
|
|
gamePrizeRepo gamePrizeStock
|
|
|
|
|
wallet *WalletProcessor
|
|
|
|
|
spendable spendableReader
|
|
|
|
|
tx TxRunner
|
2025-09-17 19:30:17 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewGamePlayProcessor(
|
2025-09-18 13:39:37 +07:00
|
|
|
gamePlayRepo repository.GamePlayRepository,
|
2026-09-30 15:31:11 +07:00
|
|
|
gameRepo gameReader,
|
|
|
|
|
gamePrizeRepo gamePrizeStock,
|
|
|
|
|
wallet *WalletProcessor,
|
|
|
|
|
spendable spendableReader,
|
|
|
|
|
tx TxRunner,
|
2025-09-17 19:30:17 +07:00
|
|
|
) *GamePlayProcessor {
|
|
|
|
|
return &GamePlayProcessor{
|
2026-09-30 15:31:11 +07:00
|
|
|
gamePlayRepo: gamePlayRepo,
|
|
|
|
|
gameRepo: gameRepo,
|
|
|
|
|
gamePrizeRepo: gamePrizeRepo,
|
|
|
|
|
wallet: wallet,
|
|
|
|
|
spendable: spendable,
|
|
|
|
|
tx: tx,
|
2025-09-17 19:30:17 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
|
|
|
// 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
|
2025-09-18 13:39:37 +07:00
|
|
|
responses := mappers.ToGamePlayResponsesFromPointers(gamePlays)
|
2025-09-17 19:30:17 +07:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 15:31:11 +07:00
|
|
|
// 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.
|
2025-09-17 19:30:17 +07:00
|
|
|
func (p *GamePlayProcessor) PlayGame(ctx context.Context, req *models.PlayGameRequest) (*models.PlayGameResponse, error) {
|
2026-09-30 15:31:11 +07:00
|
|
|
refuse := func(format string, args ...any) error {
|
|
|
|
|
return fmt.Errorf("%w: %s", ErrGamePlayRefused, fmt.Sprintf(format, args...))
|
|
|
|
|
}
|
2025-09-17 19:30:17 +07:00
|
|
|
game, err := p.gameRepo.GetByID(ctx, req.GameID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("game not found: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if !game.IsActive {
|
2026-09-30 15:31:11 +07:00
|
|
|
return nil, refuse("game is not active")
|
2025-09-17 19:30:17 +07:00
|
|
|
}
|
2026-09-30 15:31:11 +07:00
|
|
|
cost, err := gameCoinCost(game.Metadata)
|
2025-09-17 19:30:17 +07:00
|
|
|
if err != nil {
|
2026-09-30 15:31:11 +07:00
|
|
|
return nil, refuse("%s", err.Error())
|
2025-09-17 19:30:17 +07:00
|
|
|
}
|
|
|
|
|
|
2026-09-30 15:31:11 +07:00
|
|
|
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)
|
2025-09-17 19:30:17 +07:00
|
|
|
if err != nil {
|
2026-09-30 15:31:11 +07:00
|
|
|
return fmt.Errorf("failed to get available prizes: %w", err)
|
2025-09-17 19:30:17 +07:00
|
|
|
}
|
2026-09-30 15:31:11 +07:00
|
|
|
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)
|
2025-09-17 19:30:17 +07:00
|
|
|
|
2026-09-30 15:31:11 +07:00
|
|
|
// 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
|
|
|
|
|
})
|
2025-09-17 19:30:17 +07:00
|
|
|
if err != nil {
|
2026-09-30 15:31:11 +07:00
|
|
|
return nil, err
|
2025-09-17 19:30:17 +07:00
|
|
|
}
|
|
|
|
|
|
2026-09-30 15:31:11 +07:00
|
|
|
balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now())
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2025-09-17 19:30:17 +07:00
|
|
|
return &models.PlayGameResponse{
|
2026-09-30 15:31:11 +07:00
|
|
|
GamePlay: *mappers.ToGamePlayResponse(gamePlay),
|
|
|
|
|
PrizeWon: selectedPrize,
|
|
|
|
|
CoinsRemaining: balances[constants.WalletCurrencyCoin],
|
2025-09-17 19:30:17 +07:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-30 15:31:11 +07:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 19:30:17 +07:00
|
|
|
// 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]
|
|
|
|
|
}
|