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] }