服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
225 lines
7.8 KiB
Go
225 lines
7.8 KiB
Go
package pvp
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
|
|
"longheng.io/server/internal/modules/dnf/pvf"
|
|
)
|
|
|
|
const (
|
|
ExperienceKey = "pvp_experience"
|
|
RankPointKey = "pvp_rank_point"
|
|
MaxRankKey = "pvp_max_rank_point"
|
|
GradeKey = "pvp_grade"
|
|
)
|
|
|
|
type Weight struct{ Threshold, Value int64 }
|
|
type AdditionalWeight struct{ Threshold, Win, Loss int64 }
|
|
|
|
// RatingRules supplies the missing local server calculation. The PVF contains
|
|
// its parameters, not the original server's Elo rounding/placement algorithm.
|
|
type RatingRules struct {
|
|
GradePoints [][2]int64 // loss, win
|
|
MatchExperiencePercent [2]int64
|
|
ManualExperiencePercent [2]int64
|
|
InitialRating, InitialRP int64
|
|
WarmupGames, WarmupWeight int64
|
|
AfterWarmupBonus int64
|
|
Warmup, Rating, WinStreak, LossStreak []Weight
|
|
Additional []AdditionalWeight
|
|
}
|
|
|
|
func loadRatingRules(ref, matching *pvf.Document, grades int) (*RatingRules, error) {
|
|
return loadRatingRulesWithPoints(ref, matching, grades, "pvp grade point")
|
|
}
|
|
|
|
func loadRatingRulesWithPoints(ref, matching *pvf.Document, grades int, pointTag string) (*RatingRules, error) {
|
|
r := &RatingRules{}
|
|
points := ref.Ints(pointTag)
|
|
if len(points) != grades*3 {
|
|
return nil, fmt.Errorf("PVP grade points do not cover experience grades")
|
|
}
|
|
for i := 0; i < len(points); i += 3 {
|
|
if points[i] != int64(i/3) || points[i+1] <= 0 || points[i+1] > math.MaxInt32 || points[i+2] > 0 || points[i+2] < math.MinInt32 {
|
|
return nil, fmt.Errorf("invalid PVP grade point row")
|
|
}
|
|
r.GradePoints = append(r.GradePoints, [2]int64{points[i+2], points[i+1]})
|
|
}
|
|
for _, row := range []struct {
|
|
name string
|
|
dst *[2]int64
|
|
}{
|
|
{"pvp match exp set", &r.MatchExperiencePercent},
|
|
{"pvp manual match etc set", &r.ManualExperiencePercent},
|
|
} {
|
|
v := ref.Ints(row.name)
|
|
if len(v) != 6 || v[0] < 0 || v[0] > 10000 || v[3] < 0 || v[3] > 10000 {
|
|
return nil, fmt.Errorf("invalid PVP experience mode %s", row.name)
|
|
}
|
|
*row.dst = [2]int64{v[3], v[0]}
|
|
}
|
|
for _, row := range []struct {
|
|
name string
|
|
dst *int64
|
|
}{
|
|
{"initial rating point", &r.InitialRating}, {"pvp initial rating point", &r.InitialRP},
|
|
{"warm up games", &r.WarmupGames}, {"warm up default weight", &r.WarmupWeight},
|
|
{"first after warm up bonus", &r.AfterWarmupBonus},
|
|
} {
|
|
v := matching.Ints(row.name)
|
|
if len(v) != 1 || v[0] < 0 || v[0] > math.MaxInt32 {
|
|
return nil, fmt.Errorf("invalid PVP rating parameter %s", row.name)
|
|
}
|
|
*row.dst = v[0]
|
|
}
|
|
if r.WarmupWeight == 0 || r.WarmupWeight > 10000 || r.WarmupGames > 10000 || r.AfterWarmupBonus > 10000 {
|
|
return nil, fmt.Errorf("invalid PVP warmup bounds")
|
|
}
|
|
for _, row := range []struct {
|
|
name string
|
|
dst *[]Weight
|
|
}{
|
|
{"warm up weight", &r.Warmup}, {"rating", &r.Rating},
|
|
{"streak win", &r.WinStreak}, {"streak lose", &r.LossStreak},
|
|
} {
|
|
v := matching.Ints(row.name)
|
|
if len(v) == 0 || len(v)%2 != 0 {
|
|
return nil, fmt.Errorf("invalid PVP weight table %s", row.name)
|
|
}
|
|
for i := 0; i < len(v); i += 2 {
|
|
if v[i] < 0 || v[i] > math.MaxInt32 || v[i+1] <= 0 || v[i+1] > 10000 || i > 0 && v[i] >= v[i-2] {
|
|
return nil, fmt.Errorf("invalid PVP weight row %s", row.name)
|
|
}
|
|
*row.dst = append(*row.dst, Weight{v[i], v[i+1]})
|
|
}
|
|
}
|
|
if r.Rating[len(r.Rating)-1].Threshold != 0 {
|
|
return nil, fmt.Errorf("PVP rating weights have no zero floor")
|
|
}
|
|
// The shipped PVF repeats this opening tag in place of its closing tag.
|
|
// Document.Ints returns the first section's values without string rewriting.
|
|
v := matching.Ints("addtional weight")
|
|
if len(v) == 0 || len(v)%3 != 0 {
|
|
return nil, fmt.Errorf("invalid PVP additional weight table")
|
|
}
|
|
for i := 0; i < len(v); i += 3 {
|
|
if v[i] < 0 || v[i] > math.MaxInt32 || v[i+1] < -10000 || v[i+1] > 10000 || v[i+2] < -10000 || v[i+2] > 10000 || i > 0 && v[i] >= v[i-3] {
|
|
return nil, fmt.Errorf("invalid PVP additional weight row")
|
|
}
|
|
r.Additional = append(r.Additional, AdditionalWeight{v[i], v[i+1], v[i+2]})
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func weightAt(rows []Weight, value, fallback int64) int64 {
|
|
for _, row := range rows {
|
|
if value >= row.Threshold {
|
|
return row.Value
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func (c *Catalog) applyRating(stats map[string]int64, match Match, played int64) (int64, error) {
|
|
r := c.RatingRules
|
|
if r == nil {
|
|
return 0, fmt.Errorf("PVP rating rules missing")
|
|
}
|
|
for _, key := range []string{ExperienceKey, RankPointKey, MaxRankKey, GradeKey, "pvp_max_grade", "pvp_win_streak", "pvp_loss_streak"} {
|
|
if stats[key] < 0 || stats[key] > math.MaxInt32 {
|
|
return 0, fmt.Errorf("invalid PVP stat %s", key)
|
|
}
|
|
}
|
|
grade := stats[GradeKey]
|
|
if grade >= int64(len(r.GradePoints)) || match.OpponentRP < 0 || match.OpponentRP > math.MaxInt32 {
|
|
return 0, fmt.Errorf("invalid PVP grade/opponent rating")
|
|
}
|
|
beforeEXP, beforeRP := stats[ExperienceKey], stats[RankPointKey]
|
|
stats["pvp_result_before_exp"], stats["pvp_result_before_rp"], stats["pvp_result_before_grade"] = beforeEXP, beforeRP, grade
|
|
delta := int64(0)
|
|
if !match.Draw {
|
|
outcome := 0
|
|
if match.Won {
|
|
outcome = 1
|
|
}
|
|
// Local compatibility rule: first member of each mode tuple is a
|
|
// percentage of the PVF grade/outcome points; truncate toward zero.
|
|
percent := r.MatchExperiencePercent[outcome]
|
|
if match.Manual {
|
|
percent = r.ManualExperiencePercent[outcome]
|
|
}
|
|
delta = r.GradePoints[grade][outcome] * percent / 100
|
|
}
|
|
stats[ExperienceKey] = min(max(beforeEXP+delta, 0), int64(c.ExperienceBounds[len(c.ExperienceBounds)-1]))
|
|
stats[GradeKey] = int64(min(len(c.ExperienceBounds)-2, int(grade)))
|
|
for stats[GradeKey]+1 < int64(len(c.ExperienceBounds)-1) && stats[ExperienceKey] >= int64(c.ExperienceBounds[stats[GradeKey]+1]) {
|
|
stats[GradeKey]++
|
|
}
|
|
for stats[GradeKey] > 0 && stats[ExperienceKey] < int64(c.ExperienceBounds[stats[GradeKey]]) {
|
|
stats[GradeKey]--
|
|
}
|
|
stats["pvp_max_grade"] = max(stats["pvp_max_grade"], grade, stats[GradeKey])
|
|
stats["pvp_result_experience_delta"] = stats[ExperienceKey] - beforeEXP
|
|
if match.Manual {
|
|
return stats["pvp_result_experience_delta"], nil
|
|
}
|
|
winStreak, lossStreak := stats["pvp_win_streak"], stats["pvp_loss_streak"]
|
|
score := 0.5
|
|
switch {
|
|
case match.Draw:
|
|
winStreak, lossStreak = 0, 0
|
|
case match.Won:
|
|
winStreak, lossStreak, score = min(winStreak+1, math.MaxInt32), 0, 1
|
|
default:
|
|
winStreak, lossStreak, score = 0, min(lossStreak+1, math.MaxInt32), 0
|
|
}
|
|
stats["pvp_win_streak"], stats["pvp_loss_streak"] = winStreak, lossStreak
|
|
self, opponent := beforeRP, match.OpponentRP
|
|
// A zero RP from the old server is unseeded. First settlement seeds the
|
|
// PVF initial Elo; both players use their frozen pre-settlement RP.
|
|
if self == r.InitialRP && stats["pvp_rating_initialized"] == 0 {
|
|
self = r.InitialRating
|
|
}
|
|
if opponent == r.InitialRP && !match.OpponentRated {
|
|
opponent = r.InitialRating
|
|
}
|
|
k := weightAt(r.Rating, self, 0)
|
|
if played < r.WarmupGames {
|
|
k = weightAt(r.Warmup, played, r.WarmupWeight)
|
|
}
|
|
multiplier := int64(100)
|
|
if match.Won && !match.Draw {
|
|
multiplier = weightAt(r.WinStreak, winStreak, 100)
|
|
}
|
|
if !match.Won && !match.Draw {
|
|
multiplier = weightAt(r.LossStreak, lossStreak, 100)
|
|
}
|
|
expected := 1 / (1 + math.Pow(10, float64(opponent-self)/400))
|
|
ratingDelta := int64(math.Round(float64(k) * float64(multiplier) / 100 * (score - expected)))
|
|
if !match.Draw {
|
|
for _, row := range r.Additional {
|
|
if self >= row.Threshold {
|
|
if match.Won {
|
|
ratingDelta += row.Win
|
|
} else {
|
|
ratingDelta += row.Loss
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if match.Won && played == r.WarmupGames {
|
|
ratingDelta += r.AfterWarmupBonus
|
|
}
|
|
if match.Won {
|
|
ratingDelta = max(ratingDelta, 0)
|
|
} else {
|
|
ratingDelta = min(ratingDelta, 0)
|
|
}
|
|
}
|
|
stats[RankPointKey] = min(max(self+ratingDelta, 0), math.MaxInt32)
|
|
stats["pvp_rating_initialized"] = 1
|
|
stats[MaxRankKey] = max(stats[MaxRankKey], beforeRP, stats[RankPointKey])
|
|
return stats["pvp_result_experience_delta"], nil
|
|
}
|