服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
266 lines
11 KiB
Go
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)
|
|
}
|
|
}
|