Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

266 lines
11 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"errors"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
func TestCommitDungeonRuntimeAlwaysCreatesFreshDungeonGeneration(t *testing.T) {
service, session, first := newBackToVillageRuntime(t)
if first.lifecycleToken == 0 || session.dungeon.runtime != first {
t.Fatalf("initial runtime token=%d owner=%p", first.lifecycleToken, session.dungeon.runtime)
}
session.dungeon.mu.Lock()
session.dungeon.debugLightningPending = true
session.dungeon.deathTower = newDeathTowerRuntime([]int{30001}, 60)
session.dungeon.mu.Unlock()
if err := first.Session.Abandon(); err != nil {
t.Fatal(err)
}
next, _, err := service.prepareDungeonRuntime(
context.Background(),
session,
dungeoncmd.SelectDungeonRequest{DungeonID: 700, Difficulty: 1},
)
if err != nil {
t.Fatal(err)
}
if err := freezeCurrentDungeonTownReturnOrigin(session, next); err != nil {
t.Fatal(err)
}
oldToken := first.lifecycleToken
if err := service.commitDungeonRuntime(session, next); err != nil {
t.Fatal(err)
}
session.dungeon.mu.Lock()
gotRuntime := session.dungeon.runtime
gotToken := session.dungeon.runToken
debugPending := session.dungeon.debugLightningPending
deathTower := session.dungeon.deathTower
session.dungeon.mu.Unlock()
if gotRuntime != next || gotToken == 0 || gotToken != next.lifecycleToken || gotToken == oldToken {
t.Fatalf("fresh runtime=%p want=%p old_token=%d new_token=%d session_token=%d", gotRuntime, next, oldToken, next.lifecycleToken, gotToken)
}
if !first.payloadReleased {
t.Fatal("replaced dungeon payload was not released")
}
if debugPending || deathTower != nil {
t.Fatalf("fresh state debug=%t death_tower=%p", debugPending, deathTower)
}
}
func TestRetireCurrentDungeonRuntimeLockedClearsDetachedSessionOwners(t *testing.T) {
service, session, runtime := newBackToVillageRuntime(t)
session.dungeon.mu.Lock()
session.dungeon.debugLightningPending = true
session.dungeon.deathTower = newDeathTowerRuntime([]int{30001}, 60)
service.retireCurrentDungeonRuntimeLocked(session, runtime, "test_force_or_completed_return")
gotRuntime := session.dungeon.runtime
gotToken := session.dungeon.runToken
debugPending := session.dungeon.debugLightningPending
deathTower := session.dungeon.deathTower
session.dungeon.mu.Unlock()
if gotRuntime != nil || gotToken != 0 || debugPending || deathTower != nil || !runtime.payloadReleased ||
runtime.lifecycleState != currentDungeonRuntimeRetired || runtime.lifecycleEndReason != "test_force_or_completed_return" {
t.Fatalf("retired runtime=%p token=%d debug=%t death_tower=%p released=%t lifecycle=%d reason=%q",
gotRuntime, gotToken, debugPending, deathTower, runtime.payloadReleased,
runtime.lifecycleState, runtime.lifecycleEndReason)
}
}
func TestRetiredDungeonRuntimeGenerationCannotBeCommittedAgain(t *testing.T) {
service, session, runtime := newBackToVillageRuntime(t)
session.dungeon.mu.Lock()
service.retireCurrentDungeonRuntimeLocked(session, runtime, "test_retired_generation")
session.dungeon.mu.Unlock()
err := service.commitDungeonRuntime(session, runtime)
if !errors.Is(err, errDungeonRuntimeRetired) {
t.Fatalf("commit retired runtime error=%v, want %v", err, errDungeonRuntimeRetired)
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
if session.dungeon.runtime != nil || session.dungeon.runToken != 0 {
t.Fatalf("retired runtime became current runtime=%p token=%d",
session.dungeon.runtime, session.dungeon.runToken)
}
}
func TestDisconnectWithoutWorldmapRuntimeClearsDetachedDungeonOwners(t *testing.T) {
service := &Service{}
session := &gameSession{selectedCharacterID: 99}
session.dungeon.runToken = 77
session.dungeon.deathTower = newDeathTowerRuntime([]int{30001}, 60)
session.dungeon.debugLightningPending = true
service.releaseDisconnectedDungeonAttempt(session, "test_disconnect_without_worldmap_runtime")
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
if session.dungeon.runtime != nil || session.dungeon.runToken != 0 ||
session.dungeon.deathTower != nil || session.dungeon.debugLightningPending {
t.Fatalf("detached dungeon scope leaked runtime=%p token=%d death_tower=%p debug=%t",
session.dungeon.runtime, session.dungeon.runToken,
session.dungeon.deathTower, session.dungeon.debugLightningPending)
}
}
func TestFreshSoloDungeonFinishLoadingResetsConsumableGateWithoutInventingHellPartyData(t *testing.T) {
service, session, _ := newBackToVillageRuntime(t)
connection := session.conn.(*bufferConn)
connection.write.Reset()
// The actor is already created by the post-op29 chain. Keep the unrelated
// post-loading projection complete so this test isolates the native
// op37/op30 ordering and the absence of an invented Hell Party snapshot.
session.postStartMapPlayerStateSent = true
session.postFinishLoadingPlayerStateSent = true
if err := service.sendFinishLoadingStatus(session, make([]byte, currentFinishLoadingLegacyRequestBodySize)); err != nil {
t.Fatal(err)
}
status, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if status.Header.Classification != dnfproto.DefaultChannelClassification ||
status.Header.MsgID != uint16(dnfenum.CmdPacketFinishLoading) ||
!bytes.Equal(status.Body, []byte{1}) {
t.Fatalf("finish status=%+v body=%x", status.Header, status.Body)
}
state, rest := splitGameServerUpperPacket(t, rest)
if state.Header.Classification != 0 || state.Header.MsgID != uint16(dnfenum.CmdPacketFinishLoading) ||
len(state.Body) != currentFinishLoadingCharacterStateBodySize {
t.Fatalf("finish state=%+v body_len=%d", state.Header, len(state.Body))
}
commit, rest := splitGameServerUpperPacket(t, rest)
if commit.Header.Classification != 0 || commit.Header.MsgID != currentIncreaseStatusResultMsgID ||
!bytes.Equal(commit.Body, make([]byte, currentIncreaseStatusResultBodySize)) {
t.Fatalf("finish commit=%+v body=%x", commit.Header, commit.Body)
}
reset, rest := splitGameServerUpperPacket(t, rest)
if reset.Header.Classification != 0 ||
reset.Header.MsgID != currentDungeonConsumableUseGateResetWireOpcode ||
len(reset.Body) != 0 {
t.Fatalf("consumable gate reset=%+v body=%x", reset.Header, reset.Body)
}
if len(rest) != 0 {
extra, _ := splitGameServerUpperPacket(t, rest)
t.Fatalf("ordinary finish-loading emitted extra packet after reset=%+v body=%x", extra.Header, extra.Body)
}
}
func TestFreshSoloDungeonConsumableGateResetFollowsOp120ActorProjection(t *testing.T) {
service, session, runtime := newBackToVillageRuntime(t)
connection := session.conn.(*bufferConn)
connection.write.Reset()
session.postStartMapPlayerStateSent = true
if err := service.sendFinishLoadingStatus(
session,
make([]byte, currentFinishLoadingLegacyRequestBodySize),
); err != nil {
t.Fatal(err)
}
packets := splitAllCurrentGameServerUpperPackets(t, connection.write.Bytes())
placementIndex := -1
resetIndex := -1
for index, packet := range packets {
if packet.Header.Classification != 0 {
continue
}
switch packet.Header.MsgID {
case uint16(dnfenum.CmdPacketRequestBlacklist):
placementIndex = index
case currentDungeonConsumableUseGateResetWireOpcode:
resetIndex = index
}
}
if placementIndex < 0 || resetIndex <= placementIndex || resetIndex != len(packets)-1 {
t.Fatalf("finish-loading packet order placement=%d reset=%d count=%d packets=%+v",
placementIndex, resetIndex, len(packets), packets)
}
reset := packets[resetIndex]
if len(reset.Body) != 0 || !runtime.consumableUseGateResetSent {
t.Fatalf("final reset body=%x sent=%t", reset.Body, runtime.consumableUseGateResetSent)
}
}
func TestFreshSoloDungeonConsumableGateResetRetriesAfterOp37Op30WithoutReplay(t *testing.T) {
service, session, runtime := newBackToVillageRuntime(t)
session.postStartMapPlayerStateSent = true
session.postFinishLoadingPlayerStateSent = true
wantErr := errors.New("consumable gate reset write failed")
failing := &failNthDungeonWriteConn{failAt: 4, err: wantErr}
session.conn = failing
err := service.sendFinishLoadingStatus(
session,
make([]byte, currentFinishLoadingLegacyRequestBodySize),
)
if !errors.Is(err, wantErr) {
t.Fatalf("first finish-loading error=%v want=%v", err, wantErr)
}
if runtime.consumableUseGateResetSent {
t.Fatal("failed class0/op42 write consumed the dungeon-generation reset")
}
first := splitAllCurrentGameServerUpperPackets(t, failing.write.Bytes())
if len(first) != 3 ||
first[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishLoading) ||
first[0].Header.Classification != dnfproto.DefaultChannelClassification ||
first[1].Header.MsgID != uint16(dnfenum.CmdPacketFinishLoading) ||
first[1].Header.Classification != 0 ||
first[2].Header.MsgID != currentIncreaseStatusResultMsgID ||
first[2].Header.Classification != 0 {
t.Fatalf("first finish-loading writes=%+v", first)
}
resume := &bufferConn{}
session.conn = resume
if err := service.sendFinishLoadingStatus(
session,
make([]byte, currentFinishLoadingLegacyRequestBodySize),
); err != nil {
t.Fatalf("retry finish-loading: %v", err)
}
retried := splitAllCurrentGameServerUpperPackets(t, resume.write.Bytes())
if len(retried) != 2 ||
retried[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishLoading) ||
retried[0].Header.Classification != dnfproto.DefaultChannelClassification ||
retried[1].Header.MsgID != currentDungeonConsumableUseGateResetWireOpcode ||
retried[1].Header.Classification != 0 || len(retried[1].Body) != 0 {
t.Fatalf("retry writes=%+v", retried)
}
if !runtime.consumableUseGateResetSent {
t.Fatal("successful class0/op42 retry did not consume generation reset")
}
}
func TestChangingSelectedCharacterRetiresOldDungeonScope(t *testing.T) {
service, session, runtime := newBackToVillageRuntime(t)
session.dungeon.mu.Lock()
session.dungeon.deathTower = newDeathTowerRuntime([]int{30001}, 60)
session.dungeon.debugLightningPending = true
session.dungeon.mu.Unlock()
service.bindGameSessionCharacter(session, 100)
if session.selectedCharacterID != 100 {
t.Fatalf("selected character=%d want=100", session.selectedCharacterID)
}
session.dungeon.mu.Lock()
gotRuntime := session.dungeon.runtime
gotToken := session.dungeon.runToken
deathTower := session.dungeon.deathTower
debugPending := session.dungeon.debugLightningPending
session.dungeon.mu.Unlock()
if gotRuntime != nil || gotToken != 0 || deathTower != nil || debugPending || !runtime.payloadReleased {
t.Fatalf("changed-character dungeon state runtime=%p token=%d death_tower=%p debug=%t released=%t",
gotRuntime, gotToken, deathTower, debugPending, runtime.payloadReleased)
}
}