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,383 @@
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"testing"
"longheng.io/server/internal/modules/dnf/channelcatalog"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestSelectedCreatureSceneReadyProjectionDoesNotRebuildActorAfterOp24(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "dnf:1",
Name: "Actor19",
Job: "15",
Level: 90,
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{},
Warehouse: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{
"26": {
SlotIndex: 26,
ItemID: 400990167,
RawEntry: testCurrentEquippedCreatureRaw(26, 400990167, 1784487991),
},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Pet.Save(ctx, dnfrepo.PetRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.PetEntry{
"1784487991": {
PetKey: "1784487991",
CreatureKey: 1784487991,
ItemID: 400990167,
Name: "PetA",
NameRaw: []byte("PetA"),
Satiety: 14,
Level: 3,
Exp: 5,
SourceListType: 3,
SourceSlotIndex: 26,
},
},
EquippedKey: "1784487991",
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "scene-ready-pet-bind",
channel: channelcatalog.Channel{ID: 19},
residentChannel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
townActorOwnerChannel: 19,
selectedCreatureStateTableSent: true,
}
if err := service.sendSelectedCreatureSceneReadyProjection(session, "test_scene_ready"); err != nil {
t.Fatal(err)
}
if got := connection.write.Bytes(); len(got) != 0 {
t.Fatalf("scene-ready tail replaced the op24-bound local actor: %x", got)
}
}
func TestSelectedCreatureInitialStateAfterMode1PushesBarsBeforeSceneTail(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {
SlotIndex: 12,
ItemID: 101030741,
Extra: map[string]string{
"equipment_effect_id": "1",
},
},
"26": {
SlotIndex: 26,
ItemID: 400990167,
RawEntry: testCurrentEquippedCreatureEnchantRaw(26, 400990167, 37, 10008713, 2),
Extra: map[string]string{
"value_a": "10008713",
"pet_enchant_card_item_id": "10008713",
"byte_12": "2",
},
},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Pet.Save(ctx, dnfrepo.PetRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.PetEntry{
"37": {
PetKey: "37",
CreatureKey: 37,
ItemID: 400990167,
Name: "PetB",
NameRaw: []byte("PetB"),
Satiety: 72,
Level: 7,
Exp: 0x12345678,
SourceListType: 3,
SourceSlotIndex: 26,
},
},
EquippedKey: "37",
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "initial-pet-bars",
channel: channelcatalog.Channel{ID: 19},
residentChannel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
}
if err := service.sendSelectedCreatureInitialStateAfterMode1(session, "test_after_mode1"); err != nil {
t.Fatal(err)
}
effectRefresh, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
if effectRefresh.Header.MsgID != 14 || effectRefresh.Header.Classification != 0 ||
len(effectRefresh.Body) != 3+currentItemListEntryWireSize ||
effectRefresh.Body[0] != currentSocketListEquipment ||
binary.LittleEndian.Uint16(effectRefresh.Body[1:3]) != 1 ||
binary.LittleEndian.Uint16(effectRefresh.Body[3:5]) != 12 ||
binary.LittleEndian.Uint16(effectRefresh.Body[3+currentEquipmentEffectRuneWireOffset:3+currentEquipmentEffectRuneWireOffset+2]) != 1 {
t.Fatalf("initial effect-rune refresh header=%+v body=%x", effectRefresh.Header, effectRefresh.Body)
}
itemRefresh, rest := splitLongHengGameServerUpperPacket(t, rest)
assertEquippedCreatureItemRefreshPacket(t, itemRefresh, 400990167, 37, 10008713, 2)
state, rest := splitGameServerUpperPacket(t, rest)
if state.Header.MsgID != currentCreatureStateTableMsgID ||
state.Header.Classification != 0 ||
len(state.Body) < 20 ||
state.Body[3] != 1 ||
binary.LittleEndian.Uint32(state.Body[4:8]) != 37 ||
state.Body[8] != 72 ||
binary.LittleEndian.Uint32(state.Body[10:14]) != 0x12345678 ||
state.Body[14] != 7 {
t.Fatalf("initial creature table header=%+v body=%x", state.Header, state.Body)
}
growth, trailing := splitGameServerUpperPacket(t, rest)
wantGrowth := []byte{0, 0, 0, 7, 0, 0x78, 0x56, 0x34, 0x12}
if growth.Header.MsgID != currentCreatureGrowthMsgID ||
growth.Header.Classification != 0 ||
!bytes.Equal(growth.Body, wantGrowth) {
t.Fatalf("initial creature growth header=%+v body=%x want=%x", growth.Header, growth.Body, wantGrowth)
}
if len(trailing) != 0 {
t.Fatalf("unexpected trailing initial creature packets=%x", trailing)
}
}
func TestSelectedCreatureCompletedTownReturnRefreshesSlot26BeforeBars(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
const (
characterID = "19"
itemID = 400990167
serial = 37
cardID = 10008713
)
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: characterID,
Entries: map[string]dnfrepo.EquipmentEntry{
"26": {SlotIndex: 26, ItemID: itemID, RawEntry: testCurrentEquippedCreatureEnchantRaw(26, itemID, serial, cardID, 2)},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Pet.Save(ctx, dnfrepo.PetRecord{
CharacterID: characterID,
Entries: map[string]dnfrepo.PetEntry{
"37": {
PetKey: "37", CreatureKey: serial, ItemID: itemID, Name: "PetB", NameRaw: []byte("PetB"),
Satiety: 72, Level: 7, Exp: 0x12345678, SourceListType: 3, SourceSlotIndex: 26,
},
},
EquippedKey: "37",
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:1", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection, connID: "completed-return-pet", channel: channelcatalog.Channel{ID: 19},
residentChannel: channelcatalog.Channel{ID: 19}, selectedCharacterID: 19,
}
if err := service.sendSelectedCreatureCompletedTownReturnStateAfterMode1(session, "test_completed_return"); err != nil {
t.Fatal(err)
}
packets := splitAllCurrentUpperPackets(t, connection.write.Bytes())
if len(packets) != 3 {
t.Fatalf("completed return pet packet count=%d", len(packets))
}
if packets[0].Header.MsgID != 14 || packets[0].Body[0] != currentSocketListEquipment ||
packets[1].Header.MsgID != currentCreatureStateTableMsgID || packets[2].Header.MsgID != currentCreatureGrowthMsgID {
t.Fatalf("completed return pet order=%v/%v/%v", packets[0].Header.MsgID, packets[1].Header.MsgID, packets[2].Header.MsgID)
}
assertEquippedCreatureItemRefreshPacket(t, packets[0], itemID, serial, cardID, 2)
if packets[1].Body[3] != 1 || binary.LittleEndian.Uint32(packets[1].Body[4:8]) != serial ||
packets[1].Body[8] != 72 || binary.LittleEndian.Uint32(packets[1].Body[10:14]) != 0x12345678 {
t.Fatalf("completed return creature state=%x", packets[1].Body)
}
}
func TestSelectedCreatureCompletedTownReturnSkipsWhenNoPetIsEquipped(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "19", Entries: map[string]dnfrepo.EquipmentEntry{}}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
session := &gameSession{conn: connection, connID: "completed-return-no-pet", channel: channelcatalog.Channel{ID: 19}, selectedCharacterID: 19}
if err := service.sendSelectedCreatureCompletedTownReturnStateAfterMode1(session, "test_completed_return_no_pet"); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("completed return without pet emitted=%x", connection.write.Bytes())
}
}
func TestSelectedCreatureStateAfterMoveAckSendsOnlyAbsolutePetState(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{
"26": {
SlotIndex: 26,
ItemID: 400990167,
RawEntry: testCurrentEquippedCreatureEnchantRaw(26, 400990167, 37, 10008713, 2),
Extra: map[string]string{
"value_a": "10008713",
"pet_enchant_card_item_id": "10008713",
"byte_12": "2",
},
},
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Pet.Save(ctx, dnfrepo.PetRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.PetEntry{
"37": {
PetKey: "37",
CreatureKey: 37,
ItemID: 400990167,
Name: "PetB",
NameRaw: []byte("PetB"),
Satiety: 72,
Level: 7,
Exp: 0x12345678,
SourceListType: 3,
SourceSlotIndex: 26,
},
},
EquippedKey: "37",
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:1",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "pet-after-op19",
channel: channelcatalog.Channel{ID: 19},
selectedCharacterID: 19,
}
if err := service.sendSelectedCreatureStateAfterMoveAck(session, "test"); err != nil {
t.Fatal(err)
}
itemRefresh, rest := splitLongHengGameServerUpperPacket(t, connection.write.Bytes())
assertEquippedCreatureItemRefreshPacket(t, itemRefresh, 400990167, 37, 10008713, 2)
state, rest := splitLongHengGameServerUpperPacket(t, rest)
if state.Header.MsgID != currentCreatureStateTableMsgID || state.Header.Classification != 0 {
t.Fatalf("first pet packet header=%+v body=%x", state.Header, state.Body)
}
growth, trailing := splitLongHengGameServerUpperPacket(t, rest)
if growth.Header.MsgID != currentCreatureGrowthMsgID || growth.Header.Classification != 0 {
t.Fatalf("second pet packet header=%+v body=%x", growth.Header, growth.Body)
}
if len(trailing) != 0 {
t.Fatalf("pet move replayed actor/container packets after targeted item refresh=%x", trailing)
}
}
func assertEquippedCreatureItemRefreshPacket(
t *testing.T,
packet dnfproto.ChannelPacket,
itemID uint32,
serial uint32,
cardID uint32,
upgrade byte,
) {
t.Helper()
if packet.Header.MsgID != 14 || packet.Header.Classification != 0 ||
len(packet.Body) != 3+currentItemListEntryWireSize ||
packet.Body[0] != currentSocketListEquipment ||
binary.LittleEndian.Uint16(packet.Body[1:3]) != 1 {
t.Fatalf("equipped creature item refresh header=%+v body=%x", packet.Header, packet.Body)
}
row := packet.Body[3:]
if binary.LittleEndian.Uint16(row[0:2]) != 26 ||
binary.LittleEndian.Uint32(row[2:6]) != itemID ||
binary.LittleEndian.Uint32(row[6:10]) != serial ||
binary.LittleEndian.Uint32(row[0x0E:0x12]) != cardID ||
row[0x12] != upgrade ||
binary.LittleEndian.Uint32(row[currentPetRemainSecondsOffset:currentPetRemainSecondsOffset+4]) != 0 ||
binary.LittleEndian.Uint32(row[currentItemListExpireTimeOffset:currentItemListExpireTimeOffset+4]) != 0 {
t.Fatalf("equipped creature item refresh row=%x", row)
}
}
func testCurrentEquippedCreatureEnchantRaw(
slot byte,
itemID uint32,
serial uint32,
cardID uint32,
upgrade byte,
) []byte {
raw := testCurrentEquippedCreatureRaw(slot, itemID, serial)
binary.LittleEndian.PutUint32(raw[16:20], cardID)
raw[20] = upgrade
return raw
}