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

@@ -0,0 +1,341 @@
package pvp
import (
"context"
"errors"
"fmt"
"math"
"reflect"
"testing"
"time"
repo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
)
type textSource map[string]string
func (s textSource) ReadText(p string) (string, error) {
if v, ok := s[p]; ok {
return v, nil
}
return "", fmt.Errorf("missing %s", p)
}
func dailyTestSource() textSource {
s := textSource{
"etc/missionsystem.etc": "[casual daily mission]\n0 113 10\n[/casual daily mission]\n[casual daily mission]\n1 118 10\n[/casual daily mission]\n[casual daily mission]\n2 131 10\n[/casual daily mission]\n",
"pvp_mission/mission.lst": "113 `daily/a.msn`\n118 `daily/b.msn`\n131 `daily/c.msn`\n",
"etc/pvp_ref.etc": "[pvp experience]\n1 12000 2 27000\n[/pvp experience]\n[pvp grade point]\n0 1000 0 1 1000 -429\n[/pvp grade point]\n[pvp match exp set]\n100 -1 -1 50 -1 50\n[/pvp match exp set]\n[pvp manual match etc set]\n45 -1 1 45 -1 0\n[/pvp manual match etc set]\n",
"etc/matchingmode.etc": "[initial rating point]\n1000\n[pvp initial rating point]\n0\n[warm up games]\n10\n[warm up default weight]\n120\n[warm up weight]\n0 100\n[/warm up weight]\n[rating]\n2000 8 1700 10 1500 14 1400 16 1100 20 700 22 0 24\n[/rating]\n[streak win]\n8 180 6 140 4 120\n[/streak win]\n[streak lose]\n6 200 4 140 2 120\n[/streak lose]\n[addtional weight]\n2000 0 -1 1700 0 0 800 0 0 500 0 1 0 0 2\n[addtional weight]\n[first after warm up bonus]\n40\n[/first after warm up bonus]\n",
}
for i, condition := range []string{"hereafter join", "first aerial attack", "winning streak"} {
s[fmt.Sprintf("pvp_mission/daily/%c.msn", 'a'+i)] = fmt.Sprintf("[grade] `[daily]` %d\n[rank range] 0 30\n[condition] `[%s]` `[all]` 3 1\n[/condition]\n[reward victory emblem] 40\n[/reward victory emblem]\n", i, condition)
}
return s
}
func testCatalog(t *testing.T) *Catalog {
t.Helper()
s := dailyTestSource()
c, err := Load(s)
if err != nil {
t.Fatal(err)
}
if len(c.Pools[0]) != 1 || c.Pools[2][0].ID != 131 || !reflect.DeepEqual(c.ExperienceBounds, []uint32{0, 12000, 27000}) {
t.Fatalf("catalog=%+v", c)
}
delete(s, "pvp_mission/daily/b.msn")
if _, err := Load(s); err == nil {
t.Fatal("missing mission was silently substituted")
}
return c
}
func TestNormalTargetMissionsPreservePVFAndMatchFacts(t *testing.T) {
s := dailyTestSource()
definitions := []struct {
id int
category, condition, tag string
target, parameter, pre int
}{
{499, "battle", "hereafter join", "all", 100, -1, 0},
{500, "battle", "hereafter join", "all", 333, -1, 499},
{522, "victory", "hereafter winning count", "all", 100, -1, 0},
{545, "rating", "rank", "all", 2, -1, 0},
{565, "training", "combo clear", "swordman", 1, 0, 0},
{566, "training", "combo clear", "fighter", 1, 0, 0},
{604, "training", "arcade mode", "all", -1, 1, 0},
}
for _, d := range definitions {
p := fmt.Sprintf("hidden/%d.msn", d.id)
s["pvp_mission/mission.lst"] += fmt.Sprintf("%d `%s`\n", d.id, p)
s["pvp_mission/"+p] = fmt.Sprintf("[grade] `[normal]`\n[normal category] `[%s]`\n[rank range] 0 30\n[condition] `[%s]` `[%s]` %d %d\n[/condition]\n", d.category, d.condition, d.tag, d.target, d.parameter)
if d.pre != 0 {
s["pvp_mission/"+p] += fmt.Sprintf("[pre required mission] %d\n", d.pre)
}
}
s["pvp_mission/mission.lst"] += "800 `hidden/800.msn`\n"
s["pvp_mission/hidden/800.msn"] = "[grade] `[total match normal]`\n"
c, err := Load(s)
if err != nil || len(c.Normal) != len(definitions) || c.Normal[1].PreRequired != 499 {
t.Fatalf("catalog=%+v err=%v", c, err)
}
repos := memory.NewMemoryGroup()
ctx := context.Background()
now := time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC)
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: "29", AccountID: "a", Job: "0", Stats: map[string]int64{CertificationKey: 98, WinsKey: 40}}); err != nil {
t.Fatal(err)
}
before, err := Prepare(ctx, repos, c, "a", "29", now)
want := []Row{{499, 98, 100}, {500, 98, 333}, {522, 40, 100}, {545, 0, 2}, {565, 0, 1}, {604, 0, math.MaxUint32}}
if err != nil || len(before.Rows) != 3 || !reflect.DeepEqual(before.NormalRows, want) {
t.Fatalf("before=%+v err=%v", before, err)
}
match := Match{Key: "target-progress", StartedAt: now, CompletedAt: now.Add(time.Minute), Won: true}
failed := repos
failed.CharacterSettlement = failCommit{repos.CharacterSettlement}
if _, err := Complete(ctx, failed, c, "a", "29", match); err == nil {
t.Fatal("commit failure ignored")
}
restored, err := Prepare(ctx, repos, c, "a", "29", now)
if err != nil || !reflect.DeepEqual(restored.NormalRows, before.NormalRows) {
t.Fatal("failed transaction changed target progress")
}
want[0].Progress, want[1].Progress, want[2].Progress = 99, 99, 41
for retry := 0; retry < 2; retry++ {
after, err := Complete(ctx, repos, c, "a", "29", match)
if err != nil || !reflect.DeepEqual(after.NormalRows, want) {
t.Fatalf("retry=%d after=%+v err=%v", retry, after, err)
}
if retry == 0 && (!reflect.DeepEqual(after.BeforeNormalRows, before.NormalRows) || len(after.ChangedRows) != 4) {
t.Fatal("daily and normal result deltas were not both retained")
}
if retry == 1 && len(after.ChangedRows) != 0 {
t.Fatal("target replay emitted completion again")
}
}
reset, err := Prepare(ctx, repos, c, "a", "29", now.Add(24*time.Hour))
if err != nil || !reflect.DeepEqual(reset.NormalRows, want) || reset.Rows[0].Progress != 0 {
t.Fatal("daily reset discarded normal progress")
}
reset.Character.Job = "1"
rows, err := c.normalRows(reset.Character)
if err != nil || rows[4].ID != 566 {
t.Fatal("another job's combo cards leaked")
}
for _, bad := range []string{
"[grade] `[normal]`\n[normal category] `[battle]`\n[rank range] 0 30\n[condition] `[hereafter join]` `[all]` 100\n[/condition]",
s["pvp_mission/hidden/499.msn"] + "[pre required mission] 500\n",
s["pvp_mission/hidden/499.msn"] + "[pre required mission] 999\n",
} {
s["pvp_mission/hidden/499.msn"] = bad
if _, err := Load(s); err == nil {
t.Fatal("malformed target or prerequisite accepted")
}
}
}
func TestRankedRecordHistoryCommitReplayAndWindow(t *testing.T) {
ctx := context.Background()
repos := memory.NewMemoryGroup()
c := testCatalog(t)
now := time.Date(2026, 9, 6, 10, 0, 0, 0, time.UTC)
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: "29", AccountID: "a", Job: "0",
Stats: map[string]int64{WinsKey: 16, LossesKey: 7, "pvp_win_streak": 4}}); err != nil {
t.Fatal(err)
}
outcomes := []int64{2, 1, 1, 3, 2, 1, 2, 3, 1, 1, 1, 2}
var recent int64
wins, losses, draws := int64(16), int64(7), int64(0)
for i, outcome := range outcomes {
match := Match{Key: fmt.Sprintf("history-%d", i), StartedAt: now.Add(time.Duration(i) * time.Minute), CompletedAt: now.Add(time.Duration(i+1) * time.Minute), Won: outcome == 1, Draw: outcome == 3}
before, _, _ := repos.Character.Load(ctx, "29")
failed := repos
failed.CharacterSettlement = failCommit{repos.CharacterSettlement}
if _, err := Complete(ctx, failed, c, "a", "29", match); err == nil {
t.Fatal("injected commit failure ignored")
}
unchanged, _, _ := repos.Character.Load(ctx, "29")
if !reflect.DeepEqual(before.Stats, unchanged.Stats) {
t.Fatal("failed transaction leaked record")
}
recent = (recent<<2 | outcome) & ((1 << 20) - 1)
switch outcome {
case 1:
wins++
case 2:
losses++
case 3:
draws++
}
for retry := 0; retry < 2; retry++ {
state, err := Complete(ctx, repos, c, "a", "29", match)
if err != nil {
t.Fatal(err)
}
s := state.Character.Stats
if s[WinsKey] != wins || s[LossesKey] != losses || s[DrawsKey] != draws || s[RecentResultsKey] != recent || s[MaxWinStreakKey] != 4 {
t.Fatalf("record=%v", s)
}
}
}
manual := Match{Key: "manual-history-exclusion", StartedAt: now.Add(time.Hour), CompletedAt: now.Add(2 * time.Hour), Won: true, Manual: true}
state, err := Complete(ctx, repos, c, "a", "29", manual)
if err != nil || state.Character.Stats[RecentResultsKey] != recent || state.Character.Stats[WinsKey] != wins {
t.Fatalf("manual record=%+v err=%v", state, err)
}
}
type failCommit struct {
repo.CharacterSettlementUnitOfWork
}
func (u failCommit) WithinCharacterSettlement(ctx context.Context, id string, f func(repo.Group) error) error {
return u.CharacterSettlementUnitOfWork.WithinCharacterSettlement(ctx, id, func(tx repo.Group) error {
if err := f(tx); err != nil {
return err
}
return errors.New("injected commit failure")
})
}
func TestDailyAssignmentSettlementAndReplay(t *testing.T) {
ctx := context.Background()
catalog := testCatalog(t)
repos := memory.NewMemoryGroup()
character := repo.CharacterRecord{CharacterID: "29", AccountID: "a", Stats: map[string]int64{"exp": 555, "pvp_rank_point": 777}}
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC)
state, err := Prepare(ctx, repos, catalog, "a", "29", now)
if err != nil || len(state.Rows) != 3 || state.Rows[0].Target != 3 {
t.Fatalf("state=%+v err=%v", state, err)
}
match := Match{Key: "match1", StartedAt: now, CompletedAt: now.Add(time.Minute), Won: true, Reports: map[uint16]bool{118: true}}
failed := repos
failed.CharacterSettlement = failCommit{repos.CharacterSettlement}
if _, err := Complete(ctx, failed, catalog, "a", "29", match); err == nil {
t.Fatal("commit failure ignored")
}
after, _, _ := repos.Character.Load(ctx, "29")
if !reflect.DeepEqual(after.Stats, state.Character.Stats) {
t.Fatal("failed settlement committed")
}
for i := 0; i < 2; i++ {
state, err = Complete(ctx, repos, catalog, "a", "29", match)
if err != nil || state.Character.Stats[CertificationKey] != 1 || state.Character.Stats[WinsKey] != 1 || state.Character.Stats[LossesKey] != 0 || state.Rows[0].Progress != 1 || state.Rows[1].Progress != 1 {
t.Fatalf("replay=%d state=%+v err=%v", i, state, err)
}
}
if state.Rows[2].Progress != 0 || state.Character.Stats["exp"] != 555 || state.Character.Stats[RankPointKey] != 855 || state.Character.Stats[ExperienceKey] != 1000 {
t.Fatal("ranked rewards or independent normal experience are incorrect")
}
restored, err := Prepare(ctx, repos, catalog, "a", "29", now.Add(2*time.Minute))
if err != nil || !reflect.DeepEqual(restored.Rows, state.Rows) {
t.Fatal("reconnect lost progress")
}
if _, err = Complete(ctx, repos, catalog, "b", "29", match); err == nil {
t.Fatal("wrong account accepted")
}
reset, err := Prepare(ctx, repos, catalog, "a", "29", now.Add(24*time.Hour))
if err != nil || reset.Rows[0].Progress != 0 || reset.Character.Stats[CertificationKey] != 1 {
t.Fatal("daily reset changed certification or retained progress")
}
match.Key = "spanning-reset"
match.CompletedAt = now.Add(24 * time.Hour)
state, err = Complete(ctx, repos, catalog, "a", "29", match)
if err != nil || state.Rows[0].Progress != 0 || state.Character.Stats[CertificationKey] != 2 {
t.Fatal("yesterday's match progressed today's missions")
}
}
func TestDailyEmblemsCommitReplayRepairAndReset(t *testing.T) {
ctx := context.Background()
for _, reward := range []int64{0, 40} {
t.Run(fmt.Sprint(reward), func(t *testing.T) {
catalog := testCatalog(t)
catalog.Pools[0][0].Emblems = reward
repos := memory.NewMemoryGroup()
now := time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC)
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: "29", AccountID: "a"}); err != nil {
t.Fatal(err)
}
state, err := Prepare(ctx, repos, catalog, "a", "29", now)
if err != nil {
t.Fatal(err)
}
// A completed row from the old server has no paid marker.
state.Character.Stats[dailyKey(0, "progress")] = 3
state.Character.Stats[EmblemsKey] = 7
if err := repos.Character.Save(ctx, state.Character); err != nil {
t.Fatal(err)
}
failed := repos
failed.CharacterSettlement = failCommit{repos.CharacterSettlement}
if _, err := Prepare(ctx, failed, catalog, "a", "29", now); err == nil {
t.Fatal("reward commit failure ignored")
}
after, _, _ := repos.Character.Load(ctx, "29")
if !reflect.DeepEqual(after.Stats, state.Character.Stats) {
t.Fatal("failed reward mutated saved state")
}
for attempt := 0; attempt < 2; attempt++ {
repaired, err := Prepare(ctx, repos, catalog, "a", "29", now)
if err != nil || repaired.Character.Stats[EmblemsKey] != 7+reward || repaired.Character.Stats[dailyKey(0, "paid")] != 1 {
t.Fatalf("repair=%+v err=%v", repaired, err)
}
want := int64(0)
if attempt == 0 {
want = reward
}
if repaired.EmblemsGranted != want {
t.Fatal("reward replayed")
}
}
reset, err := Prepare(ctx, repos, catalog, "a", "29", now.Add(24*time.Hour))
if err != nil || reset.Rows[0].Progress != 0 || reset.Character.Stats[EmblemsKey] != 7+reward || reset.Character.Stats[dailyKey(0, "paid")] != 0 {
t.Fatalf("reset=%+v err=%v", reset, err)
}
for i := 0; i < 3; i++ {
match := Match{Key: fmt.Sprintf("match%d", i), StartedAt: now.Add(24 * time.Hour), CompletedAt: now.Add(24*time.Hour + time.Duration(i)*time.Minute), Won: i == 0, Draw: i == 2}
for retry := 0; retry < 2; retry++ {
state, err = Complete(ctx, repos, catalog, "a", "29", match)
if err != nil {
t.Fatal(err)
}
}
}
if state.Character.Stats[EmblemsKey] != 7+reward*2 || state.Character.Stats[WinsKey] != 1 || state.Character.Stats[LossesKey] != 1 || state.Character.Stats[CertificationKey] != 3 {
t.Fatalf("final=%+v", state)
}
})
}
}
func TestDailyRewardOverflowRollsBackProgressAndWins(t *testing.T) {
ctx := context.Background()
catalog := testCatalog(t)
repos := memory.NewMemoryGroup()
now := time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC)
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: "29", AccountID: "a"}); err != nil {
t.Fatal(err)
}
state, err := Prepare(ctx, repos, catalog, "a", "29", now)
if err != nil {
t.Fatal(err)
}
state.Character.Stats[EmblemsKey] = math.MaxInt32 - 39
state.Character.Stats[dailyKey(0, "progress")] = 2
if err := repos.Character.Save(ctx, state.Character); err != nil {
t.Fatal(err)
}
if _, err := Complete(ctx, repos, catalog, "a", "29", Match{Key: "overflow", StartedAt: now, CompletedAt: now, Won: true}); err == nil {
t.Fatal("overflow accepted")
}
after, _, _ := repos.Character.Load(ctx, "29")
if !reflect.DeepEqual(after.Stats, state.Character.Stats) {
t.Fatal("overflow partially committed")
}
}