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,115 @@
package dnfbridge
import (
"bytes"
"encoding/binary"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// The four native tower counters are read by the client at body+6..9 of the
// adventure-group info push and at +22..25 of the actor refresh. Both are u8
// fields, so the value saturates rather than wrapping.
func TestBuildCurrentAdventureInfoBodyWritesTowerCounters(t *testing.T) {
for _, roster := range []struct {
name string
characters []dnfrepo.CharacterRecord
}{
{name: "empty"},
{name: "populated", characters: []dnfrepo.CharacterRecord{{
CharacterID: "20", Name: "冒险家", Job: "11", Level: 90,
Stats: map[string]int64{"grow_type": 0x22, "server_group_id": 2},
}}},
} {
for _, check := range []struct {
name string
remaining [adventuregroup.TowerTypeCount]uint32
limits [adventuregroup.TowerTypeCount]uint32
want []byte
}{
{"available", [2]uint32{2, 1}, [2]uint32{3, 1}, []byte{2, 3, 1, 1}},
{"exhausted", [2]uint32{0, 0}, [2]uint32{3, 1}, []byte{0, 3, 0, 1}},
{"byte_limit", [2]uint32{255, 254}, [2]uint32{255, 255}, []byte{255, 255, 254, 255}},
{"saturate", [2]uint32{256, 0}, [2]uint32{300, ^uint32(0)}, []byte{255, 255, 0, 255}},
} {
t.Run(roster.name+"/"+check.name, func(t *testing.T) {
projection := adventuregroup.Projection{TowerRemaining: check.remaining, TowerLimits: check.limits}
body := buildCurrentAdventureInfoBodyWithState(20, adventuregroup.Summary{}, roster.characters, "group", 20260806, projection)
if got := body[6:10]; !bytes.Equal(got, check.want) {
t.Errorf("native tower bytes=%v, want %v", got, check.want)
}
actor := buildCurrentAdventureActorRefreshBody(20, adventuregroup.Summary{}, projection)
if got := actor[22:26]; !bytes.Equal(got, check.want) {
t.Errorf("native actor refresh tower bytes=%v, want %v", got, check.want)
}
})
}
}
}
// The expedition attribute set rotates on the UTC+8 local midnight, not on UTC
// midnight: the client's preview and the server's paid reward must agree on the
// same day boundary.
func TestAreaAttributesRotateOnUTC8Midnight(t *testing.T) {
c := adventuregroup.RuntimeConfig{
ExpeditionAreas: []adventuregroup.ExpeditionArea{{Index: 1, DesignatedGroups: []string{"g"}}},
AttributeGroups: map[string][]string{"g": {"a", "b", "c"}},
AttributeIDs: map[string]byte{"a": 1, "b": 2, "c": 3}, RotationDays: 1,
}
before := time.Date(2026, 9, 13, 15, 59, 59, 0, time.UTC)
a, b := c.AreaAttributes(1, before), c.AreaAttributes(1, before.Add(time.Second))
if len(a) != 1 || len(b) != 1 || a[0] == b[0] {
t.Fatalf("local midnight did not rotate: %v -> %v", a, b)
}
utcMidnight := time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC)
if got := c.AreaAttributes(1, utcMidnight); got[0] != b[0] {
t.Fatalf("rotated again at UTC midnight: %v -> %v", b, got)
}
}
// The client reads its character list from the native idle table in area 0, and
// identifies an already-sent character by durable ID rather than by roster slot.
func TestAdventureExpeditionNativeCandidatesAndIdentity(t *testing.T) {
characters := []dnfrepo.CharacterRecord{
{CharacterID: "1", Slot: 0, Job: "0", Level: 90, Name: "A", Stats: map[string]int64{"grow_type": 0x21}},
{CharacterID: "2", Slot: 1, Job: "0", Level: 90, Name: "B", Stats: map[string]int64{"grow_type": 0x22}},
}
requests := []currentAdventureExpeditionMemberRequest{{Selector: 0, Job: 0, GrowType: 1}, {Selector: 1, Job: 0, GrowType: 2}}
members, _, ok := resolveCurrentAdventureExpeditionMembers(requests, characters, adventuregroup.RuntimeState{})
if !ok || len(members) != 2 || members[0].PersistentCharacterID != "1" || members[1].PersistentCharacterID != "2" {
t.Fatalf("members=%+v accepted=%t", members, ok)
}
state := currentAdventureSelectedState{Characters: characters, Config: adventuregroup.RuntimeConfig{
ExpeditionAreas: []adventuregroup.ExpeditionArea{{Index: 1}},
}}
projection := currentAdventureExpeditionProjection(state, time.Unix(100, 0))
if len(projection.Expeditions["0"].Members) != 2 || len(state.Runtime.Expeditions) != 0 {
t.Fatalf("idle candidates/projection mutated source: %+v", projection)
}
wire := buildCurrentAdventureExpeditionStateBody(projection, time.Unix(100, 0))
if len(wire) < 14 || wire[0] != 2 || wire[1] != 0 || wire[2] != 0 || binary.LittleEndian.Uint16(wire[12:14]) != 2 {
t.Fatalf("native idle area header=%x", wire[:14])
}
state.Runtime.Expeditions = map[string]adventuregroup.Expedition{"1": {Area: 1, State: 1, Members: members[:1]}}
characters[0].Slot = 3
requests[0].Selector = 3
if _, _, ok := resolveCurrentAdventureExpeditionMembers(requests[:1], characters, state.Runtime); ok {
t.Fatal("roster reordering bypassed occupied persistent identity")
}
if got := currentAdventureExpeditionProjection(state, time.Unix(100, 0)); len(got.Expeditions["0"].Members) != 1 {
t.Fatal("occupied character remained in idle candidates")
}
requests[0] = currentAdventureExpeditionMemberRequest{Selector: 1, Job: 0, GrowType: 1}
if _, _, ok := resolveCurrentAdventureExpeditionMembers(requests[:1], characters, adventuregroup.RuntimeState{}); ok {
t.Fatal("numeric character ID was accepted as a different slot")
}
requests = []currentAdventureExpeditionMemberRequest{{Selector: 1, Job: 0, GrowType: 2}, {Selector: 1, Job: 0, GrowType: 2}}
if _, _, ok := resolveCurrentAdventureExpeditionMembers(requests, characters, adventuregroup.RuntimeState{}); ok {
t.Fatal("duplicate persistent character accepted")
}
}