Files
a301_server/internal/bossraid/service.go
tolelom ac6827aae5 Fix: 보스 레이드 재입장 불가 버그 수정
- BossRoom 세션명을 매 입장마다 고유하게 생성 (슬롯명_타임스탬프)
- SlotSessionName 필드 추가로 슬롯 리셋 시 원래 슬롯명 사용
- DeleteRoomBySlotSessionName 추가 (dedicated server ResetRoom 대응)
- CompleteRaid/FailRaid/cleanup에서 슬롯 리셋 로직 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:04:03 +09:00

601 lines
20 KiB
Go

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:"
// waitingRoomTimeout is the maximum time a room can stay in "waiting" status
// before being considered stale and cleaned up. Covers loading + Fusion connection + retries.
waitingRoomTimeout = 2 * time.Minute
)
// 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()
// 입장 요청 플레이어들의 stale waiting room 선제 정리
s.cleanupStaleWaitingForUsers(usernames)
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)
}
}
// 세션명에 타임스탬프를 붙여 매 입장마다 고유하게 만듦
// (이전 Fusion 세션이 아직 살아있어도 충돌하지 않음)
uniqueSession := fmt.Sprintf("%s_%d", slot.SessionName, time.Now().UnixNano())
room = &BossRoom{
SessionName: uniqueSession,
SlotSessionName: slot.SessionName, // 슬롯 리셋용 원래 이름 보존
BossID: bossID,
Status: StatusWaiting,
MaxPlayers: len(usernames),
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 {
// 1회만 시도 — 실패 시 즉시 RewardFailure에 저장하여 백그라운드 워커가 재시도
txID, grantErr := s.rewardGrant(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
s.saveRewardFailure(sessionName, r, grantErr, txID)
}
resultRewards = append(resultRewards, result)
}
}
// Grant experience to players (1회 시도, 실패 시 백그라운드 재시도)
if s.expGrant != nil {
for _, r := range rewards {
if r.Experience > 0 {
expErr := s.expGrant(r.Username, r.Experience)
if expErr != nil {
log.Printf("경험치 지급 실패: %s: %v (백그라운드 재시도 예정)", r.Username, expErr)
hasRewardFailure = true
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)
}
// SlotSessionName으로 슬롯 리셋 (고유 세션명이 아닌 원래 슬롯명)
slotName := resultRoom.SlotSessionName
if slotName == "" {
slotName = sessionName // 하위 호환
}
if err := s.repo.ResetRoomSlot(slotName); err != nil {
log.Printf("슬롯 리셋 실패 (complete): %s: %v", slotName, 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)
}
slotName := room.SlotSessionName
if slotName == "" {
slotName = sessionName
}
if err := s.repo.ResetRoomSlot(slotName); err != nil {
log.Printf("슬롯 리셋 실패 (fail): %s: %v", slotName, 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 {
// 토큰 생성 실패 시 방/슬롯 롤백
log.Printf("입장 토큰 생성 실패, 방/슬롯 롤백: session=%s: %v", room.SessionName, err)
if delErr := s.repo.DeleteRoomBySessionName(room.SessionName); delErr != nil {
log.Printf("롤백 중 방 삭제 실패: %v", delErr)
}
rollbackSlot := room.SlotSessionName
if rollbackSlot == "" {
rollbackSlot = room.SessionName
}
if resetErr := s.repo.ResetRoomSlot(rollbackSlot); resetErr != nil {
log.Printf("롤백 중 슬롯 리셋 실패: %v", resetErr)
}
return nil, nil, fmt.Errorf("입장 토큰 생성 실패: %w", err)
}
return room, tokens, nil
}
// cleanupStaleWaitingForUsers checks if any of the given users are stuck in
// a waiting room whose entry token has already expired or been consumed.
// If the pending token is gone from Redis, the room is abandoned and safe to remove.
// If the token still exists, the room may have active loading players — leave it alone.
func (s *Service) cleanupStaleWaitingForUsers(usernames []string) {
ctx := context.Background()
for _, username := range usernames {
rooms, err := s.repo.FindWaitingRoomsByUsername(username)
if err != nil || len(rooms) == 0 {
continue
}
// pending entry token이 Redis에 남아있으면 정상 로딩 중일 수 있음 → 보존
pendingKey := pendingEntryPrefix + username
exists, _ := s.rdb.Exists(ctx, pendingKey).Result()
if exists > 0 {
continue
}
// 토큰 만료/소비됨 → 방 abandoned 확정, 정리
for _, room := range rooms {
log.Printf("abandoned 대기방 정리 (토큰 만료): session=%s, player=%s", room.SessionName, username)
if err := s.repo.DeleteRoomBySessionName(room.SessionName); err != nil {
log.Printf("대기방 삭제 실패: %v", err)
}
cleanupSlot := room.SlotSessionName
if cleanupSlot == "" {
cleanupSlot = room.SessionName
}
_ = s.repo.ResetRoomSlot(cleanupSlot)
}
}
}
// --- 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
// and cleans up waiting rooms that have exceeded the timeout.
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)
}
// waiting 상태로 너무 오래 머문 방 정리 (로딩 중 강제 종료 등)
waitingThreshold := time.Now().Add(-waitingRoomTimeout)
cleaned, err := s.repo.CleanupStaleWaitingRooms(waitingThreshold)
if err != nil {
log.Printf("스태일 대기방 정리 실패: %v", err)
return
}
if cleaned > 0 {
log.Printf("스태일 대기방 %d개 정리", cleaned)
}
}
// 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.
// sessionName here is the slot's original session name (not the unique per-entry name).
func (s *Service) ResetRoom(sessionName string) error {
// 고유 세션명 BossRoom도 정리 (slot_session_name으로 검색)
if err := s.repo.DeleteRoomBySlotSessionName(sessionName); err != nil {
log.Printf("BossRoom 레코드 정리 실패 (by slot): %s: %v", sessionName, err)
}
// 하위 호환: 원래 세션명으로도 시도
if err := s.repo.DeleteRoomBySessionName(sessionName); err != nil {
// 이미 삭제되었을 수 있으므로 무시
}
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 helpers ---
// 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)
}
}