401 lines
10 KiB
Go
401 lines
10 KiB
Go
package activity
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/binary"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// CardType 卡牌类型
|
||
type CardType string
|
||
|
||
// CardTypeConfig 卡牌类型配置
|
||
type CardTypeConfig struct {
|
||
Code CardType `json:"code"`
|
||
Name string `json:"name"`
|
||
ImageURL string `json:"image_url"`
|
||
Quantity int32 `json:"quantity"`
|
||
}
|
||
|
||
// MatchingCard 游戏中的卡牌实例
|
||
type MatchingCard struct {
|
||
ID string `json:"id"`
|
||
Type CardType `json:"type"`
|
||
}
|
||
|
||
// MatchingGame 对对碰游戏结构
|
||
type MatchingGame struct {
|
||
Mu sync.Mutex `json:"-"`
|
||
ServerSeed []byte `json:"server_seed"`
|
||
ServerSeedHash string `json:"server_seed_hash"`
|
||
Nonce int64 `json:"nonce"`
|
||
CardConfigs []CardTypeConfig `json:"card_configs"`
|
||
Deck []*MatchingCard `json:"deck"`
|
||
Board [9]*MatchingCard `json:"board"`
|
||
CardIDCounter int64 `json:"card_id_counter"`
|
||
TotalPairs int64 `json:"total_pairs"`
|
||
MaxPossiblePairs int64 `json:"max_possible_pairs"`
|
||
LastActivity time.Time `json:"last_activity"`
|
||
Position string `json:"position"` // 用户选择的类型,用于服务端验证
|
||
CreatedAt time.Time `json:"created_at"` // 游戏创建时间,用于自动开奖超时判断
|
||
|
||
ActivityID int64 `json:"activity_id"`
|
||
IssueID int64 `json:"issue_id"`
|
||
OrderID int64 `json:"order_id"`
|
||
UserID int64 `json:"user_id"`
|
||
}
|
||
|
||
const MatchingGameKeyPrefix = "bindbox:matching_game:"
|
||
|
||
// NewMatchingGameWithConfig 使用配置创建游戏
|
||
func NewMatchingGameWithConfig(configs []CardTypeConfig, position string, masterSeed []byte) *MatchingGame {
|
||
if len(masterSeed) == 0 {
|
||
return nil
|
||
}
|
||
|
||
g := &MatchingGame{
|
||
CardConfigs: configs,
|
||
Board: [9]*MatchingCard{},
|
||
LastActivity: time.Now(),
|
||
}
|
||
|
||
h := hmac.New(sha256.New, masterSeed)
|
||
h.Write([]byte(position))
|
||
h.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
|
||
g.ServerSeed = h.Sum(nil)
|
||
|
||
hash := sha256.Sum256(g.ServerSeed)
|
||
g.ServerSeedHash = fmt.Sprintf("%x", hash)
|
||
|
||
totalCards := 0
|
||
for _, cfg := range configs {
|
||
totalCards += int(cfg.Quantity)
|
||
}
|
||
|
||
allCards := make([]*MatchingCard, 0, totalCards)
|
||
for _, cfg := range configs {
|
||
for i := int32(0); i < cfg.Quantity; i++ {
|
||
g.CardIDCounter++
|
||
id := fmt.Sprintf("c%d", g.CardIDCounter)
|
||
mc := &MatchingCard{
|
||
ID: id,
|
||
Type: cfg.Code,
|
||
}
|
||
allCards = append(allCards, mc)
|
||
}
|
||
}
|
||
g.Deck = allCards
|
||
|
||
g.SecureShuffle()
|
||
|
||
for i := 0; i < 9; i++ {
|
||
if len(g.Deck) > 0 {
|
||
card := g.Deck[0]
|
||
g.Deck = g.Deck[1:]
|
||
g.Board[i] = card
|
||
}
|
||
}
|
||
|
||
var theoreticalMax int64
|
||
for _, cfg := range configs {
|
||
theoreticalMax += int64(cfg.Quantity / 2)
|
||
}
|
||
g.MaxPossiblePairs = theoreticalMax
|
||
|
||
return g
|
||
}
|
||
|
||
func (g *MatchingGame) SecureShuffle() {
|
||
n := len(g.Deck)
|
||
for i := n - 1; i > 0; i-- {
|
||
j := g.secureRandInt(i+1, fmt.Sprintf("shuffle:%d", i))
|
||
g.Deck[i], g.Deck[j] = g.Deck[j], g.Deck[i]
|
||
}
|
||
}
|
||
|
||
func (g *MatchingGame) secureRandInt(max int, context string) int {
|
||
g.Nonce++
|
||
message := fmt.Sprintf("%s|nonce:%d", context, g.Nonce)
|
||
mac := hmac.New(sha256.New, g.ServerSeed)
|
||
mac.Write([]byte(message))
|
||
sum := mac.Sum(nil)
|
||
val := binary.BigEndian.Uint64(sum[:8])
|
||
return int(val % uint64(max))
|
||
}
|
||
|
||
// GetGameState 获取游戏状态
|
||
func (g *MatchingGame) GetGameState() map[string]any {
|
||
return map[string]any{
|
||
"board": g.Board,
|
||
"deck_count": len(g.Deck),
|
||
"total_pairs": g.TotalPairs,
|
||
"server_seed_hash": g.ServerSeedHash,
|
||
}
|
||
}
|
||
|
||
// SimulateMaxPairs 服务端模拟计算给定牌组和选中类型的理论最大对数
|
||
// 返回值:理论最大可消除对数
|
||
func (g *MatchingGame) SimulateMaxPairs() int64 {
|
||
// 重建完整牌组:Board + Deck
|
||
allCards := make([]*MatchingCard, 0, len(g.Board)+len(g.Deck))
|
||
for _, c := range g.Board {
|
||
if c != nil {
|
||
allCards = append(allCards, c)
|
||
}
|
||
}
|
||
allCards = append(allCards, g.Deck...)
|
||
|
||
// 🔍 详细日志:牌组信息
|
||
boardTypes := make([]string, 0, 9)
|
||
for _, c := range g.Board {
|
||
if c != nil {
|
||
boardTypes = append(boardTypes, string(c.Type))
|
||
}
|
||
}
|
||
log.Printf("[SimulateMaxPairs] Board(%d张): %s", len(boardTypes), strings.Join(boardTypes, ","))
|
||
log.Printf("[SimulateMaxPairs] Deck长度: %d, 总牌数: %d", len(g.Deck), len(allCards))
|
||
log.Printf("[SimulateMaxPairs] Position: '%s'", g.Position)
|
||
|
||
if len(allCards) < 9 {
|
||
log.Printf("[SimulateMaxPairs] 牌数不足9张,返回0")
|
||
return 0
|
||
}
|
||
|
||
selectedType := CardType(g.Position)
|
||
|
||
// 模拟游戏
|
||
hand := make([]*MatchingCard, 9)
|
||
copy(hand, allCards[:9])
|
||
deckIndex := 9
|
||
|
||
// 初始机会 = 手牌中选中类型的数量
|
||
chance := int64(0)
|
||
for _, c := range hand {
|
||
if c != nil && c.Type == selectedType {
|
||
chance++
|
||
}
|
||
}
|
||
|
||
// 🔍 详细日志:初始状态
|
||
handTypes := make([]string, 0, len(hand))
|
||
for _, c := range hand {
|
||
if c != nil {
|
||
handTypes = append(handTypes, string(c.Type))
|
||
}
|
||
}
|
||
log.Printf("[SimulateMaxPairs] 初始手牌: %s", strings.Join(handTypes, ","))
|
||
log.Printf("[SimulateMaxPairs] 选中类型: '%s', 初始机会: %d", selectedType, chance)
|
||
|
||
totalPairs := int64(0)
|
||
|
||
// canEliminate 检查是否有可配对的牌
|
||
canEliminate := func() CardType {
|
||
counts := make(map[CardType]int)
|
||
for _, c := range hand {
|
||
if c == nil {
|
||
continue
|
||
}
|
||
counts[c.Type]++
|
||
if counts[c.Type] >= 2 {
|
||
return c.Type
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// eliminatePair 消除一对
|
||
eliminatePair := func(targetType CardType) bool {
|
||
first, second := -1, -1
|
||
for i, c := range hand {
|
||
if c == nil || c.Type != targetType {
|
||
continue
|
||
}
|
||
if first < 0 {
|
||
first = i
|
||
} else {
|
||
second = i
|
||
break
|
||
}
|
||
}
|
||
if first >= 0 && second >= 0 {
|
||
// 移除两张牌(从切片中删除)
|
||
newHand := make([]*MatchingCard, 0, len(hand)-2)
|
||
for i, c := range hand {
|
||
if i != first && i != second {
|
||
newHand = append(newHand, c)
|
||
}
|
||
}
|
||
hand = newHand
|
||
totalPairs++
|
||
chance++
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 游戏循环
|
||
guard := 0
|
||
for guard < 1000 {
|
||
guard++
|
||
|
||
// 尝试消除
|
||
if pairType := canEliminate(); pairType != "" {
|
||
eliminatePair(pairType)
|
||
continue
|
||
}
|
||
|
||
// 不能消除,尝试摸牌
|
||
if chance > 0 && deckIndex < len(allCards) {
|
||
newCard := allCards[deckIndex]
|
||
hand = append(hand, newCard)
|
||
deckIndex++
|
||
chance--
|
||
|
||
continue
|
||
}
|
||
|
||
// 既不能消除也不能摸牌,游戏结束
|
||
break
|
||
}
|
||
|
||
log.Printf("[SimulateMaxPairs] 模拟结束, 最终对数: %d, 剩余手牌: %d, 已摸牌数: %d", totalPairs, len(hand), deckIndex-9)
|
||
return totalPairs
|
||
}
|
||
|
||
// ReconstructMatchingGame 根据订单号还原游戏状态
|
||
func (s *service) ReconstructMatchingGame(ctx context.Context, orderNo string) (*MatchingGame, error) {
|
||
order, err := s.readDB.Orders.WithContext(ctx).Where(s.readDB.Orders.OrderNo.Eq(orderNo)).First()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 1. Get DrawLog first
|
||
drawLog, err := s.readDB.ActivityDrawLogs.WithContext(ctx).
|
||
Where(s.readDB.ActivityDrawLogs.OrderID.Eq(order.ID)).
|
||
First()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("draw log not found: %w", err)
|
||
}
|
||
|
||
// 2. Get Receipt using DrawLogID
|
||
receipt, err := s.readDB.ActivityDrawReceipts.WithContext(ctx).
|
||
Where(s.readDB.ActivityDrawReceipts.DrawLogID.Eq(drawLog.ID)).
|
||
First()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("draw receipt not found: %w", err)
|
||
}
|
||
|
||
serverSeed, err := hex.DecodeString(receipt.ServerSubSeed)
|
||
if err != nil || len(serverSeed) == 0 {
|
||
return nil, fmt.Errorf("invalid server seed in receipt")
|
||
}
|
||
|
||
// Retrieve ActivityID from Issue
|
||
issue, err := s.readDB.ActivityIssues.WithContext(ctx).Where(s.readDB.ActivityIssues.ID.Eq(drawLog.IssueID)).First()
|
||
var activityID int64
|
||
if err == nil && issue != nil {
|
||
activityID = issue.ActivityID
|
||
}
|
||
|
||
configs, err := s.loadCardTypesFromDB(ctx)
|
||
if err != nil || len(configs) == 0 {
|
||
return nil, fmt.Errorf("failed to load card types")
|
||
}
|
||
|
||
game := &MatchingGame{
|
||
CardConfigs: configs,
|
||
Board: [9]*MatchingCard{},
|
||
LastActivity: time.Now(),
|
||
ServerSeed: serverSeed,
|
||
ServerSeedHash: receipt.ServerSeedHash,
|
||
Nonce: 0,
|
||
IssueID: drawLog.IssueID,
|
||
OrderID: drawLog.OrderID,
|
||
UserID: order.UserID,
|
||
ActivityID: activityID,
|
||
}
|
||
|
||
allCards := make([]*MatchingCard, 0, 99)
|
||
for _, cfg := range configs {
|
||
for i := int32(0); i < cfg.Quantity; i++ {
|
||
game.CardIDCounter++
|
||
id := fmt.Sprintf("c%d", game.CardIDCounter)
|
||
mc := &MatchingCard{
|
||
ID: id,
|
||
Type: cfg.Code,
|
||
}
|
||
allCards = append(allCards, mc)
|
||
}
|
||
}
|
||
game.Deck = allCards
|
||
|
||
game.SecureShuffle()
|
||
|
||
for i := 0; i < 9; i++ {
|
||
if len(game.Deck) > 0 {
|
||
card := game.Deck[0]
|
||
game.Deck = game.Deck[1:]
|
||
game.Board[i] = card
|
||
}
|
||
}
|
||
|
||
var theoreticalMax int64
|
||
for _, cfg := range configs {
|
||
theoreticalMax += int64(cfg.Quantity / 2)
|
||
}
|
||
game.MaxPossiblePairs = theoreticalMax
|
||
|
||
return game, nil
|
||
}
|
||
|
||
func (s *service) ListMatchingCardTypes(ctx context.Context) ([]CardTypeConfig, error) {
|
||
return s.loadCardTypesFromDB(ctx)
|
||
}
|
||
|
||
func (s *service) loadCardTypesFromDB(ctx context.Context) ([]CardTypeConfig, error) {
|
||
items, err := s.readDB.MatchingCardTypes.WithContext(ctx).Where(s.readDB.MatchingCardTypes.Status.Eq(1)).Order(s.readDB.MatchingCardTypes.Sort.Asc()).Find()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
configs := make([]CardTypeConfig, len(items))
|
||
for i, item := range items {
|
||
configs[i] = CardTypeConfig{
|
||
Code: CardType(item.Code),
|
||
Name: item.Name,
|
||
ImageURL: item.ImageURL,
|
||
Quantity: item.Quantity,
|
||
}
|
||
}
|
||
return configs, nil
|
||
}
|
||
|
||
// GetMatchingGameFromRedis 从 Redis 加载游戏状态
|
||
func (s *service) GetMatchingGameFromRedis(ctx context.Context, gameID string) (*MatchingGame, error) {
|
||
data, err := s.redis.Get(ctx, MatchingGameKeyPrefix+gameID).Bytes()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var game MatchingGame
|
||
if err := json.Unmarshal(data, &game); err != nil {
|
||
return nil, err
|
||
}
|
||
return &game, nil
|
||
}
|
||
|
||
// SaveMatchingGameToRedis 保存游戏状态到 Redis
|
||
func (s *service) SaveMatchingGameToRedis(ctx context.Context, gameID string, game *MatchingGame) error {
|
||
data, err := json.Marshal(game)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.redis.Set(ctx, MatchingGameKeyPrefix+gameID, data, 30*time.Minute).Err()
|
||
}
|