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,217 @@
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"reflect"
"strings"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
"longheng.io/server/internal/modules/dnf/worldmap"
)
func markRuntimeAsForgottenLand(runtime *runtimeDungeonState) {
runtime.Dungeon.Metadata.DungeonType = "lost land"
runtime.Dungeon.Metadata.LostLandParameters = worldmap.LostLandParameters{
DayMilliseconds: 120000, NightMilliseconds: 120000, Set: true,
}
runtime.Dungeon.Metadata.LimitTime = worldmap.DungeonLimitTime{
PrimarySeconds: 600, SecondarySeconds: 1200, Set: true,
}
}
func TestBuildCurrentForgottenLandTimeInfoBodyMatchesCurrentEXEReader(t *testing.T) {
body := buildCurrentForgottenLandTimeInfoBody(600, 1200)
if len(body) != 8 || binary.LittleEndian.Uint32(body[:4]) != 600 || binary.LittleEndian.Uint32(body[4:]) != 1200 {
t.Fatalf("time info body=%x", body)
}
}
func TestCurrentForgottenLandTimeProjectionUsesTypedPVFLimits(t *testing.T) {
service, session, runtime := newBackToVillageRuntime(t)
markRuntimeAsForgottenLand(runtime)
if err := service.sendCurrentForgottenLandTimeProjection(session, runtime); err != nil {
t.Fatal(err)
}
packet, rest := splitGameServerUpperPacket(t, session.conn.(*bufferConn).write.Bytes())
if packet.Header.Classification != 0 || packet.Header.MsgID != currentForgottenLandTimeInfoMsgID ||
!bytes.Equal(packet.Body, buildCurrentForgottenLandTimeInfoBody(600, 1200)) || len(rest) != 0 {
t.Fatalf("projection header=%+v body=%x trailing=%x", packet.Header, packet.Body, rest)
}
}
func TestForgottenLandEntryProjectsTimeBeforeStartMapAndArmsOneTimer(t *testing.T) {
source := bridgeDungeonPVF(false)
source["dungeon/test.dgn"] = strings.Replace(
source["dungeon/test.dgn"],
"[basis level]\n20\n",
"[basis level]\n20\n[dungeon type]\n`lost land`\n[lost land parameters]\n120000 120000\n[clear condition]\n[limit time]\n600 1200\n[/clear condition]\n",
1,
)
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "99", AccountID: "account-1", Level: 20,
Stats: map[string]int64{
"fatigue": 100, "town_id": 38, "area_id": 1,
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "99", Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
queue := newFakeCurrentDungeonDeathTimerQueue()
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
worldMapTable: table, worldMapResolver: resolver, dungeonMonsterTable: monsters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameplayTimers: queue,
}
connection := &bufferConn{}
session := &gameSession{
conn: connection, connID: "forgotten-land-entry-test", selectedCharacterID: 99,
selectPreviewObjectStateSent: true, currentSceneObjectListSent: true,
}
bindDungeonSelectorOriginForTestAt(t, service, session, 38, 1, 450, 234)
body := make([]byte, 21)
binary.LittleEndian.PutUint32(body[:4], 700)
body[4] = 1
if err := service.handleDungeonSelectUpper(session, body); err != nil {
t.Fatal(err)
}
packets := splitAllCurrentGameServerUpperPackets(t, connection.write.Bytes())
infoIndex, timeIndex, startIndex := -1, -1, -1
for index, packet := range packets {
switch packet.Header.MsgID {
case currentDungeonInfoNotification:
infoIndex = index
case currentForgottenLandTimeInfoMsgID:
timeIndex = index
if packet.Header.Classification != 0 || !bytes.Equal(packet.Body, buildCurrentForgottenLandTimeInfoBody(600, 1200)) {
t.Fatalf("time projection header=%+v body=%x", packet.Header, packet.Body)
}
case currentDungeonStartNotification:
startIndex = index
}
}
if infoIndex < 0 || timeIndex <= infoIndex || startIndex <= timeIndex {
t.Fatalf("entry packet indexes op28=%d op603=%d op29=%d", infoIndex, timeIndex, startIndex)
}
queue.mu.Lock()
forgottenLandTasks := 0
for _, task := range queue.tasks {
if strings.HasPrefix(task.name, "dnf-forgotten-land:") {
forgottenLandTasks++
}
}
forgottenLandTimerActive := queue.active[session.dungeon.runtime.forgottenLandTimerName] != nil
queue.mu.Unlock()
if forgottenLandTasks != 1 || !forgottenLandTimerActive {
t.Fatalf("forgotten land tasks=%d active=%t timer=%q", forgottenLandTasks, forgottenLandTimerActive, session.dungeon.runtime.forgottenLandTimerName)
}
}
func TestCurrentForgottenLandTimerUsesGameplayQueueAndDoesNotReset(t *testing.T) {
service, session, runtime := newBackToVillageRuntime(t)
markRuntimeAsForgottenLand(runtime)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
if err := service.armCurrentForgottenLandTimer(session, runtime); err != nil {
t.Fatal(err)
}
firstDeadline := runtime.forgottenLandDeadline
if err := service.armCurrentForgottenLandTimer(session, runtime); err != nil {
t.Fatal(err)
}
scheduled, cancelled, active := queue.counts()
if scheduled != 1 || cancelled != 0 || active != 1 {
t.Fatalf("timer counts scheduled=%d cancelled=%d active=%d", scheduled, cancelled, active)
}
if runtime.forgottenLandDeadline != firstDeadline || firstDeadline.Sub(queue.Now()) != 600*time.Second {
t.Fatalf("deadline=%s first=%s now=%s", runtime.forgottenLandDeadline, firstDeadline, queue.Now())
}
task := queue.task(t, 0)
if task.delay != 600*time.Second {
t.Fatalf("timer delay=%s", task.delay)
}
connection := session.conn.(*bufferConn)
connection.write.Reset()
queue.fire(task, false)
if got := townPostTransitionPacketSignatures(t, connection.write.Bytes()); !reflect.DeepEqual(
got,
[]string{"op42", "op376", "mode0", "mode1", "op205", "op24", "op559", "op33weakness"},
) {
t.Fatalf("forgotten-land timeout full route=%v", got)
}
if !runtime.townReturnPending || !runtime.townReturnOp24Sent || runtime.forgottenLandTimerActive ||
session.dungeon.runtime != nil || runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunAbandoned {
t.Fatalf("timeout state pending=%t op24=%t timer_active=%t", runtime.townReturnPending, runtime.townReturnOp24Sent, runtime.forgottenLandTimerActive)
}
}
func TestCancelledForgottenLandTimerCannotAffectReplacementRuntime(t *testing.T) {
service, session, runtime := newBackToVillageRuntime(t)
markRuntimeAsForgottenLand(runtime)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
if err := service.armCurrentForgottenLandTimer(session, runtime); err != nil {
t.Fatal(err)
}
task := queue.task(t, 0)
session.dungeon.mu.Lock()
service.cancelCurrentForgottenLandTimerLocked(session, runtime, "test_runtime_replaced")
session.dungeon.runtime = nil
session.dungeon.mu.Unlock()
session.conn.(*bufferConn).write.Reset()
queue.fire(task, true)
if got := session.conn.(*bufferConn).write.Len(); got != 0 {
t.Fatalf("stale timer wrote %d bytes", got)
}
}
func TestInterruptedTownReturnCancelsForgottenLandTimerAndRetiresRun(t *testing.T) {
service, session, runtime := newBackToVillageRuntime(t)
markRuntimeAsForgottenLand(runtime)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
if err := service.armCurrentForgottenLandTimer(session, runtime); err != nil {
t.Fatal(err)
}
task := queue.task(t, 0)
connection := session.conn.(*bufferConn)
connection.write.Reset()
if err := service.handleDungeonBackToVillage(session, nil); err != nil {
t.Fatal(err)
}
_, cancelled, _ := queue.counts()
queue.mu.Lock()
timerStillActive := queue.active[task.name] != nil
queue.mu.Unlock()
if !runtime.townReturnPending || runtime.forgottenLandTimerActive || cancelled != 1 || timerStillActive ||
session.dungeon.runtime != nil || runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunAbandoned {
t.Fatalf("full return pending=%t timer_active=%t cancelled=%d scheduled=%t owner=%p status=%s",
runtime.townReturnPending, runtime.forgottenLandTimerActive, cancelled, timerStillActive,
session.dungeon.runtime, runtime.Session.Snapshot().Run.Status)
}
connection.write.Reset()
queue.fire(task, true)
if connection.write.Len() != 0 {
t.Fatalf("stale forgotten-land timer wrote after retirement=%x", connection.write.Bytes())
}
}