lawless/server/modules/mercenary.go

493 行
14 KiB
Go

// Package modules - 佣兵大厅系统模块
// 对齐GDD-13 佣兵大厅与悬赏系统
package modules
import (
"context"
"database/sql"
"encoding/json"
"math/rand"
"time"
"github.com/heroiclabs/nakama-common/runtime"
"github.com/jackc/pgx/v5"
)
// RegisterMercenary 注册佣兵相关 RPC。
func RegisterMercenary(initializer runtime.Initializer) error {
rpcs := map[string]func(runtime.Initializer) 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)
}