package bossraid import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "log" "time" "github.com/redis/go-redis/v9" "github.com/tolelom/tolchain/core" ) const ( // defaultMaxPlayers is the maximum number of players allowed in a boss raid room. defaultMaxPlayers = 3 // entryTokenTTL is the TTL for boss raid entry tokens in Redis. entryTokenTTL = 5 * time.Minute // entryTokenPrefix is the Redis key prefix for entry token → {username, sessionName}. entryTokenPrefix = "bossraid:entry:" // pendingEntryPrefix is the Redis key prefix for username → {sessionName, entryToken}. pendingEntryPrefix = "bossraid:pending:" ) // entryTokenData is stored in Redis for each entry token. type entryTokenData struct { Username string `json:"username"` SessionName string `json:"sessionName"` } type Service struct { repo *Repository rdb *redis.Client rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) (txID string, err error) expGrant func(username string, exp int) error } func NewService(repo *Repository, rdb *redis.Client) *Service { return &Service{repo: repo, rdb: rdb} } // SetRewardGranter sets the callback for granting rewards via blockchain. // The callback returns the blockchain transaction ID and an error. func (s *Service) SetRewardGranter(fn func(username string, tokenAmount uint64, assets []core.MintAssetPayload) (txID string, err error)) { s.rewardGrant = fn } // SetExpGranter sets the callback for granting experience to players. func (s *Service) SetExpGranter(fn func(username string, exp int) error) { s.expGrant = fn } // RequestEntry creates a new boss room for a party. // Allocates an idle room slot from a registered dedicated server. // Returns the room with assigned session name. func (s *Service) RequestEntry(usernames []string, bossID int) (*BossRoom, error) { // 좀비 슬롯 정리 — idle 슬롯 검색 전에 stale 인스턴스를 리셋 s.CheckStaleSlots() if len(usernames) == 0 { return nil, fmt.Errorf("플레이어 목록이 비어있습니다") } if len(usernames) > 3 { return nil, fmt.Errorf("최대 3명까지 입장할 수 있습니다") } // 중복 플레이어 검증 seen := make(map[string]bool, len(usernames)) for _, u := range usernames { if seen[u] { return nil, fmt.Errorf("중복된 플레이어가 있습니다: %s", u) } seen[u] = true } playersJSON, err := json.Marshal(usernames) if err != nil { return nil, fmt.Errorf("플레이어 목록 직렬화 실패: %w", err) } var room *BossRoom // Wrap slot allocation + active-room check + creation in a transaction. err = s.repo.Transaction(func(txRepo *Repository) error { // Find an idle room slot from a live dedicated server instance staleThreshold := time.Now().Add(-30 * time.Second) slot, err := txRepo.FindIdleRoomSlot(staleThreshold) if err != nil { return fmt.Errorf("현재 이용 가능한 보스 레이드 방이 없습니다") } for _, username := range usernames { count, err := txRepo.CountActiveByUsername(username) if err != nil { return fmt.Errorf("플레이어 상태 확인 실패: %w", err) } if count > 0 { return fmt.Errorf("플레이어 %s가 이미 보스 레이드 중입니다", username) } } room = &BossRoom{ SessionName: slot.SessionName, BossID: bossID, Status: StatusWaiting, MaxPlayers: defaultMaxPlayers, Players: string(playersJSON), } if err := txRepo.Create(room); err != nil { return fmt.Errorf("방 생성 실패: %w", err) } // Mark slot as waiting and link to the boss room slot.Status = SlotWaiting slot.BossRoomID = &room.ID if err := txRepo.UpdateRoomSlot(slot); err != nil { return fmt.Errorf("슬롯 상태 업데이트 실패: %w", err) } return nil }) if err != nil { return nil, err } return room, nil } // StartRaid marks a room as in_progress and updates the slot status. // Uses optimistic locking (WHERE status = 'waiting') to prevent concurrent state transitions. func (s *Service) StartRaid(sessionName string) (*BossRoom, error) { now := time.Now() err := s.repo.TransitionRoomStatus(sessionName, StatusWaiting, StatusInProgress, map[string]interface{}{ "started_at": now, }) if err == ErrStatusConflict { return nil, fmt.Errorf("시작할 수 없는 상태입니다 (이미 변경됨)") } if err != nil { return nil, fmt.Errorf("상태 업데이트 실패: %w", err) } // Update slot status to in_progress (non-fatal if fails) s.repo.TransitionSlotStatus(sessionName, SlotWaiting, SlotInProgress) room, err := s.repo.FindBySessionName(sessionName) if err != nil { return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err) } return room, nil } // PlayerReward describes the reward for a single player. type PlayerReward struct { Username string `json:"username"` TokenAmount uint64 `json:"tokenAmount"` Assets []core.MintAssetPayload `json:"assets"` Experience int `json:"experience"` // 경험치 보상 } // RewardResult holds the result of granting a reward to one player. type RewardResult struct { Username string `json:"username"` Success bool `json:"success"` Error string `json:"error,omitempty"` } // CompleteRaid marks a room as completed and grants rewards via blockchain. // Uses optimistic locking (WHERE status = 'in_progress') to prevent double-completion. func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) { var resultRewards []RewardResult // Validate reward recipients are room players before transitioning room, err := s.repo.FindBySessionName(sessionName) if err != nil { return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err) } var players []string if err := json.Unmarshal([]byte(room.Players), &players); err != nil { return nil, nil, fmt.Errorf("플레이어 목록 파싱 실패: %w", err) } playerSet := make(map[string]bool, len(players)) for _, p := range players { playerSet[p] = true } for _, r := range rewards { if !playerSet[r.Username] { return nil, nil, fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username) } } // Atomically transition status: in_progress → completed now := time.Now() err = s.repo.TransitionRoomStatus(sessionName, StatusInProgress, StatusCompleted, map[string]interface{}{ "completed_at": now, }) if err == ErrStatusConflict { return nil, nil, fmt.Errorf("완료할 수 없는 상태입니다 (이미 변경됨)") } if err != nil { return nil, nil, fmt.Errorf("상태 업데이트 실패: %w", err) } // Re-fetch the updated room resultRoom, err := s.repo.FindBySessionName(sessionName) if err != nil { return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err) } // Grant rewards outside the transaction to avoid holding the lock during RPC calls. // Each reward is attempted up to 3 times with exponential backoff before being // recorded as a RewardFailure for background retry. resultRewards = make([]RewardResult, 0, len(rewards)) hasRewardFailure := false if s.rewardGrant != nil { for _, r := range rewards { lastTxID, grantErr := s.grantWithRetry(r.Username, r.TokenAmount, r.Assets) result := RewardResult{Username: r.Username, Success: grantErr == nil} if grantErr != nil { result.Error = grantErr.Error() log.Printf("보상 지급 실패 (재시도 소진): %s: %v", r.Username, grantErr) hasRewardFailure = true // 실패한 보상을 DB에 기록하여 백그라운드 재시도 가능하게 함 s.saveRewardFailure(sessionName, r, grantErr, lastTxID) } resultRewards = append(resultRewards, result) } } // Grant experience to players (with retry) if s.expGrant != nil { for _, r := range rewards { if r.Experience > 0 { expErr := s.grantExpWithRetry(r.Username, r.Experience) if expErr != nil { log.Printf("경험치 지급 실패 (재시도 소진): %s: %v", r.Username, expErr) hasRewardFailure = true // 경험치 실패도 RewardFailure에 기록 (토큰/에셋 없이 경험치만) s.saveRewardFailure(sessionName, PlayerReward{ Username: r.Username, Experience: r.Experience, }, expErr, "") } } } } // 보상 실패(블록체인 또는 경험치)가 있으면 상태를 reward_failed로 업데이트 if hasRewardFailure { if err := s.repo.TransitionRoomStatus(sessionName, StatusCompleted, StatusRewardFailed, nil); err != nil { log.Printf("보상 실패 상태 업데이트 실패: %s: %v", sessionName, err) } } // BossRoom 삭제 후 슬롯 리셋 — 다음 파티가 즉시 슬롯 재사용 가능 if err := s.repo.DeleteRoomBySessionName(sessionName); err != nil { log.Printf("BossRoom 삭제 실패 (complete): %s: %v", sessionName, err) } if err := s.repo.ResetRoomSlot(sessionName); err != nil { log.Printf("슬롯 리셋 실패 (complete): %s: %v", sessionName, err) } return resultRoom, resultRewards, nil } // FailRaid marks a room as failed and resets the slot. // Uses optimistic locking (WHERE status IN ('waiting','in_progress')) to prevent concurrent state transitions. func (s *Service) FailRaid(sessionName string) (*BossRoom, error) { now := time.Now() err := s.repo.TransitionRoomStatusMulti(sessionName, []RoomStatus{StatusWaiting, StatusInProgress}, StatusFailed, map[string]interface{}{"completed_at": now}, ) if err == ErrStatusConflict { return nil, fmt.Errorf("실패 처리할 수 없는 상태입니다 (이미 변경됨)") } if err != nil { return nil, fmt.Errorf("상태 업데이트 실패: %w", err) } // 응답용 room 조회 (삭제 전에 수행) room, err := s.repo.FindBySessionName(sessionName) if err != nil { return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err) } // BossRoom 삭제 후 슬롯 리셋 — 다음 파티가 즉시 슬롯 재사용 가능 if err := s.repo.DeleteRoomBySessionName(sessionName); err != nil { log.Printf("BossRoom 삭제 실패 (fail): %s: %v", sessionName, err) } if err := s.repo.ResetRoomSlot(sessionName); err != nil { log.Printf("슬롯 리셋 실패 (fail): %s: %v", sessionName, err) } return room, nil } // GetRoom returns a room by session name. func (s *Service) GetRoom(sessionName string) (*BossRoom, error) { return s.repo.FindBySessionName(sessionName) } // generateToken creates a cryptographically random hex token. func generateToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // GenerateEntryTokens creates entry tokens for all players in a room // and stores them in Redis. Returns a map of username → entryToken. func (s *Service) GenerateEntryTokens(sessionName string, usernames []string) (map[string]string, error) { ctx := context.Background() tokens := make(map[string]string, len(usernames)) for _, username := range usernames { token, err := generateToken() if err != nil { return nil, fmt.Errorf("토큰 생성 실패: %w", err) } tokens[username] = token // Store entry token → {username, sessionName} data, _ := json.Marshal(entryTokenData{ Username: username, SessionName: sessionName, }) entryKey := entryTokenPrefix + token if err := s.rdb.Set(ctx, entryKey, string(data), entryTokenTTL).Err(); err != nil { return nil, fmt.Errorf("Redis 저장 실패: %w", err) } // Store pending entry: username → {sessionName, entryToken} pendingData, _ := json.Marshal(map[string]string{ "sessionName": sessionName, "entryToken": token, }) pendingKey := pendingEntryPrefix + username if err := s.rdb.Set(ctx, pendingKey, string(pendingData), entryTokenTTL).Err(); err != nil { return nil, fmt.Errorf("Redis 저장 실패: %w", err) } } return tokens, nil } // ValidateEntryToken validates and consumes a one-time entry token. // Returns the username and sessionName if valid. func (s *Service) ValidateEntryToken(token string) (username, sessionName string, err error) { ctx := context.Background() key := entryTokenPrefix + token val, err := s.rdb.GetDel(ctx, key).Result() if err == redis.Nil { return "", "", fmt.Errorf("유효하지 않거나 만료된 입장 토큰입니다") } if err != nil { return "", "", fmt.Errorf("토큰 검증 실패: %w", err) } var data entryTokenData if err := json.Unmarshal([]byte(val), &data); err != nil { return "", "", fmt.Errorf("토큰 데이터 파싱 실패: %w", err) } return data.Username, data.SessionName, nil } // GetMyEntryToken returns the pending entry token for a username. func (s *Service) GetMyEntryToken(username string) (sessionName, entryToken string, err error) { ctx := context.Background() key := pendingEntryPrefix + username val, err := s.rdb.Get(ctx, key).Result() if err == redis.Nil { return "", "", fmt.Errorf("대기 중인 입장 토큰이 없습니다") } if err != nil { return "", "", fmt.Errorf("토큰 조회 실패: %w", err) } var data map[string]string if err := json.Unmarshal([]byte(val), &data); err != nil { return "", "", fmt.Errorf("토큰 데이터 파싱 실패: %w", err) } return data["sessionName"], data["entryToken"], nil } // RequestEntryWithTokens creates a boss room and generates entry tokens for all players. // Returns the room and a map of username → entryToken. func (s *Service) RequestEntryWithTokens(usernames []string, bossID int) (*BossRoom, map[string]string, error) { room, err := s.RequestEntry(usernames, bossID) if err != nil { return nil, nil, err } tokens, err := s.GenerateEntryTokens(room.SessionName, usernames) if err != nil { return nil, nil, fmt.Errorf("입장 토큰 생성 실패: %w", err) } return room, tokens, nil } // --- Dedicated Server Management --- const staleTimeout = 30 * time.Second // RegisterServer registers a dedicated server instance (container). // Creates the server group + slots if needed, then assigns a slot to this instance. // Returns the assigned sessionName. func (s *Service) RegisterServer(serverName, instanceID string, maxRooms int) (string, error) { if serverName == "" || instanceID == "" { return "", fmt.Errorf("serverName과 instanceId는 필수입니다") } if maxRooms <= 0 { maxRooms = 10 } // Ensure server group exists server := &DedicatedServer{ ServerName: serverName, MaxRooms: maxRooms, } if err := s.repo.UpsertDedicatedServer(server); err != nil { return "", fmt.Errorf("서버 그룹 등록 실패: %w", err) } // Re-fetch to get the ID server, err := s.repo.FindDedicatedServerByName(serverName) if err != nil { return "", fmt.Errorf("서버 조회 실패: %w", err) } // Ensure all room slots exist if err := s.repo.EnsureRoomSlots(server.ID, serverName, maxRooms); err != nil { return "", fmt.Errorf("슬롯 생성 실패: %w", err) } // Assign a slot to this instance staleThreshold := time.Now().Add(-staleTimeout) slot, err := s.repo.AssignSlotToInstance(server.ID, instanceID, staleThreshold) if err != nil { return "", fmt.Errorf("슬롯 배정 실패: %w", err) } return slot.SessionName, nil } // Heartbeat updates the heartbeat for a container instance. func (s *Service) Heartbeat(instanceID string) error { return s.repo.UpdateHeartbeat(instanceID) } // CheckStaleSlots resets slots whose instances have gone silent. func (s *Service) CheckStaleSlots() { threshold := time.Now().Add(-staleTimeout) count, err := s.repo.ResetStaleSlots(threshold) if err != nil { log.Printf("스태일 슬롯 체크 실패: %v", err) return } if count > 0 { log.Printf("스태일 슬롯 %d개 리셋", count) } } // ResetRoom resets a room slot back to idle and cleans up any lingering BossRoom records. // Called by the dedicated server after a raid ends and the runner is recycled. func (s *Service) ResetRoom(sessionName string) error { // 완료/실패되지 않은 BossRoom 레코드 정리 (waiting/in_progress 상태) if err := s.repo.DeleteRoomBySessionName(sessionName); err != nil { log.Printf("BossRoom 레코드 정리 실패: %s: %v", sessionName, err) } return s.repo.ResetRoomSlot(sessionName) } // GetServerStatus returns a server group and its room slots. func (s *Service) GetServerStatus(serverName string) (*DedicatedServer, []RoomSlot, error) { server, err := s.repo.FindDedicatedServerByName(serverName) if err != nil { return nil, nil, fmt.Errorf("서버를 찾을 수 없습니다: %w", err) } slots, err := s.repo.GetRoomSlotsByServer(server.ID) if err != nil { return nil, nil, fmt.Errorf("슬롯 조회 실패: %w", err) } return server, slots, nil } // --- Reward retry helpers --- const immediateRetries = 3 // grantWithRetry attempts the reward grant up to 3 times with backoff (1s, 2s). // Returns the last attempted transaction ID (may be empty) and the error. func (s *Service) grantWithRetry(username string, tokenAmount uint64, assets []core.MintAssetPayload) (string, error) { delays := []time.Duration{1 * time.Second, 2 * time.Second} var lastErr error var lastTxID string for attempt := 0; attempt < immediateRetries; attempt++ { txID, err := s.rewardGrant(username, tokenAmount, assets) if txID != "" { lastTxID = txID } if err == nil { return txID, nil } lastErr = err if attempt < len(delays) { log.Printf("보상 지급 재시도 (%d/%d): %s: %v", attempt+1, immediateRetries, username, lastErr) time.Sleep(delays[attempt]) } } return lastTxID, lastErr } // grantExpWithRetry attempts the experience grant up to 3 times with backoff (1s, 2s). func (s *Service) grantExpWithRetry(username string, exp int) error { delays := []time.Duration{1 * time.Second, 2 * time.Second} var lastErr error for attempt := 0; attempt < immediateRetries; attempt++ { lastErr = s.expGrant(username, exp) if lastErr == nil { return nil } if attempt < len(delays) { log.Printf("경험치 지급 재시도 (%d/%d): %s: %v", attempt+1, immediateRetries, username, lastErr) time.Sleep(delays[attempt]) } } return lastErr } // saveRewardFailure records a failed reward in the DB for background retry. func (s *Service) saveRewardFailure(sessionName string, r PlayerReward, grantErr error, lastTxID string) { assets := "[]" if len(r.Assets) > 0 { if data, err := json.Marshal(r.Assets); err == nil { assets = string(data) } } rf := &RewardFailure{ SessionName: sessionName, Username: r.Username, TokenAmount: r.TokenAmount, Assets: assets, Experience: r.Experience, Error: grantErr.Error(), LastTxID: lastTxID, } if err := s.repo.SaveRewardFailure(rf); err != nil { log.Printf("보상 실패 기록 저장 실패: %s/%s: %v", sessionName, r.Username, err) } }