DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+224
View File
@@ -0,0 +1,224 @@
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
}