412 lines
12 KiB
Go
412 lines
12 KiB
Go
package economy
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// خطاهای دامنه.
|
|
var (
|
|
ErrTooSoon = errors.New("daily reward not ready")
|
|
ErrAdNotVerified = errors.New("ad not verified")
|
|
ErrAdCap = errors.New("daily ad cap reached")
|
|
ErrAdDuplicate = errors.New("ad reward already claimed")
|
|
ErrNotFound = errors.New("item not found")
|
|
ErrAlreadyOwned = errors.New("already owned")
|
|
ErrNotOwned = errors.New("card not owned")
|
|
ErrIAPInvalid = errors.New("purchase not valid")
|
|
)
|
|
|
|
const tsLayout = "2006-01-02 15:04:05"
|
|
|
|
func nowUTC() time.Time { return time.Now().UTC() }
|
|
func nowStr() string { return nowUTC().Format(tsLayout) }
|
|
func parseTS(s string) time.Time {
|
|
t, _ := time.Parse(tsLayout, s)
|
|
return t
|
|
}
|
|
|
|
// Service سرویس اقتصاد بازی.
|
|
type Service struct {
|
|
db *sql.DB
|
|
ad AdVerifier
|
|
iap IAPVerifier
|
|
|
|
mu sync.RWMutex // محافظ کشِ کاتالوگ
|
|
cat Catalog // کشِ کاتالوگ خواندهشده از DB
|
|
}
|
|
|
|
func New(db *sql.DB, ad AdVerifier, iap IAPVerifier) *Service {
|
|
return &Service{db: db, ad: ad, iap: iap}
|
|
}
|
|
|
|
// Profile وضعیت اقتصادی کاربر.
|
|
type Profile struct {
|
|
Coins int64 `json:"coins"`
|
|
Tickets int64 `json:"tickets"`
|
|
XP int64 `json:"xp"`
|
|
Trophies int `json:"trophies"`
|
|
Level int `json:"level"`
|
|
XPInto int64 `json:"xp_into_level"`
|
|
XPNeed int64 `json:"xp_for_next"`
|
|
VIP bool `json:"vip"`
|
|
VIPUntil *string `json:"vip_until"`
|
|
SelectedCard string `json:"selected_card"`
|
|
}
|
|
|
|
// GetProfile وضعیت اقتصادی کاربر را میخواند.
|
|
func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) {
|
|
var p Profile
|
|
var vipUntil sql.NullString
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT coins, tickets, xp, trophies, vip_until, selected_card FROM users WHERE id = ?`, userID).
|
|
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP)
|
|
if vipUntil.Valid {
|
|
p.VIPUntil = &vipUntil.String
|
|
p.VIP = parseTS(vipUntil.String).After(nowUTC())
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
func (s *Service) isVIP(ctx context.Context, userID int64) bool {
|
|
var vu sql.NullString
|
|
if err := s.db.QueryRowContext(ctx, `SELECT vip_until FROM users WHERE id = ?`, userID).Scan(&vu); err != nil {
|
|
return false
|
|
}
|
|
return vu.Valid && parseTS(vu.String).After(nowUTC())
|
|
}
|
|
|
|
// ClaimDaily سکه روزانه را در صورت گذشت ۲۴ ساعت میدهد.
|
|
func (s *Service) ClaimDaily(ctx context.Context, userID int64) (int64, error) {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
cutoff := nowUTC().Add(-24 * time.Hour).Format(tsLayout)
|
|
res, err := tx.ExecContext(ctx,
|
|
`UPDATE users SET last_daily_at = ?
|
|
WHERE id = ? AND (last_daily_at IS NULL OR last_daily_at <= ?)`,
|
|
nowStr(), userID, cutoff)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return 0, ErrTooSoon
|
|
}
|
|
if err := adjustTx(ctx, tx, userID, CurrencyCoin, DailyReward, "daily_reward", ""); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, err
|
|
}
|
|
return DailyReward, nil
|
|
}
|
|
|
|
// ClaimAdReward پس از تأیید تبلیغ rewarded، سکه میدهد (با ضد تکرار و سقف روزانه).
|
|
func (s *Service) ClaimAdReward(ctx context.Context, userID int64, token string) (int64, error) {
|
|
ok, err := s.ad.Verify(ctx, userID, token)
|
|
if err != nil || !ok {
|
|
return 0, ErrAdNotVerified
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// سقف روزانه
|
|
since := nowUTC().Add(-24 * time.Hour).Format(tsLayout)
|
|
var cnt int
|
|
if err := tx.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM ad_rewards WHERE user_id = ? AND created_at > ?`, userID, since).Scan(&cnt); err != nil {
|
|
return 0, err
|
|
}
|
|
if cnt >= AdDailyCap {
|
|
return 0, ErrAdCap
|
|
}
|
|
|
|
// ثبت توکن یکتا (ضد تکرار)
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO ad_rewards (user_id, token, amount) VALUES (?, ?, ?)`, userID, token, AdReward); err != nil {
|
|
if isUnique(err) {
|
|
return 0, ErrAdDuplicate
|
|
}
|
|
return 0, err
|
|
}
|
|
if err := adjustTx(ctx, tx, userID, CurrencyCoin, AdReward, "ad_reward", token); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, err
|
|
}
|
|
return AdReward, nil
|
|
}
|
|
|
|
// BuyCard اسکین کارت را با سکه باز میکند.
|
|
func (s *Service) BuyCard(ctx context.Context, userID int64, cardID string) error {
|
|
skin := s.findCardSkin(cardID)
|
|
if skin == nil {
|
|
return ErrNotFound
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// آیا قبلاً مالک است؟
|
|
var exists int
|
|
_ = tx.QueryRowContext(ctx,
|
|
`SELECT 1 FROM user_cards WHERE user_id = ? AND card_id = ?`, userID, cardID).Scan(&exists)
|
|
if exists == 1 {
|
|
return ErrAlreadyOwned
|
|
}
|
|
if skin.PriceCoins > 0 {
|
|
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -skin.PriceCoins, "buy_card", cardID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO user_cards (user_id, card_id) VALUES (?, ?)`, userID, cardID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// SelectCard کارت انتخابی کاربر را تنظیم میکند (باید مالکش باشد یا کارت پیشفرض).
|
|
func (s *Service) SelectCard(ctx context.Context, userID int64, cardID string) error {
|
|
if s.findCardSkin(cardID) == nil {
|
|
return ErrNotFound
|
|
}
|
|
if cardID != "simple" {
|
|
var exists int
|
|
_ = s.db.QueryRowContext(ctx,
|
|
`SELECT 1 FROM user_cards WHERE user_id = ? AND card_id = ?`, userID, cardID).Scan(&exists)
|
|
if exists != 1 {
|
|
return ErrNotOwned
|
|
}
|
|
}
|
|
_, err := s.db.ExecContext(ctx, `UPDATE users SET selected_card = ? WHERE id = ?`, cardID, userID)
|
|
return err
|
|
}
|
|
|
|
// OwnedCards فهرست کارتهای متعلق به کاربر (شامل کارت پیشفرض).
|
|
func (s *Service) OwnedCards(ctx context.Context, userID int64) ([]string, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT card_id FROM user_cards WHERE user_id = ?`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
owned := []string{"simple"}
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
if id != "simple" {
|
|
owned = append(owned, id)
|
|
}
|
|
}
|
|
return owned, rows.Err()
|
|
}
|
|
|
|
// addXPTx مقدار XP را با اعمال بوستر فعال اضافه میکند.
|
|
func (s *Service) addXPTx(ctx context.Context, tx *sql.Tx, userID int64, base int) error {
|
|
mult := 1
|
|
var boostUntil sql.NullString
|
|
var m int
|
|
if err := tx.QueryRowContext(ctx,
|
|
`SELECT xp_boost_mult, xp_boost_until FROM users WHERE id = ?`, userID).Scan(&m, &boostUntil); err == nil {
|
|
if boostUntil.Valid && parseTS(boostUntil.String).After(nowUTC()) && m > 1 {
|
|
mult = m
|
|
}
|
|
}
|
|
_, err := tx.ExecContext(ctx, `UPDATE users SET xp = xp + ? WHERE id = ?`, base*mult, userID)
|
|
return err
|
|
}
|
|
|
|
// PurchaseRequest درخواست تأیید خرید IAP.
|
|
type PurchaseRequest struct {
|
|
Store string `json:"store"` // bazaar | myket
|
|
Kind string `json:"kind"` // coin | ticket | booster
|
|
ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ
|
|
Token string `json:"token"` // purchaseToken
|
|
}
|
|
|
|
// VerifyPurchase خرید را تأیید و در صورت معتبر بودن اعتبار/بلیط/VIP/بوستر میدهد (idempotent).
|
|
func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req PurchaseRequest) error {
|
|
ok, err := s.iap.Verify(ctx, req.Store, req.ProductID, req.Token)
|
|
if err != nil || !ok {
|
|
return ErrIAPInvalid
|
|
}
|
|
|
|
var coins, tickets int64
|
|
var vipDays int
|
|
var booster *Booster
|
|
switch req.Kind {
|
|
case "coin":
|
|
p := s.findCoinPackage(req.ProductID)
|
|
if p == nil {
|
|
return ErrNotFound
|
|
}
|
|
coins, vipDays = p.Coins, p.VIPDays
|
|
if s.isVIP(ctx, userID) {
|
|
coins += coins / 10 // پاداش ۱۰٪ VIP
|
|
}
|
|
case "ticket":
|
|
p := s.findTicketPackage(req.ProductID)
|
|
if p == nil {
|
|
return ErrNotFound
|
|
}
|
|
tickets = p.Tickets
|
|
case "booster":
|
|
booster = s.findBooster(req.ProductID)
|
|
if booster == nil {
|
|
return ErrNotFound
|
|
}
|
|
default:
|
|
return ErrNotFound
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// ثبت خرید (یکتا بودن store+token ⇒ ضد دوبارهاعتبارسنجی)
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO purchases (user_id, store, product_id, purchase_token, status, coins, tickets, vip_days)
|
|
VALUES (?, ?, ?, ?, 'verified', ?, ?, ?)`,
|
|
userID, req.Store, req.ProductID, req.Token, coins, tickets, vipDays); err != nil {
|
|
if isUnique(err) {
|
|
return ErrAlreadyOwned // قبلاً پردازش شده
|
|
}
|
|
return err
|
|
}
|
|
if coins > 0 {
|
|
if err := adjustTx(ctx, tx, userID, CurrencyCoin, coins, "purchase_coin", req.ProductID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if tickets > 0 {
|
|
if err := adjustTx(ctx, tx, userID, CurrencyTicket, tickets, "purchase_ticket", req.ProductID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if vipDays > 0 {
|
|
if err := extendVIPTx(ctx, tx, userID, vipDays); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if booster != nil {
|
|
until := laterOf(nowUTC(), time.Time{}).Add(time.Duration(booster.Hours) * time.Hour).Format(tsLayout)
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE users SET xp_boost_mult = ?, xp_boost_until = ? WHERE id = ?`,
|
|
booster.Multiplier, until, userID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// extendVIPTx زمان VIP را تمدید میکند (از بیشینهٔ اکنون و پایان فعلی).
|
|
func extendVIPTx(ctx context.Context, tx *sql.Tx, userID int64, days int) error {
|
|
var cur sql.NullString
|
|
if err := tx.QueryRowContext(ctx, `SELECT vip_until FROM users WHERE id = ?`, userID).Scan(&cur); err != nil {
|
|
return err
|
|
}
|
|
base := nowUTC()
|
|
if cur.Valid {
|
|
if t := parseTS(cur.String); t.After(base) {
|
|
base = t
|
|
}
|
|
}
|
|
until := base.Add(time.Duration(days) * 24 * time.Hour).Format(tsLayout)
|
|
_, err := tx.ExecContext(ctx, `UPDATE users SET vip_until = ? WHERE id = ?`, until, userID)
|
|
return err
|
|
}
|
|
|
|
func laterOf(a, b time.Time) time.Time {
|
|
if a.After(b) {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// --- تسویهی داخل بازی ---
|
|
|
|
// ChargeEntry ورودی میز را از کاربر کسر میکند.
|
|
func (s *Service) ChargeEntry(ctx context.Context, userID, entry int64, ref string) error {
|
|
if entry <= 0 {
|
|
return nil
|
|
}
|
|
return s.Adjust(ctx, userID, CurrencyCoin, -entry, "table_entry", ref)
|
|
}
|
|
|
|
// Refund ورودی میز را بازمیگرداند (در صورت لغو بازی).
|
|
func (s *Service) Refund(ctx context.Context, userID, entry int64, ref string) error {
|
|
if entry <= 0 {
|
|
return nil
|
|
}
|
|
return s.Adjust(ctx, userID, CurrencyCoin, entry, "table_refund", ref)
|
|
}
|
|
|
|
// AwardWinner جایزه، XP و جام را به برنده میدهد.
|
|
func (s *Service) AwardWinner(ctx context.Context, userID, prize int64, xp, trophy int, ref string) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if prize > 0 {
|
|
if err := adjustTx(ctx, tx, userID, CurrencyCoin, prize, "table_prize", ref); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if xp > 0 {
|
|
if err := s.addXPTx(ctx, tx, userID, xp); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if trophy > 0 {
|
|
if _, err := tx.ExecContext(ctx, `UPDATE users SET trophies = trophies + ? WHERE id = ?`, trophy, userID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// RecordGame نتیجهی بازی را در تاریخچه ثبت میکند.
|
|
func (s *Service) RecordGame(ctx context.Context, room, playersJSON string, winnerTeam int, kot bool) error {
|
|
_, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO game_history (room, players_json, winner_team, kot) VALUES (?, ?, ?, ?)`,
|
|
room, playersJSON, winnerTeam, boolToInt(kot))
|
|
return err
|
|
}
|
|
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func isUnique(err error) bool {
|
|
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique")
|
|
}
|