lawless/server/modules/battle.go

515 行
15 KiB
Go

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

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)
}