diff --git a/internal/bossraid/handler.go b/internal/bossraid/handler.go new file mode 100644 index 0000000..34a81e2 --- /dev/null +++ b/internal/bossraid/handler.go @@ -0,0 +1,141 @@ +package bossraid + +import ( + "log" + + "github.com/gofiber/fiber/v2" +) + +type Handler struct { + svc *Service +} + +func NewHandler(svc *Service) *Handler { + return &Handler{svc: svc} +} + +func bossError(c *fiber.Ctx, status int, userMsg string, err error) error { + log.Printf("bossraid error: %s: %v", userMsg, err) + return c.Status(status).JSON(fiber.Map{"error": userMsg}) +} + +// RequestEntry handles POST /api/internal/bossraid/entry +// Called by MMO server when a party requests boss raid entry. +func (h *Handler) RequestEntry(c *fiber.Ctx) error { + var req struct { + Usernames []string `json:"usernames"` + BossID int `json:"bossId"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if len(req.Usernames) == 0 || req.BossID <= 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "usernames와 bossId는 필수입니다"}) + } + + room, err := h.svc.RequestEntry(req.Usernames, req.BossID) + if err != nil { + return bossError(c, fiber.StatusConflict, err.Error(), err) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "roomId": room.ID, + "sessionName": room.SessionName, + "bossId": room.BossID, + "players": req.Usernames, + "status": room.Status, + }) +} + +// StartRaid handles POST /api/internal/bossraid/start +// Called by dedicated server when the Fusion session begins. +func (h *Handler) StartRaid(c *fiber.Ctx) error { + var req struct { + SessionName string `json:"sessionName"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.SessionName == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"}) + } + + room, err := h.svc.StartRaid(req.SessionName) + if err != nil { + return bossError(c, fiber.StatusBadRequest, err.Error(), err) + } + + return c.JSON(fiber.Map{ + "roomId": room.ID, + "sessionName": room.SessionName, + "status": room.Status, + }) +} + +// CompleteRaid handles POST /api/internal/bossraid/complete +// Called by dedicated server when the boss is killed. Distributes rewards. +func (h *Handler) CompleteRaid(c *fiber.Ctx) error { + var req struct { + SessionName string `json:"sessionName"` + Rewards []PlayerReward `json:"rewards"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.SessionName == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"}) + } + + room, results, err := h.svc.CompleteRaid(req.SessionName, req.Rewards) + if err != nil { + return bossError(c, fiber.StatusBadRequest, err.Error(), err) + } + + return c.JSON(fiber.Map{ + "roomId": room.ID, + "sessionName": room.SessionName, + "status": room.Status, + "rewardResults": results, + }) +} + +// FailRaid handles POST /api/internal/bossraid/fail +// Called by dedicated server on timeout or party wipe. +func (h *Handler) FailRaid(c *fiber.Ctx) error { + var req struct { + SessionName string `json:"sessionName"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "잘못된 요청입니다"}) + } + if req.SessionName == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"}) + } + + room, err := h.svc.FailRaid(req.SessionName) + if err != nil { + return bossError(c, fiber.StatusBadRequest, err.Error(), err) + } + + return c.JSON(fiber.Map{ + "roomId": room.ID, + "sessionName": room.SessionName, + "status": room.Status, + }) +} + +// GetRoom handles GET /api/internal/bossraid/room +// Query param: sessionName +func (h *Handler) GetRoom(c *fiber.Ctx) error { + sessionName := c.Query("sessionName") + if sessionName == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "sessionName은 필수입니다"}) + } + + room, err := h.svc.GetRoom(sessionName) + if err != nil { + return bossError(c, fiber.StatusNotFound, "방을 찾을 수 없습니다", err) + } + + return c.JSON(room) +} diff --git a/internal/bossraid/model.go b/internal/bossraid/model.go new file mode 100644 index 0000000..263ce55 --- /dev/null +++ b/internal/bossraid/model.go @@ -0,0 +1,31 @@ +package bossraid + +import ( + "time" + + "gorm.io/gorm" +) + +type RoomStatus string + +const ( + StatusWaiting RoomStatus = "waiting" + StatusInProgress RoomStatus = "in_progress" + StatusCompleted RoomStatus = "completed" + StatusFailed RoomStatus = "failed" +) + +// BossRoom represents a boss raid session room. +type BossRoom struct { + ID uint `json:"id" gorm:"primaryKey"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + SessionName string `json:"sessionName" gorm:"type:varchar(100);uniqueIndex;not null"` + BossID int `json:"bossId" gorm:"index;not null"` + Status RoomStatus `json:"status" gorm:"type:varchar(20);index;default:waiting;not null"` + MaxPlayers int `json:"maxPlayers" gorm:"default:3;not null"` + Players string `json:"players" gorm:"type:text"` // JSON array of usernames + StartedAt *time.Time `json:"startedAt,omitempty"` + CompletedAt *time.Time `json:"completedAt,omitempty"` +} diff --git a/internal/bossraid/repository.go b/internal/bossraid/repository.go new file mode 100644 index 0000000..3b788f7 --- /dev/null +++ b/internal/bossraid/repository.go @@ -0,0 +1,39 @@ +package bossraid + +import "gorm.io/gorm" + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Create(room *BossRoom) error { + return r.db.Create(room).Error +} + +func (r *Repository) Update(room *BossRoom) error { + return r.db.Save(room).Error +} + +func (r *Repository) FindBySessionName(sessionName string) (*BossRoom, error) { + var room BossRoom + if err := r.db.Where("session_name = ?", sessionName).First(&room).Error; err != nil { + return nil, err + } + return &room, nil +} + +// CountActiveByUsername checks if a player is already in an active boss raid. +func (r *Repository) CountActiveByUsername(username string) (int64, error) { + var count int64 + search := `"` + username + `"` + err := r.db.Model(&BossRoom{}). + Where("status IN ? AND players LIKE ?", + []RoomStatus{StatusWaiting, StatusInProgress}, + "%"+search+"%", + ).Count(&count).Error + return count, err +} diff --git a/internal/bossraid/service.go b/internal/bossraid/service.go new file mode 100644 index 0000000..25a7f94 --- /dev/null +++ b/internal/bossraid/service.go @@ -0,0 +1,178 @@ +package bossraid + +import ( + "encoding/json" + "fmt" + "log" + "time" + + "github.com/tolelom/tolchain/core" +) + +type Service struct { + repo *Repository + rewardGrant func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +// 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 +} + +// RequestEntry creates a new boss room for a party. +// 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명까지 입장할 수 있습니다") + } + + // Check if any player is already in an active room + for _, username := range usernames { + count, err := s.repo.CountActiveByUsername(username) + if err != nil { + return nil, fmt.Errorf("플레이어 상태 확인 실패: %w", err) + } + if count > 0 { + return nil, fmt.Errorf("플레이어 %s가 이미 보스 레이드 중입니다", username) + } + } + + playersJSON, err := json.Marshal(usernames) + if err != nil { + return nil, fmt.Errorf("플레이어 목록 직렬화 실패: %w", err) + } + + sessionName := fmt.Sprintf("BossRaid_%d_%d", bossID, time.Now().UnixNano()) + + room := &BossRoom{ + SessionName: sessionName, + BossID: bossID, + Status: StatusWaiting, + MaxPlayers: 3, + Players: string(playersJSON), + } + + if err := s.repo.Create(room); err != nil { + return nil, fmt.Errorf("방 생성 실패: %w", err) + } + + return room, nil +} + +// StartRaid marks a room as in_progress. +func (s *Service) StartRaid(sessionName string) (*BossRoom, error) { + room, err := s.repo.FindBySessionName(sessionName) + if err != nil { + return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err) + } + if room.Status != StatusWaiting { + return nil, fmt.Errorf("시작할 수 없는 상태입니다: %s", room.Status) + } + + now := time.Now() + room.Status = StatusInProgress + room.StartedAt = &now + + if err := s.repo.Update(room); 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"` +} + +// 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. +func (s *Service) CompleteRaid(sessionName string, rewards []PlayerReward) (*BossRoom, []RewardResult, error) { + room, err := s.repo.FindBySessionName(sessionName) + if err != nil { + return nil, nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err) + } + if room.Status != StatusInProgress { + return nil, nil, 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 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) + } + } + + // Mark room completed + now := time.Now() + room.Status = StatusCompleted + room.CompletedAt = &now + if err := s.repo.Update(room); err != nil { + return nil, nil, fmt.Errorf("상태 업데이트 실패: %w", err) + } + + // Grant rewards + results := 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) + } + results = append(results, result) + } + } + + return room, results, nil +} + +// FailRaid marks a room as failed. +func (s *Service) FailRaid(sessionName string) (*BossRoom, error) { + room, err := s.repo.FindBySessionName(sessionName) + if err != nil { + return nil, fmt.Errorf("방을 찾을 수 없습니다: %w", err) + } + if room.Status != StatusWaiting && room.Status != StatusInProgress { + return nil, fmt.Errorf("실패 처리할 수 없는 상태입니다: %s", room.Status) + } + + now := time.Now() + room.Status = StatusFailed + room.CompletedAt = &now + + if err := s.repo.Update(room); err != nil { + return nil, fmt.Errorf("상태 업데이트 실패: %w", err) + } + + return room, nil +} + +// GetRoom returns a room by session name. +func (s *Service) GetRoom(sessionName string) (*BossRoom, error) { + return s.repo.FindBySessionName(sessionName) +} diff --git a/main.go b/main.go index b058436..1522365 100644 --- a/main.go +++ b/main.go @@ -5,8 +5,11 @@ import ( "a301_server/internal/announcement" "a301_server/internal/auth" + "a301_server/internal/bossraid" "a301_server/internal/chain" "a301_server/internal/download" + + "github.com/tolelom/tolchain/core" "a301_server/pkg/config" "a301_server/pkg/database" "a301_server/routes" @@ -27,7 +30,7 @@ func main() { log.Println("MySQL 연결 성공") // AutoMigrate - database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}) + database.DB.AutoMigrate(&auth.User{}, &announcement.Announcement{}, &download.Info{}, &chain.UserWallet{}, &bossraid.BossRoom{}) if err := database.ConnectRedis(); err != nil { log.Fatalf("Redis 연결 실패: %v", err) @@ -70,6 +73,15 @@ func main() { return err }) + // Boss Raid + brRepo := bossraid.NewRepository(database.DB) + brSvc := bossraid.NewService(brRepo) + brSvc.SetRewardGranter(func(username string, tokenAmount uint64, assets []core.MintAssetPayload) error { + _, err := chainSvc.GrantRewardByUsername(username, tokenAmount, assets) + return err + }) + brHandler := bossraid.NewHandler(brSvc) + if config.C.InternalAPIKey == "" { log.Println("WARN: INTERNAL_API_KEY not set — /api/internal/* endpoints are disabled") } @@ -117,7 +129,7 @@ func main() { }, }) - routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, authLimiter, apiLimiter) + routes.Register(app, authHandler, annHandler, dlHandler, chainHandler, brHandler, authLimiter, apiLimiter) log.Fatal(app.Listen(":" + config.C.AppPort)) } diff --git a/routes/routes.go b/routes/routes.go index e153c4b..77c9be2 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -3,6 +3,7 @@ package routes import ( "a301_server/internal/announcement" "a301_server/internal/auth" + "a301_server/internal/bossraid" "a301_server/internal/chain" "a301_server/internal/download" "a301_server/pkg/middleware" @@ -15,6 +16,7 @@ func Register( annH *announcement.Handler, dlH *download.Handler, chainH *chain.Handler, + brH *bossraid.Handler, authLimiter fiber.Handler, apiLimiter fiber.Handler, ) { @@ -76,6 +78,14 @@ func Register( chainAdmin.Post("/reward", middleware.Idempotency, chainH.GrantReward) chainAdmin.Post("/template", middleware.Idempotency, chainH.RegisterTemplate) + // Internal - Boss Raid (API key auth) + br := api.Group("/internal/bossraid", middleware.ServerAuth) + br.Post("/entry", brH.RequestEntry) + br.Post("/start", brH.StartRaid) + br.Post("/complete", middleware.Idempotency, brH.CompleteRaid) + br.Post("/fail", brH.FailRaid) + br.Get("/room", brH.GetRoom) + // Internal - Game server endpoints (API key auth, username-based, idempotency-protected) internal := api.Group("/internal/chain", middleware.ServerAuth) internal.Post("/reward", middleware.Idempotency, chainH.InternalGrantReward)