一些检测仍在等待运行
Docs Build / build-and-deploy (push) Waiting to run
- 移除 ConfigManager 配置管理器类 - 移除 GameManager 全局单例管理器类 - 移除 NetworkManager 网络连接管理器类 - 移除 CharacterData 和 ItemData 数据模型类 - 移除 BagScene、BattleScene、LobbyScene 等场景脚本 - 移除 EncounterBubble 和 EventFeedPanel UI组件脚本 - 更新代理邀请文档中的服务器连接方式 - 更新同步状态表格中的代理任务分配信息 - 添加 MiMo 任务完成总结和审查修复记录
515 行
15 KiB
Go
515 行
15 KiB
Go
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 = hhdb.Pool.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 = hhdb.Pool.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)
|
||
}
|