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,127 @@
package dnfbridge
import (
"encoding/binary"
"strconv"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func expandedRosterRecords(count int) []dnfrepo.CharacterRecord {
characters := make([]dnfrepo.CharacterRecord, 0, count)
for slot := 0; slot < count; slot++ {
id := strconv.Itoa(slot + 1)
characters = append(characters, dnfrepo.CharacterRecord{
CharacterID: id,
AccountID: "dnf:1",
Slot: slot,
Name: "character-" + id,
Job: "0",
Level: 1,
})
}
return characters
}
// 回归:账号记录缺失或元数据损坏时限额回落到基线 32,但库里的角色仍然坐在 32..59 号
// 槽位上。投影不能跟着回落,否则那批角色会整批从选角页消失。
func TestEffectiveCharacterSlotLimitCoversOccupiedSlots(t *testing.T) {
characters := expandedRosterRecords(40)
if got := effectiveCharacterSlotLimit(characters, defaultCharacterSlots); got != 40 {
t.Fatalf("fallback limit = %d, want 40", got)
}
if got := effectiveCharacterSlotLimit(characters, maxCharacterSlots); got != maxCharacterSlots {
t.Fatalf("persisted limit was lowered to %d", got)
}
if got := effectiveCharacterSlotLimit(nil, defaultCharacterSlots); got != defaultCharacterSlots {
t.Fatalf("empty roster limit = %d, want the baseline %d", got, defaultCharacterSlots)
}
last := []dnfrepo.CharacterRecord{{CharacterID: "1", AccountID: "a", Slot: maxCharacterSlots - 1}}
if got := effectiveCharacterSlotLimit(last, defaultCharacterSlots); got != maxCharacterSlots {
t.Fatalf("last-slot limit = %d, want %d", got, maxCharacterSlots)
}
}
// 回归:限额回落到基线时,mode-2 快照必须仍然按真实占用槽位算总数和页数,
// op679 扩展批次也必须仍然能取到 36..39 号槽位。
func TestRosterSurvivesSlotLimitFallbackWithExpandedSlots(t *testing.T) {
characters := expandedRosterRecords(40)
body := buildNoPackRosterBodyForLimit(characters, defaultCharacterSlots)
if len(body) < 15 {
t.Fatalf("roster body too short: %d", len(body))
}
if body[1] != byte(len(characters)) {
t.Fatalf("roster total count = %d, want %d", body[1], len(characters))
}
// mode-2 只承载 0..35,剩下的走扩展批次。
if got := binary.LittleEndian.Uint16(body[13:15]); got != currentRosterExtensionBatchSize {
t.Fatalf("mode-2 row count = %d, want %d", got, currentRosterExtensionBatchSize)
}
offset := 15
for _, character := range characters[:currentRosterExtensionBatchSize] {
if got := int(binary.LittleEndian.Uint16(body[offset : offset+2])); got != character.Slot {
t.Fatalf("mode-2 entry at offset %d carries slot %d, want %d", offset, got, character.Slot)
}
offset += noPackRosterEntryLen(character)
}
if got := body[offset]; got != 2 {
t.Fatalf("roster page count = %d, want 2", got)
}
extension, ok := buildNoPackRosterExtensionBodyForLimit(characters, defaultCharacterSlots, currentRosterExtensionBatchSize)
if !ok {
t.Fatal("extension batch was rejected after the slot limit fell back to the baseline")
}
if extension[0] != 4 {
t.Fatalf("extension mode = %d, want 4", extension[0])
}
if got := binary.LittleEndian.Uint16(extension[1:3]); got != uint16(len(characters)) {
t.Fatalf("extension total count = %d, want %d", got, len(characters))
}
if got := binary.LittleEndian.Uint16(extension[3:5]); got != 4 {
t.Fatalf("extension row count = %d, want 4", got)
}
// 扩展批次的行键必须是账号级真实槽位,不是批次内的零基序号。
offset = 5
for _, character := range characters[currentRosterExtensionBatchSize:] {
if got := int(binary.LittleEndian.Uint16(extension[offset : offset+2])); got != character.Slot {
t.Fatalf("extension entry at offset %d carries slot %d, want %d", offset, got, character.Slot)
}
offset += noPackRosterEntryLen(character)
}
}
// 回归:nextCharacterSlot 找不到空位时曾返回 len(records),槽位有空洞时这个下标
// 正好落在一个已占用的槽位上,写进去会撞唯一约束。
func TestNextCharacterSlotReusesHolesAndRefusesWhenFull(t *testing.T) {
characters := expandedRosterRecords(40)
withHole := make([]dnfrepo.CharacterRecord, 0, len(characters)-1)
for _, character := range characters {
if character.Slot == 35 {
continue
}
withHole = append(withHole, character)
}
if got := nextCharacterSlot(withHole, dnfrepo.MaxCharacterSlotLimit); got != 35 {
t.Fatalf("next slot = %d, want the free slot 35", got)
}
if got := nextCharacterSlot(characters, dnfrepo.MaxCharacterSlotLimit); got != 40 {
t.Fatalf("next slot = %d, want 40", got)
}
full := expandedRosterRecords(dnfrepo.MaxCharacterSlotLimit)
if got := nextCharacterSlot(full, dnfrepo.MaxCharacterSlotLimit); got != -1 {
t.Fatalf("full roster next slot = %d, want -1", got)
}
// 空洞出现在扩栏区间里时同样要复用,而不是回一个重复槽位。
fullWithHole := make([]dnfrepo.CharacterRecord, 0, len(full)-1)
for _, character := range full {
if character.Slot == 47 {
continue
}
fullWithHole = append(fullWithHole, character)
}
if got := nextCharacterSlot(fullWithHole, dnfrepo.MaxCharacterSlotLimit); got != 47 {
t.Fatalf("next slot = %d, want the free slot 47", got)
}
}