package modules import ( "context" "database/sql" "encoding/json" "errors" "math/rand" "time" "github.com/heroiclabs/nakama-common/runtime" "github.com/jackc/pgx/v5" "github.com/honghuang-game/server/internal/battle" hhdb "github.com/honghuang-game/server/internal/db" ) // RegisterBattle 注册战斗相关 RPC。 func RegisterBattle(initializer runtime.Initializer) error { if err := initializer.RegisterRpc("BattleService/StartCombat", startCombat); err != nil { return err } if err := initializer.RegisterRpc("BattleService/GetBattleReport", getBattleReport); err != nil { return err } if err := initializer.RegisterRpc("BattleService/PvpChallenge", pvpChallenge); err != nil { return err } return nil } type startCombatReq struct { BattleType string `json:"battle_type"` ContextID string `json:"context_id"` PartyMembers []string `json:"party_members"` PreferredSkills []string `json:"preferred_skills"` } type pvpChallengeReq struct { TargetCharacterID string `json:"target_character_id"` BountyID string `json:"bounty_id"` UsePaidChance bool `json:"use_paid_chance"` } type battleReportReq struct { BattleID string `json:"battle_id"` } type combatData struct { BattleID string `json:"battle_id"` Status string `json:"status"` ResultSummary interface{} `json:"result_summary"` Drops interface{} `json:"drops"` DeathPenaltyApplied bool `json:"death_penalty_applied"` HonorPoints int32 `json:"honor_points"` DailyPvpCount int32 `json:"daily_pvp_count"` DailyPvpLimit int32 `json:"daily_pvp_limit"` } type battleReportData struct { BattleID string `json:"battle_id"` Type string `json:"type"` RealmTier int32 `json:"realm_tier"` GameTimestamp string `json:"game_timestamp"` Attacker interface{} `json:"attacker"` Defender interface{} `json:"defender"` Rounds interface{} `json:"rounds"` Result interface{} `json:"result"` SpecialEvents interface{} `json:"special_events"` Drops interface{} `json:"drops"` } const ( battleTypeExpedition = "expedition_pve" battleTypeDungeon = "dungeon_pve" battleTypeRuin = "ruin_pve" ) // startCombat 触发一场 PVE 战斗,服务端完整计算并返回战报摘要。 func startCombat(ctx context.Context, logger runtime.Logger, _ *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() uid := userIDFromCtx(ctx) if uid == "" { return errResp(1001, "missing token", traceID) } var req startCombatReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(4001, "invalid payload", traceID) } if req.BattleType != battleTypeExpedition && req.BattleType != battleTypeDungeon && req.BattleType != battleTypeRuin { return errResp(4001, "invalid battle type", traceID) } if req.ContextID == "" { return errResp(4002, "missing context_id", traceID) } // 取当前玩家激活角色(若多角色则取最近创建) char, err := loadCharacterForBattle(ctx, uid) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return errResp(4002, "character not found", traceID) } logger.Error("load character failed: %v", err) return errResp(9002, "internal error", traceID) } skills, err := loadCharacterSkills(ctx, char.ID) if err != nil { logger.Error("load skills failed: %v", err) return errResp(9002, "internal error", traceID) } player := battle.NewFighterFromJSON( char.ID, char.Name, char.RaceID, "attacker", int(char.WorldTier), int(char.RealmTier), char.BaseStats, "neutral", "none", true, ) for _, s := range skills { player.AddSkill(s) } monster := battle.GenerateMonster(int(char.RealmTier), int(char.WorldTier), player, rand.New(rand.NewSource(time.Now().UnixNano()))) rng := rand.New(rand.NewSource(time.Now().UnixNano())) eng := battle.NewEngine(player, monster, rng, req.BattleType, int(char.RealmTier)) report := eng.Run() battleID, err := persistBattle(ctx, char.ID, req.BattleType, char.WorldTier, char.RealmTier, report) if err != nil { logger.Error("persist battle failed: %v", err) return errResp(9002, "internal error", traceID) } report.BattleID = battleID if err := applyBattleRewards(ctx, char.ID, char.WorldTier, char.RealmTier, report); err != nil { logger.Error("apply battle rewards failed: %v", err) return errResp(9002, "internal error", traceID) } return okResp(combatData{ BattleID: battleID, Status: "completed", ResultSummary: report.Result, Drops: report.Drops, DeathPenaltyApplied: false, HonorPoints: 0, }, traceID) } // getBattleReport 查询完整文字战报。 func getBattleReport(ctx context.Context, logger runtime.Logger, _ *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() uid := userIDFromCtx(ctx) if uid == "" { return errResp(1001, "missing token", traceID) } var req battleReportReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(4005, "invalid payload", traceID) } var raw []byte err := hhdb.Pool.QueryRow(ctx, ` SELECT bl.report FROM battle_logs bl JOIN battles b ON bl.battle_id = b.id WHERE bl.battle_id = $1 AND (b.attacker_id = (SELECT id FROM characters WHERE player_id = $2 LIMIT 1) OR b.defender_id = (SELECT id FROM characters WHERE player_id = $2 LIMIT 1)) `, req.BattleID, uid).Scan(&raw) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return errResp(4005, "battle report not found", traceID) } logger.Error("get battle report failed: %v", err) return errResp(9002, "internal error", traceID) } var data interface{} _ = json.Unmarshal(raw, &data) return okResp(data, traceID) } // pvpChallenge 向同 realm_tier 玩家发起战书(当前版本保留桩,待 PVP 完整规则接入)。 func pvpChallenge(ctx context.Context, logger runtime.Logger, _ *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() uid := userIDFromCtx(ctx) if uid == "" { return errResp(1001, "missing token", traceID) } var req pvpChallengeReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(4007, "invalid payload", traceID) } // 获取挑战者信息 attacker, err := loadCharacterForBattle(ctx, uid) if err != nil { return errResp(4002, "character not found", traceID) } // 查询目标玩家 var defender characterBattleInfo err = hhdbPool.QueryRow(ctx, ` SELECT id, name, race_id, world_tier, realm_tier, minor_realm, base_stats FROM characters WHERE id = $1 AND status = 'active' `, req.TargetCharacterID).Scan(&defender.ID, &defender.Name, &defender.RaceID, &defender.WorldTier, &defender.RealmTier, &defender.MinorRealm, &defender.BaseStats) if err != nil { return errResp(4008, "target not found", traceID) } // 检查同 realm_tier if attacker.RealmTier != defender.RealmTier { return errResp(4009, "realm tier mismatch", traceID) } // 检查每日次数 var dailyCount int32 err = hhdbPool.QueryRow(ctx, ` SELECT COUNT(*) FROM battles WHERE attacker_id = $1 AND battle_type = 'pvp' AND created_at >= CURRENT_DATE `, attacker.ID).Scan(&dailyCount) if err != nil { dailyCount = 0 } if dailyCount >= 20 { return errResp(4010, "daily pvp limit reached", traceID) } // 加载技能 skills, err := loadCharacterSkills(ctx, attacker.ID) if err != nil { skills = []*battle.SkillInstance{} } // 创建战斗实体 player := battle.NewFighterFromJSON( attacker.ID, attacker.Name, attacker.RaceID, "attacker", int(attacker.WorldTier), int(attacker.RealmTier), attacker.BaseStats, "neutral", "none", true, ) for _, s := range skills { player.AddSkill(s) } // 创建怪物作为PVP对手(简化版) monster := battle.NewFighterFromJSON( defender.ID, defender.Name, defender.RaceID, "defender", int(defender.WorldTier), int(defender.RealmTier), defender.BaseStats, "neutral", "none", false, ) // 运行战斗 rng := rand.New(rand.NewSource(time.Now().UnixNano())) eng := battle.NewEngine(player, monster, rng, "pvp", int(attacker.RealmTier)) report := eng.Run() // 持久化战斗记录 battleID, err := persistBattle(ctx, attacker.ID, "pvp", attacker.WorldTier, attacker.RealmTier, report) if err != nil { logger.Error("persist pvp battle failed: %v", err) return errResp(9002, "internal error", traceID) } report.BattleID = battleID // 应用战斗奖励 if err := applyBattleRewards(ctx, attacker.ID, attacker.WorldTier, attacker.RealmTier, report); err != nil { logger.Error("apply pvp rewards failed: %v", err) } logger.Info("BattleService/PvpChallenge success: battle_id=%s winner=%s", battleID, report.Result.Winner) return okResp(combatData{ BattleID: battleID, Status: "completed", ResultSummary: report.Result, Drops: report.Drops, DailyPvpCount: dailyCount + 1, DailyPvpLimit: 20, }, traceID) } // --------------------------------------------------------------------------- // 数据加载与持久化 // --------------------------------------------------------------------------- type characterBattleInfo struct { ID string Name string RaceID string WorldTier int32 RealmTier int32 MinorRealm int32 BaseStats []byte } func loadCharacterForBattle(ctx context.Context, playerID string) (*characterBattleInfo, error) { var c characterBattleInfo err := hhdb.Pool.QueryRow(ctx, ` SELECT id, name, race_id, world_tier, realm_tier, minor_realm, base_stats FROM characters WHERE player_id = $1 AND status = 'active' ORDER BY created_at DESC LIMIT 1 `, playerID).Scan(&c.ID, &c.Name, &c.RaceID, &c.WorldTier, &c.RealmTier, &c.MinorRealm, &c.BaseStats) if err != nil { return nil, err } return &c, nil } type skillTemplateData struct { BaseCoef float64 `json:"base_coef"` CD int `json:"cd"` Cost float64 `json:"cost"` TriggerRate float64 `json:"trigger_rate"` TargetType string `json:"target_type"` } func loadCharacterSkills(ctx context.Context, characterID string) ([]*battle.SkillInstance, error) { rows, err := hhdb.Pool.Query(ctx, ` SELECT cs.id, cs.custom_name, cs.instance_data, s.id, s.name, s.element, s.alignment, s.damage_type, s.scaling_attr, s.base_template FROM character_skills cs JOIN skills s ON cs.skill_id = s.id WHERE cs.character_id = $1 `, characterID) if err != nil { return nil, err } defer rows.Close() var skills []*battle.SkillInstance for rows.Next() { var instanceID, customName, instanceDataRaw, skillID, name, element, alignment, damageType, scalingAttr, baseTemplateRaw string if err := rows.Scan(&instanceID, &customName, &instanceDataRaw, &skillID, &name, &element, &alignment, &damageType, &scalingAttr, &baseTemplateRaw); err != nil { return nil, err } baseTmpl := parseSkillTemplate(baseTemplateRaw) instTmpl := parseSkillTemplate(instanceDataRaw) // 实例数据覆盖模板默认值 skill := &battle.SkillInstance{ InstanceID: instanceID, SkillID: skillID, Name: firstNonEmpty(customName, name), DamageType: battle.DamageType(damageType), Element: element, Alignment: alignment, ScalingAttr: scalingAttr, BaseCoef: coalesceFloat(instTmpl.BaseCoef, baseTmpl.BaseCoef, 1.8), CD: coalesceInt(instTmpl.CD, baseTmpl.CD, 225), Cost: coalesceFloat(instTmpl.Cost, baseTmpl.Cost, 20), TriggerRate: coalesceFloat(instTmpl.TriggerRate, baseTmpl.TriggerRate, 1.25), TargetType: battle.TargetSingle, } if instTmpl.TargetType != "" { skill.TargetType = battle.TargetType(instTmpl.TargetType) } else if baseTmpl.TargetType != "" { skill.TargetType = battle.TargetType(baseTmpl.TargetType) } if skill.DamageType == "" { skill.DamageType = battle.DamageTypePhysical } skills = append(skills, skill) } if err := rows.Err(); err != nil { return nil, err } // 没有任何技能时补一个默认小技能,确保战斗不至于只有普攻 if len(skills) == 0 { skills = append(skills, &battle.SkillInstance{ InstanceID: "default_small", SkillID: "default_small", Name: "基础战技", DamageType: battle.DamageTypePhysical, Element: "none", Alignment: "neutral", ScalingAttr: "str", BaseCoef: 1.8, CD: 225, Cost: 20, TriggerRate: 1.25, TargetType: battle.TargetSingle, }) } return skills, nil } func parseSkillTemplate(raw string) skillTemplateData { var t skillTemplateData if raw == "" || raw == "{}" { return t } _ = json.Unmarshal([]byte(raw), &t) return t } func firstNonEmpty(a, b string) string { if a != "" { return a } return b } func coalesceFloat(values ...float64) float64 { for _, v := range values { if v > 0 { return v } } return 0 } func coalesceInt(values ...int) int { for _, v := range values { if v > 0 { return v } } return 0 } // persistBattle 将战报写入 battles / battle_logs 表 func persistBattle(ctx context.Context, characterID, battleType string, worldTier, realmTier int32, report *battle.BattleReport) (string, error) { reportJSON, err := report.MarshalReport() if err != nil { return "", err } tx, err := hhdb.Pool.Begin(ctx) if err != nil { return "", err } defer tx.Rollback(ctx) var battleID string err = tx.QueryRow(ctx, ` INSERT INTO battles (battle_type, world_tier, realm_tier, game_timestamp, attacker_id, defender_id, party_a, party_b, status, result_summary) VALUES ($1, $2, $3, NOW(), $4, NULL, '{}', '{}', 'completed', $5) RETURNING id::text `, battleType, worldTier, realmTier, characterID, report.Result).Scan(&battleID) if err != nil { return "", err } _, err = tx.Exec(ctx, ` INSERT INTO battle_logs (battle_id, report, special_events, drops) VALUES ($1, $2, $3, $4) `, battleID, reportJSON, report.SpecialEvents, report.Drops) if err != nil { return "", err } if err := tx.Commit(ctx); err != nil { return "", err } return battleID, nil } // applyBattleRewards 发放战斗奖励:货币、经验、审计日志 func applyBattleRewards(ctx context.Context, characterID string, worldTier, realmTier int32, report *battle.BattleReport) error { if report.Result.Winner != "attacker" || len(report.Drops.Currency) == 0 { return nil } tx, err := hhdb.Pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) for _, c := range report.Drops.Currency { if c.Amount <= 0 { continue } _, err = tx.Exec(ctx, ` INSERT INTO currency_balances (character_id, currency_code, amount, total_earned, total_spent) VALUES ($1, $2, $3, $3, 0) ON CONFLICT (character_id, currency_code) DO UPDATE SET amount = currency_balances.amount + EXCLUDED.amount, total_earned = currency_balances.total_earned + EXCLUDED.amount, updated_at = NOW() `, characterID, c.Type, c.Amount) if err != nil { return err } _, err = tx.Exec(ctx, ` INSERT INTO economy_audit_logs (character_id, entity_type, entity_id, currency_code, flow_type, reason_code, amount, related_id, world_tier) VALUES ($1, 'character', $1, $2, 'faucet', 'combat_drop', $3, $4, $5) `, characterID, c.Type, c.Amount, report.BattleID, worldTier) if err != nil { return err } } if report.Drops.Exp > 0 { _, err = tx.Exec(ctx, ` UPDATE characters SET exp = exp + $1, updated_at = NOW() WHERE id = $2 `, report.Drops.Exp, characterID) if err != nil { return err } } // 材料掉落仅记录到战报 drops,实际进背包需要物品模板存在,当前版本保留扩展点 return tx.Commit(ctx) }