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,291 @@
package inventory
import (
"context"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// The current client packs the op50 mode word as target list (high byte) +
// sub mode (low byte). 2026-08-28 live capture for a worn item at slot 14:
// mode word 0x0300, target 100080175, material slot 94.
func TestDecodeUpgradeItemRequestEquippedModeWord(t *testing.T) {
body := []byte{
0x00, 0x03,
0x0E, 0x00,
0x2F, 0x1A, 0xF7, 0x05,
0x5E, 0x00,
0xFF, 0xFF,
0x02, 0x00, 0x00, 0x00,
'x', 'y',
}
parsed, err := DecodeUpgradeItemRequest(body)
if err != nil {
t.Fatalf("DecodeUpgradeItemRequest error = %v", err)
}
if parsed.TargetListType != listTypeEquipment || parsed.Mode != "reinforce" ||
parsed.RawMode != 0x0300 || parsed.TargetSlotIndex != 14 ||
parsed.TargetItemTemplateID != 100080175 || parsed.MaterialSlotIndex != 94 {
t.Fatalf("parsed = %+v", parsed)
}
ampBody := append([]byte(nil), body...)
ampBody[0] = 0x01 // 0x0301: worn list-3 amplify
parsedAmp, err := DecodeUpgradeItemRequest(ampBody)
if err != nil {
t.Fatalf("DecodeUpgradeItemRequest amplify error = %v", err)
}
if parsedAmp.TargetListType != listTypeEquipment || parsedAmp.Mode != "amplify" {
t.Fatalf("parsed amplify = %+v", parsedAmp)
}
// The backpack wire is unchanged: 0x0000 / 0x0001 still decode to list 0.
bagBody := append([]byte(nil), body...)
bagBody[1] = 0x00
parsedBag, err := DecodeUpgradeItemRequest(bagBody)
if err != nil || parsedBag.TargetListType != listTypeMain || parsedBag.Mode != "reinforce" {
t.Fatalf("parsed backpack = %+v err=%v", parsedBag, err)
}
// An unsupported list in the high byte is still refused.
badList := append([]byte(nil), body...)
badList[1] = 0x07
if _, err := DecodeUpgradeItemRequest(badList); err == nil {
t.Fatal("list-7 mode word unexpectedly accepted")
}
// Sub mode 2 is the 高级装备强化机 (live capture 2026-09-11 13:35:37); it
// decodes as a reinforce carrying the high-upgrade flag. It was rejected
// until then, which is why that machine never worked.
highSub := append([]byte(nil), body...)
highSub[0] = 0x02
parsedHigh, err := DecodeUpgradeItemRequest(highSub)
if err != nil || parsedHigh.Mode != "reinforce" || !parsedHigh.HighUpgrade {
t.Fatalf("sub mode 2 = %+v err=%v", parsedHigh, err)
}
// Anything above 2 is still unknown and must fail closed.
badSub := append([]byte(nil), body...)
badSub[0] = 0x03
if _, err := DecodeUpgradeItemRequest(badSub); err == nil {
t.Fatal("sub mode 3 unexpectedly accepted")
}
if parsedBag.HighUpgrade {
t.Fatal("ordinary reinforce must not carry the high-upgrade flag")
}
}
// upgradeTicketEquippedBody is the live C2S layout for a ticket used on a worn
// item: mode word high byte 3.
func upgradeTicketEquippedBody(targetSlot int16, targetItemID int32, materialSlot int16) []byte {
body := upgradeTicketRequestBody(targetSlot, targetItemID, materialSlot)
body[0] = 0x00
body[1] = 0x03
return body
}
// newEquippedUpgradeTicketCommand targets a worn equipment slot (list 3).
func newEquippedUpgradeTicketCommand(mode string, targetSlot int16, targetItemID int32, materialSlot int16) Command {
cmd := newUpgradeTicketCommand(mode, targetSlot, targetItemID, materialSlot)
cmd.TargetListType = listTypeEquipment
return cmd
}
// saveEquippedUpgradeTicketFixture puts the worn target into the equipment
// record (slot 14) and the ticket/material into the backpack.
func saveEquippedUpgradeTicketFixture(t *testing.T, ctx context.Context, repos dnfrepo.Group, targetLevel byte, ticketCount int64) {
t.Helper()
targetRaw := make([]byte, currentItemListEntrySize)
targetRaw[0x0A] = targetLevel
ticketRaw := make([]byte, currentItemListEntrySize)
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"14": {
SlotIndex: 14,
ItemID: 700,
RawEntry: targetRaw,
Extra: map[string]string{
"item_kind": "equipment",
"durability": "45",
"max_durability": "45",
"current_exe_inventory_count": "1",
},
},
},
}); err != nil {
t.Fatalf("Save equipment error = %v", err)
}
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 121): {
ItemID: 9100,
Count: ticketCount,
RawEntry: ticketRaw,
Extra: map[string]string{
"item_kind": "stackable",
"pvf_path": "stackable/ticket/plus10.stk",
},
},
},
})
}
func TestOwnerUpgradeTicketOnEquippedTargetWritesEquipmentRepo(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveEquippedUpgradeTicketFixture(t, ctx, repos, 2, 2)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.UpgradeTicket(ctx, newEquippedUpgradeTicketCommand("reinforce", 14, 700, 121), reinforceTicketResolver(10, 100000))
if err != nil {
t.Fatalf("UpgradeTicket error = %v", err)
}
if !result.Success || !result.UpgradeSucceeded || result.TargetListType != listTypeEquipment || result.NewLevel != 10 {
t.Fatalf("result = %+v", result)
}
equip, ok, err := repos.Equipment.Load(ctx, "77")
if err != nil || !ok {
t.Fatalf("load equipment found=%t err=%v", ok, err)
}
entry := equip.Entries["14"]
if got := entry.RawEntry[0x0A] & 0x1F; got != 10 {
t.Fatalf("equipped raw level = %d, want 10", got)
}
if got := entry.Extra["reinforce"]; got != "10" {
t.Fatalf("equipped extra reinforce = %q, want 10", got)
}
// The ticket is consumed from the backpack, and no list-3 key leaked in.
loaded := loadTestInventory(t, ctx, repos, "77")
if got := loaded.Slots[slotKey(listTypeMain, 121)].Count; got != 1 {
t.Fatalf("ticket count = %d, want 1", got)
}
if _, leaked := loaded.Slots[slotKey(listTypeEquipment, 14)]; leaked {
t.Fatalf("worn row leaked into the backpack record: %+v", loaded.Slots)
}
if result.TargetUpdatedStack.ItemID != 700 || result.TargetUpdatedStack.RawEntry[0x0A]&0x1F != 10 {
t.Fatalf("target updated stack = %+v", result.TargetUpdatedStack)
}
}
func TestOwnerUpgradeTicketOnEquippedTargetMissingEntryFailsInvalidTarget(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveEquippedUpgradeTicketFixture(t, ctx, repos, 2, 2)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.UpgradeTicket(ctx, newEquippedUpgradeTicketCommand("reinforce", 99, 700, 121), reinforceTicketResolver(10, 100000))
if err != nil {
t.Fatalf("UpgradeTicket error = %v", err)
}
if result.Success || result.ErrorCode != upgradeTicketErrorInvalidTarget || result.Changed {
t.Fatalf("result = %+v, want invalid target without mutation", result)
}
}
func TestOwnerUpgradeReinforceOnEquippedTargetWritesEquipmentRepo(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{CharacterID: "77", AccountID: "acc", Stats: map[string]int64{"gold": 10_000_000}})
saveEquippedUpgradeTicketFixture(t, ctx, repos, 2, 3)
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := NewUpgradeCommand(alignedcmd.Request{AccountID: "acc", SelectedCharacterID: 77}, UpgradeItemRequest{
Mode: "reinforce",
TargetListType: listTypeEquipment,
TargetSlotIndex: 14,
TargetItemTemplateID: 700,
MaterialSlotIndex: 121,
OptionalTicketSlotIndex: -1,
})
cmd.UpgradeSuccessWeight = 100000 // guaranteed success
result, err := owner.Upgrade(ctx, cmd)
if err != nil {
t.Fatalf("Upgrade error = %v", err)
}
if !result.Success || !result.Changed || result.TargetListType != listTypeEquipment ||
result.OldLevel != 2 || result.NewLevel != 3 || result.MaterialRemainingStackCount != 2 {
t.Fatalf("result = %+v", result)
}
equip, ok, err := repos.Equipment.Load(ctx, "77")
if err != nil || !ok {
t.Fatalf("load equipment found=%t err=%v", ok, err)
}
entry := equip.Entries["14"]
if got := entry.RawEntry[0x0A] & 0x1F; got != 3 {
t.Fatalf("equipped raw level = %d, want 3", got)
}
if got := entry.Extra["reinforce"]; got != "3" {
t.Fatalf("equipped extra reinforce = %q, want 3", got)
}
loaded := loadTestInventory(t, ctx, repos, "77")
if got := loaded.Slots[slotKey(listTypeMain, 121)].Count; got != 2 {
t.Fatalf("material count = %d, want 2", got)
}
if _, leaked := loaded.Slots[slotKey(listTypeEquipment, 14)]; leaked {
t.Fatalf("worn row leaked into the backpack record: %+v", loaded.Slots)
}
}
func TestHandlerUpgradeTicketOnEquippedTargetSendsAckAndEquipmentRefresh(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveEquippedUpgradeTicketFixture(t, ctx, repos, 2, 2)
got := handleUpgradeTicketRequest(t, repos, reinforceTicketResolver(10, 100000), upgradeTicketEquippedBody(14, 700, 121))
if !got.Handled || !got.ResponseAllowed {
t.Fatalf("result = %+v", got)
}
// ACK + the material row in list 0; the worn row is not in that refresh.
if len(got.UpperResponses) != 2 {
t.Fatalf("responses = %d, want ack + material refresh", len(got.UpperResponses))
}
ack := got.UpperResponses[0]
if ack.MsgID != uint16(dnfenum.CmdPacketUpgradeItem) || ack.Classification != 1 {
t.Fatalf("ack = msg 0x%04X class %d", ack.MsgID, ack.Classification)
}
if len(ack.Body) < 15 || ack.Body[14] != listTypeEquipment {
t.Fatalf("ack body = %x, want byte[14] = listTypeEquipment(3)", ack.Body)
}
refresh := got.UpperResponses[1]
if refresh.MsgID != msgItemListUpdate || refresh.Classification != 0 || len(refresh.Body) < 2 || refresh.Body[0] != listTypeMain || refresh.Body[1] != 1 {
t.Fatalf("refresh = msg 0x%04X class %d body=%x, want one list-0 material row", refresh.MsgID, refresh.Classification, refresh.Body)
}
if len(got.EquipmentSlotRefreshes) != 1 || got.EquipmentSlotRefreshes[0] != 14 || len(got.ItemSlotRefreshes) != 0 {
t.Fatalf("equipment refreshes = %v item refreshes = %+v, want the single worn slot 14", got.EquipmentSlotRefreshes, got.ItemSlotRefreshes)
}
equip, ok, err := repos.Equipment.Load(ctx, "77")
if err != nil || !ok {
t.Fatalf("load equipment found=%t err=%v", ok, err)
}
if got := equip.Entries["14"].RawEntry[0x0A] & 0x1F; got != 10 {
t.Fatalf("equipped raw level = %d, want 10", got)
}
}
// The backpack path must be byte-for-byte what it was: list 0 in the mode word
// still resolves the target in list 0, and byte[14] of the ACK stays 0.
func TestHandlerUpgradeTicketOnBackpackTargetKeepsByte14Zero(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveUpgradeTicketFixture(t, ctx, repos, nil, 2, 2)
got := handleUpgradeTicketRequest(t, repos, reinforceTicketResolver(10, 100000), upgradeTicketRequestBody(9, 700, 121))
if !got.Handled || len(got.UpperResponses) != 2 {
t.Fatalf("result = %+v", got)
}
if ack := got.UpperResponses[0]; len(ack.Body) < 15 || ack.Body[14] != 0 {
t.Fatalf("backpack ack body = %x, want byte[14] = 0", ack.Body)
}
if len(got.EquipmentSlotRefreshes) != 0 {
t.Fatalf("backpack target must not request an equipment refresh: %v", got.EquipmentSlotRefreshes)
}
}