- swaggo/swag 기반 전체 API 엔드포인트 Swagger 어노테이션 (59개) - /swagger/ 경로에 Swagger UI 제공 - 보스레이드 데디서버 관리 (등록, 하트비트, 슬롯 리셋) - 플레이어 레벨/경험치 시스템 및 스탯 성장 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
482 lines
15 KiB
Go
482 lines
15 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:"
|
|
)
|
|
|
|
// 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) 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.
|
|
func (s *Service) SetRewardGranter(fn func(username string, tokenAmount uint64, assets []core.MintAssetPayload) 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) {
|
|
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 row-level locking to prevent concurrent state transitions.
|
|
func (s *Service) StartRaid(sessionName string) (*BossRoom, error) {
|
|
var resultRoom *BossRoom
|
|
err := s.repo.Transaction(func(txRepo *Repository) error {
|
|
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
|
|
}
|
|
if room.Status != StatusWaiting {
|
|
return fmt.Errorf("시작할 수 없는 상태입니다: %s", room.Status)
|
|
}
|
|
|
|
now := time.Now()
|
|
room.Status = StatusInProgress
|
|
room.StartedAt = &now
|
|
|
|
if err := txRepo.Update(room); err != nil {
|
|
return fmt.Errorf("상태 업데이트 실패: %w", err)
|
|
}
|
|
|
|
// Update slot status to in_progress
|
|
slot, err := txRepo.FindRoomSlotBySession(sessionName)
|
|
if err == nil {
|
|
slot.Status = SlotInProgress
|
|
txRepo.UpdateRoomSlot(slot)
|
|
}
|
|
|
|
resultRoom = room
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resultRoom, 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 a database transaction with row-level locking to prevent double-completion.
|
|
func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) {
|
|
var resultRoom *BossRoom
|
|
var resultRewards []RewardResult
|
|
|
|
err := s.repo.Transaction(func(txRepo *Repository) error {
|
|
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
|
|
}
|
|
if room.Status != StatusInProgress {
|
|
return fmt.Errorf("완료할 수 없는 상태입니다: %s", room.Status)
|
|
}
|
|
|
|
// Validate reward recipients are room players
|
|
var players []string
|
|
if err := json.Unmarshal([]byte(room.Players), &players); err != nil {
|
|
return 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 fmt.Errorf("보상 대상 %s가 방의 플레이어가 아닙니다", r.Username)
|
|
}
|
|
}
|
|
|
|
// Mark room completed
|
|
now := time.Now()
|
|
room.Status = StatusCompleted
|
|
room.CompletedAt = &now
|
|
if err := txRepo.Update(room); err != nil {
|
|
return fmt.Errorf("상태 업데이트 실패: %w", err)
|
|
}
|
|
|
|
resultRoom = room
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Grant rewards outside the transaction to avoid holding the lock during RPC calls
|
|
resultRewards = make([]RewardResult, 0, len(rewards))
|
|
if s.rewardGrant != nil {
|
|
for _, r := range rewards {
|
|
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)
|
|
}
|
|
resultRewards = append(resultRewards, result)
|
|
}
|
|
}
|
|
|
|
// Grant experience to players
|
|
if s.expGrant != nil {
|
|
for _, r := range rewards {
|
|
if r.Experience > 0 {
|
|
if expErr := s.expGrant(r.Username, r.Experience); expErr != nil {
|
|
log.Printf("경험치 지급 실패: %s: %v", r.Username, expErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reset slot to idle so it can accept new raids
|
|
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 row-level locking to prevent concurrent state transitions.
|
|
func (s *Service) FailRaid(sessionName string) (*BossRoom, error) {
|
|
var resultRoom *BossRoom
|
|
err := s.repo.Transaction(func(txRepo *Repository) error {
|
|
room, err := txRepo.FindBySessionNameForUpdate(sessionName)
|
|
if err != nil {
|
|
return fmt.Errorf("방을 찾을 수 없습니다: %w", err)
|
|
}
|
|
if room.Status != StatusWaiting && room.Status != StatusInProgress {
|
|
return fmt.Errorf("실패 처리할 수 없는 상태입니다: %s", room.Status)
|
|
}
|
|
|
|
now := time.Now()
|
|
room.Status = StatusFailed
|
|
room.CompletedAt = &now
|
|
|
|
if err := txRepo.Update(room); err != nil {
|
|
return fmt.Errorf("상태 업데이트 실패: %w", err)
|
|
}
|
|
resultRoom = room
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reset slot to idle so it can accept new raids
|
|
if err := s.repo.ResetRoomSlot(sessionName); err != nil {
|
|
log.Printf("슬롯 리셋 실패 (fail): %s: %v", sessionName, err)
|
|
}
|
|
|
|
return resultRoom, 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.
|
|
// Called by the dedicated server after a raid ends and the runner is recycled.
|
|
func (s *Service) ResetRoom(sessionName string) error {
|
|
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
|
|
}
|