// Package modules - 佣兵大厅系统模块 // 对齐GDD-13 佣兵大厅与悬赏系统 package modules import ( "context" "database/sql" "encoding/json" "time" "github.com/heroiclabs/nakama-common/runtime" ) // RegisterMercenary 注册佣兵相关 RPC。 func RegisterMercenary(initializer runtime.Initializer) error { rpcs := map[string]func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error){ "MercenaryService/GetContracts": getContracts, "MercenaryService/AcceptContract": acceptContract, "MercenaryService/CompleteContract": completeContract, "MercenaryService/PostBounty": postBounty, "MercenaryService/GetBountyInfo": getBountyInfo, "MercenaryService/GetCreditScore": getCreditScore, } for path, fn := range rpcs { if err := initializer.RegisterRpc(path, fn); err != nil { return err } } return nil } type getContractsReq struct { ContractType string `json:"contract_type"` // mercenary/limited_time/bounty Difficulty int32 `json:"difficulty"` // 1-6星 Page int32 `json:"page"` PageSize int32 `json:"page_size"` } type acceptContractReq struct { ContractID string `json:"contract_id"` PartyMembers []string `json:"party_members"` // 组队成员ID(可选) UseDisciples bool `json:"use_disciples"` // 是否使用弟子代派 } type completeContractReq struct { ContractID string `json:"contract_id"` } type postBountyReq struct { BountyType string `json:"bounty_type"` // official_bounty/private_bounty TargetID string `json:"target_id"` // 目标玩家ID RewardAmount float64 `json:"reward_amount"` // 赏金金额 Duration int32 `json:"duration"` // 有效天数 } type getBountyInfoReq struct { BountyID string `json:"bounty_id"` } type getCreditScoreReq struct { CharacterID string `json:"character_id"` } type contractData struct { ID string `json:"id"` ContractType string `json:"contract_type"` Difficulty int32 `json:"difficulty"` RewardCurrency string `json:"reward_currency"` BaseReward float64 `json:"base_reward"` MaxParticipants int32 `json:"max_participants"` Status string `json:"status"` ValidUntil time.Time `json:"valid_until"` Description interface{} `json:"description"` } type bountyData struct { ContractID string `json:"contract_id"` BountyType string `json:"bounty_type"` TargetID string `json:"target_id"` TargetName string `json:"target_name"` TargetWorldTier int32 `json:"target_world_tier"` RewardAmount float64 `json:"reward_amount"` Status string `json:"status"` Clues interface{} `json:"clues"` } type creditScoreData struct { CharacterID string `json:"character_id"` Score int32 `json:"score"` // 0-1000 Grade string `json:"grade"` // S/A/B/C/D AcceptLimit int32 `json:"accept_limit"` // 每日接单上限 BannedUntil *time.Time `json:"banned_until"` } // 信用评级配置 var creditGradeConfig = map[string]struct { MinScore int32 MaxScore int32 AcceptLimit int32 BanDays int32 }{ "S": {900, 1000, 7, 0}, "A": {700, 899, 5, 0}, "B": {500, 699, 3, 0}, "C": {300, 499, 2, 0}, "D": {0, 299, 0, 14}, // D级禁止接单14天 } // 星级难度配置 var difficultyConfig = map[int32]struct { MinRealm int32 BaseReward float64 Currency string Description string }{ 1: {1, 100, "copper", "采集边境灵草"}, 2: {2, 500, "copper", "讨伐野外妖兽群"}, 3: {2, 2000, "copper", "击杀精英BOSS"}, 4: {3, 30, "spirit_stone_low", "深入腹地探索遗迹"}, 5: {4, 150, "spirit_stone_mid", "秘境寻宝"}, 6: {5, 800, "soul_crystal", "混沌之渊探索"}, } func getContracts(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() uid := userIDFromCtx(ctx) if uid == "" { return errResp(1001, "missing token", traceID) } var req getContractsReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(2001, "invalid payload", traceID) } if req.Page < 1 { req.Page = 1 } if req.PageSize < 1 || req.PageSize > 50 { req.PageSize = 20 } offset := (req.Page - 1) * req.PageSize // 查询可用委托 query := ` SELECT c.id, c.contract_type, c.difficulty, c.currency_code, c.base_reward, c.max_participants, c.status, c.valid_until FROM contracts c WHERE c.status = 'active' AND c.valid_until > NOW() ` args := []interface{}{} if req.ContractType != "" { query += " AND c.contract_type = $1" args = append(args, req.ContractType) } if req.Difficulty > 0 { query += " AND c.difficulty = $2" args = append(args, req.Difficulty) } query += " ORDER BY c.difficulty ASC, c.created_at DESC LIMIT $3 OFFSET $4" args = append(args, req.PageSize, offset) rows, err := hhdbPool.Query(ctx, query, args...) if err != nil { logger.Error("get contracts failed: %v", err) return errResp(9002, "internal error", traceID) } defer rows.Close() var contracts []contractData for rows.Next() { var c contractData if err := rows.Scan(&c.ID, &c.ContractType, &c.Difficulty, &c.RewardCurrency, &c.BaseReward, &c.MaxParticipants, &c.Status, &c.ValidUntil); err != nil { continue } contracts = append(contracts, c) } return okResp(map[string]interface{}{ "contracts": contracts, "count": len(contracts), "page": req.Page, "page_size": req.PageSize, }, traceID) } func acceptContract(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() uid := userIDFromCtx(ctx) if uid == "" { return errResp(1001, "missing token", traceID) } var req acceptContractReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(2001, "invalid payload", traceID) } // 获取角色ID var charID string err := hhdbPool.QueryRow(ctx, ` SELECT id FROM characters WHERE player_id = $1 AND status = 'active' ORDER BY created_at DESC LIMIT 1 `, uid).Scan(&charID) if err != nil { return errResp(4002, "character not found", traceID) } // 检查信用评级 var grade string var score int32 err = hhdbPool.QueryRow(ctx, ` SELECT COALESCE(mercenary_score, 0) FROM characters WHERE id = $1 `, charID).Scan(&score) if err != nil { score = 500 } grade = "B" for g, config := range creditGradeConfig { if score >= config.MinScore && score <= config.MaxScore { grade = g break } } if grade == "D" { return errResp(8101, "credit score too low, banned from accepting contracts", traceID) } // 检查每日接单限制 var todayAccepted int32 err = hhdbPool.QueryRow(ctx, ` SELECT COUNT(*) FROM contract_participants WHERE character_id = $1 AND accepted_at >= CURRENT_DATE `, charID).Scan(&todayAccepted) if err != nil { todayAccepted = 0 } acceptLimit := creditGradeConfig[grade].AcceptLimit if todayAccepted >= acceptLimit { return errResp(8102, "daily accept limit reached", traceID) } // 接受委托 _, err = hhdbPool.Exec(ctx, ` INSERT INTO contract_participants (contract_id, character_id, participant_type, status, accepted_at) VALUES ($1, $2, 'hunter', 'accepted', NOW()) ON CONFLICT DO NOTHING `, req.ContractID, charID) if err != nil { logger.Error("accept contract failed: %v", err) return errResp(9002, "internal error", traceID) } // 更新委托状态 _, err = hhdbPool.Exec(ctx, ` UPDATE contracts SET status = 'accepted' WHERE id = $1 AND status = 'active' `, req.ContractID) if err != nil { logger.Error("update contract status failed: %v", err) } return okResp(map[string]interface{}{ "success": true, "contract_id": req.ContractID, "credit_grade": grade, "message": "委托接受成功", }, traceID) } func completeContract(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() uid := userIDFromCtx(ctx) if uid == "" { return errResp(1001, "missing token", traceID) } var req completeContractReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(2001, "invalid payload", traceID) } // 获取角色ID var charID string err := hhdbPool.QueryRow(ctx, ` SELECT id FROM characters WHERE player_id = $1 AND status = 'active' ORDER BY created_at DESC LIMIT 1 `, uid).Scan(&charID) if err != nil { return errResp(4002, "character not found", traceID) } // 检查委托是否被接受 var participantStatus string err = hhdbPool.QueryRow(ctx, ` SELECT status FROM contract_participants WHERE contract_id = $1 AND character_id = $2 `, req.ContractID, charID).Scan(&participantStatus) if err != nil { return errResp(8103, "not accepted this contract", traceID) } if participantStatus != "accepted" { return errResp(8104, "contract already completed or failed", traceID) } // 完成委托 _, err = hhdbPool.Exec(ctx, ` UPDATE contract_participants SET status = 'completed', completed_at = NOW() WHERE contract_id = $1 AND character_id = $2 `, req.ContractID, charID) if err != nil { logger.Error("complete contract failed: %v", err) return errResp(9002, "internal error", traceID) } // 发放奖励(简化版,实际应查询委托详情) reward := 100.0 _, err = hhdbPool.Exec(ctx, ` UPDATE characters SET mercenary_score = mercenary_score + 10, updated_at = NOW() WHERE id = $1 `, charID) if err != nil { logger.Error("update mercenary score failed: %v", err) } // 更新委托状态 _, err = hhdbPool.Exec(ctx, ` UPDATE contracts SET status = 'completed', completed_at = NOW() WHERE id = $1 `, req.ContractID) if err != nil { logger.Error("update contract status failed: %v", err) } return okResp(map[string]interface{}{ "success": true, "contract_id": req.ContractID, "reward": reward, "score_gain": 10, "message": "委托完成,奖励已发放", }, traceID) } func postBounty(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() uid := userIDFromCtx(ctx) if uid == "" { return errResp(1001, "missing token", traceID) } var req postBountyReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(2001, "invalid payload", traceID) } // 获取发布者角色ID var publisherID string err := hhdbPool.QueryRow(ctx, ` SELECT id FROM characters WHERE player_id = $1 AND status = 'active' ORDER BY created_at DESC LIMIT 1 `, uid).Scan(&publisherID) if err != nil { return errResp(4002, "character not found", traceID) } // 检查目标玩家 var targetName string var targetWorldTier int32 err = hhdbPool.QueryRow(ctx, ` SELECT name, world_tier FROM characters WHERE id = $1 AND status = 'active' `, req.TargetID).Scan(&targetName, &targetWorldTier) if err != nil { return errResp(8105, "target not found", traceID) } // 检查赏金金额 if req.RewardAmount <= 0 { return errResp(8106, "invalid reward amount", traceID) } // 创建悬赏委托 contractID := genUUID() validUntil := time.Now().Add(time.Duration(req.Duration) * 24 * time.Hour) _, err = hhdbPool.Exec(ctx, ` INSERT INTO contracts (id, contract_type, publisher_id, difficulty, currency_code, base_reward, max_participants, status, valid_until) VALUES ($1, 'bounty', $2, 5, 'spirit_stone_low', $3, 1, 'active', $4) `, contractID, publisherID, req.RewardAmount, validUntil) if err != nil { logger.Error("post bounty contract failed: %v", err) return errResp(9002, "internal error", traceID) } // 创建悬赏记录 _, err = hhdbPool.Exec(ctx, ` INSERT INTO bounties (contract_id, bounty_type, target_id, target_world_tier, reward_amount, fee_amount, status) VALUES ($1, $2, $3, $4, $5, $6, 'active') `, contractID, req.BountyType, req.TargetID, targetWorldTier, req.RewardAmount, req.RewardAmount*0.1) // 10%手续费 if err != nil { logger.Error("post bounty record failed: %v", err) return errResp(9002, "internal error", traceID) } return okResp(map[string]interface{}{ "success": true, "bounty_id": contractID, "target_name": targetName, "reward": req.RewardAmount, "fee": req.RewardAmount * 0.1, "valid_until": validUntil, "message": "悬赏发布成功", }, traceID) } func getBountyInfo(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() var req getBountyInfoReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(2001, "invalid payload", traceID) } var bounty bountyData err := hhdbPool.QueryRow(ctx, ` SELECT b.contract_id, b.bounty_type, b.target_id, c.name, b.target_world_tier, b.reward_amount, b.status, b.clues FROM bounties b JOIN characters c ON b.target_id = c.id WHERE b.contract_id = $1 `, req.BountyID).Scan( &bounty.ContractID, &bounty.BountyType, &bounty.TargetID, &bounty.TargetName, &bounty.TargetWorldTier, &bounty.RewardAmount, &bounty.Status, &bounty.Clues, ) if err != nil { return errResp(8107, "bounty not found", traceID) } return okResp(bounty, traceID) } func getCreditScore(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() uid := userIDFromCtx(ctx) if uid == "" { return errResp(1001, "missing token", traceID) } var req getCreditScoreReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(2001, "invalid payload", traceID) } var score int32 err := hhdbPool.QueryRow(ctx, ` SELECT COALESCE(mercenary_score, 0) FROM characters WHERE id = $1 `, req.CharacterID).Scan(&score) if err != nil { score = 500 } grade := "B" acceptLimit := int32(3) for g, config := range creditGradeConfig { if score >= config.MinScore && score <= config.MaxScore { grade = g acceptLimit = config.AcceptLimit break } } return okResp(creditScoreData{ CharacterID: req.CharacterID, Score: score, Grade: grade, AcceptLimit: acceptLimit, }, traceID) }