服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
283 lines
12 KiB
Go
283 lines
12 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"testing"
|
||
|
||
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
||
"longheng.io/server/internal/modules/dnf/raid"
|
||
)
|
||
|
||
// TestFiendWarFailureKeepsTheAttackPartyAndPresentsTheBannerSequence pins the two
|
||
// operator rulings Fix D and Fix C implement together (2026-10-01):
|
||
//
|
||
// 「攻坚失败后 所有人应被传送回待机区 并且攻坚队不应该被解散」
|
||
//
|
||
// Three things have to hold at once, and each of them was broken before:
|
||
//
|
||
// 1. The 攻坚队 row must SURVIVE the failure with its squad labels intact. It used
|
||
// to go through abandonRuntimeRaid, which deletes the row: town_move then
|
||
// refused every op36 with AreaID >= 2 (fiendwar_membership_required), including
|
||
// the waiting area, so a member standing in a town operation area could not
|
||
// even walk back.
|
||
// 2. op665 must NOT be sent. sendRuntimeSingleRaidEnded(session, 665, ...) is what
|
||
// makes the client print 46791「已经强制退出攻坚队。」 (raid_online.go:1501-1503)
|
||
// -- the popup the operator reported as the party being disbanded.
|
||
// 3. The failure presentation must publish the 587/601 pair in that order. 601 is
|
||
// the only packet in the whole client that writes raidObj+0x2C, and the banner
|
||
// gate is the conjunction of the 587 state byte and that ResultType byte.
|
||
//
|
||
// The release that follows the hold is asserted too: it must put the client's start
|
||
// gate back to state 0 WITHOUT touching the party, because START_RAID is only
|
||
// accepted from state 0 or 5.
|
||
func TestFiendWarFailureKeepsTheAttackPartyAndPresentsTheBannerSequence(t *testing.T) {
|
||
f := newRuntimeRaidRewardTestFixture(t, true)
|
||
s := f.service
|
||
state := s.raids[f.raidKey]
|
||
state.RaidMode, state.RouteOrRaidType = runtimeRaidOwnerFiendWar, 2
|
||
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
|
||
// Both members fight in squad 1 on both roster rows: the 编制 the failure must
|
||
// hand back untouched.
|
||
for i := range state.Members {
|
||
state.Members[i].GroupIndex, state.Members[i].SlotOrder = 1, byte(i)
|
||
}
|
||
for i := range state.FrozenMembers {
|
||
state.FrozenMembers[i].GroupIndex, state.FrozenMembers[i].SlotOrder = 1, byte(i)
|
||
}
|
||
state.Lifecycle.Objectives = map[int]runtimeRaidDungeonObjective{
|
||
4962: {Phase: runtimeRaidDungeonPhaseOne, MapID: 4962, State: raid.AntonDungeonOpen, MaxHP: 1, RemainingHP: 1},
|
||
}
|
||
queue := &runtimeRaidPhaseTestQueue{now: f.now}
|
||
s.gameplayTimers = queue
|
||
|
||
failed, err := s.failRuntimeFiendWarRaid(state.RaidKey, f.now)
|
||
if err != nil {
|
||
t.Fatalf("fail the Fiend War run: %v", err)
|
||
}
|
||
if failed.Lifecycle.Status != runtimeRaidLifecycleFailed {
|
||
t.Fatalf("failure status = %d, want failed", failed.Lifecycle.Status)
|
||
}
|
||
// (1) The 攻坚队 survives, with every squad label it had.
|
||
current := s.raids[state.RaidKey]
|
||
if current == nil {
|
||
t.Fatal("the failure deleted the 攻坚队 row: town_move then refuses every op36 with AreaID >= 2")
|
||
}
|
||
if len(current.Members) != 2 || countRuntimeRaidGroupedMembers(*current) != 2 {
|
||
t.Fatalf("the failure damaged the roster: members=%d grouped=%d, want 2/2",
|
||
len(current.Members), countRuntimeRaidGroupedMembers(*current))
|
||
}
|
||
if current.Members[0].GroupIndex != 1 || current.Members[1].GroupIndex != 1 {
|
||
t.Fatalf("the failure cleared a squad label: %d/%d",
|
||
current.Members[0].GroupIndex, current.Members[1].GroupIndex)
|
||
}
|
||
|
||
for _, peer := range []*gameSession{f.leader, f.member} {
|
||
peer.conn.(*bufferConn).write.Reset()
|
||
}
|
||
if err := s.publishFiendWarFailurePresentation(failed); err != nil {
|
||
t.Fatalf("publish failure presentation: %v", err)
|
||
}
|
||
// 601 **必须排在 1453{6,0} 之前**。客户端反汇编(DNF.exe 8A7AF614…AA4D):
|
||
// 1453 的分派器 0x009B2910(byte* body) 按 body[0] ∈ 1..6 走跳表
|
||
// [eax*4 + 0x9B2B7C];case 6 的实体 0x009B2B18 读 raidObj+0x2C 决定发哪条文案
|
||
// (== 2 → 75518 破坏成功;== 3 → 75503 未能破坏),而 raidObj+0x2C 全客户端只有
|
||
// 601 的处理器会写(sub_1D6B840 尾部 0x01D6C3E8)。601 排在后面时,case 6 读到的是
|
||
// 构造函数留下的旧值 —— 真机上就是"攻坚失败一条提示都没有"。
|
||
want := []uint16{raid.RaidResultMsgID, 1453, raid.RaidDungeonStateMsgID, raid.RaidRemainTimeMsgID, raid.RaidStateMsgID}
|
||
for _, peer := range []*gameSession{f.leader, f.member} {
|
||
got := upperMsgIDSequence(t, peer.conn.(*bufferConn).write.Bytes())
|
||
// (2) No disband popup, ever.
|
||
for _, msgID := range got {
|
||
if msgID == 665 {
|
||
t.Fatalf("char %d received op665: that is the 「已经强制退出攻坚队。」 popup",
|
||
peer.selectedCharacterID)
|
||
}
|
||
}
|
||
if !isMsgIDSubsequence(got, want) {
|
||
t.Fatalf("char %d payload order = %v, want the subsequence %v (banner result FIRST, then board close, closed gates, zeroed clock, banner state)",
|
||
peer.selectedCharacterID, got, want)
|
||
}
|
||
}
|
||
// 结果类型本身也要钉:1 是安东/卢克那个通用值,超时空的失败文案要的是 3。
|
||
if runtimeRaidFiendWarFailResultType != 3 {
|
||
t.Fatalf("超时空失败的结果类型 = %d, want 3 —— 客户端 case 5/6 只认 2(成功)/3(失败),"+
|
||
"发 1 时 75503「未能破坏量子传送站。」永远不会出现", runtimeRaidFiendWarFailResultType)
|
||
}
|
||
// The state byte is asserted against the client's own value, not against the
|
||
// named constant: the client gate needs state 6 and the constant must therefore
|
||
// BE 6. (A test that only compares the constant with itself cannot fail when the
|
||
// constant is wrong.)
|
||
if runtimeRaidSettlementStateFinished != 6 || runtimeRaidFiendWarFailureState != runtimeRaidSettlementStateFinished {
|
||
t.Fatalf("the Fiend War failure state byte = %d, want the client's state 6",
|
||
runtimeRaidFiendWarFailureState)
|
||
}
|
||
if got := encodedRaidStateForTest(t, f.leader, raid.RaidStateMsgID); got != runtimeRaidSettlementStateFinished {
|
||
t.Fatalf("587 state = %d, want the client's failure state 6", got)
|
||
}
|
||
// The hold is a named constant and the release is armed, not immediate.
|
||
if queue.scheduleCalls == 0 || queue.scheduledDelay != runtimeRaidFiendWarFailureHold {
|
||
t.Fatalf("release scheduled calls=%d delay=%v, want the named hold %v",
|
||
queue.scheduleCalls, queue.scheduledDelay, runtimeRaidFiendWarFailureHold)
|
||
}
|
||
// (3) The release re-opens the start gate and leaves the party alone.
|
||
for _, peer := range []*gameSession{f.leader, f.member} {
|
||
peer.conn.(*bufferConn).write.Reset()
|
||
}
|
||
queue.callback(queue.now.Add(runtimeRaidFiendWarFailureHold))
|
||
for _, peer := range []*gameSession{f.leader, f.member} {
|
||
if got := encodedRaidStateForTest(t, peer, raid.RaidStateMsgID); got != runtimeRaidFiendWarFailureReleaseState {
|
||
t.Fatalf("char %d release state = %d, want %d (only 0 and 5 permit START_RAID)",
|
||
peer.selectedCharacterID, got, runtimeRaidFiendWarFailureReleaseState)
|
||
}
|
||
}
|
||
after := s.raids[state.RaidKey]
|
||
if after == nil {
|
||
t.Fatal("the release deleted the 攻坚队 row")
|
||
}
|
||
if len(after.Members) != 2 || countRuntimeRaidGroupedMembers(*after) != 2 {
|
||
t.Fatalf("the release damaged the roster: members=%d grouped=%d, want 2/2",
|
||
len(after.Members), countRuntimeRaidGroupedMembers(*after))
|
||
}
|
||
if after.Started || after.StartPending || after.EntryCostPaid || len(after.FrozenMembers) != 0 {
|
||
t.Fatalf("the release did not return the raid to its lobby: started=%t pending=%t paid=%t frozen=%d",
|
||
after.Started, after.StartPending, after.EntryCostPaid, len(after.FrozenMembers))
|
||
}
|
||
}
|
||
|
||
// TestRealPVFFiendWarFailureReturnsEveryoneToTheWaitingArea covers the half of Fix D
|
||
// the unit test cannot: publishFailedFiendWar's TOWN branch.
|
||
//
|
||
// The old tail handled only members physically inside a dungeon
|
||
// (raid_fiendwar_battle.go returned early when session.dungeon.runtime was nil) and
|
||
// answered everybody else with op665. A member standing in a Fiend War town operation
|
||
// area therefore never moved, and because the tail had also deleted the 攻坚队 row,
|
||
// town_move refused every later op36 with AreaID >= 2 (fiendwar_membership_required)
|
||
// -- including the waiting area itself -- so his client hung where it stood.
|
||
//
|
||
// This drives the real composition (failRuntimeFiendWarRaid + publishFailedFiendWar)
|
||
// against the deployed Script.pvf and asserts that both members end in the waiting
|
||
// room 98/2 with the 攻坚队 and its 编制 intact and no disband popup.
|
||
func TestRealPVFFiendWarFailureReturnsEveryoneToTheWaitingArea(t *testing.T) {
|
||
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
|
||
if path == "" {
|
||
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
|
||
}
|
||
f := newRuntimeRaidRewardTestFixture(t, true)
|
||
s := f.service
|
||
s.options.pvfPath = path
|
||
if err := s.preloadTownCatalog(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
config, err := s.runtimeRaidFiendWarConfig()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
state := s.raids[f.raidKey]
|
||
state.RaidMode, state.RouteOrRaidType = runtimeRaidOwnerFiendWar, 2
|
||
state.AntonConfig, state.FiendWarConfig = nil, config
|
||
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
|
||
state.Lifecycle.FiendWar, err = raid.NewFiendWarBattle(config.Battle)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
state.Lifecycle.FiendWar.GroupAreas[1] = int(currentFiendWarWaitingAreaID)
|
||
for i := range state.Members {
|
||
state.Members[i].GroupIndex, state.Members[i].SlotOrder = 1, byte(i)
|
||
}
|
||
for i := range state.FrozenMembers {
|
||
state.FrozenMembers[i].GroupIndex, state.FrozenMembers[i].SlotOrder = 1, byte(i)
|
||
}
|
||
s.onlinePlayers = newOnlinePlayerManager()
|
||
for _, peer := range []*gameSession{f.leader, f.member} {
|
||
peer.channel = channelcatalog.Channel{ID: 261, Type: 42}
|
||
peer.conn.(*bufferConn).write.Reset()
|
||
}
|
||
// Both members are in the town scene, not inside a dungeon: exactly the case the
|
||
// old tail never touched.
|
||
bindDungeonSelectorOriginForTestAt(t, s, f.leader, 98, 6, 177, 597)
|
||
bindDungeonSelectorOriginForTestAt(t, s, f.member, 98, 9, 105, 320)
|
||
|
||
failed, err := s.failRuntimeFiendWarRaid(state.RaidKey, f.now)
|
||
if err != nil {
|
||
t.Fatalf("fail the Fiend War run: %v", err)
|
||
}
|
||
if err := s.publishFailedFiendWar(failed); err != nil {
|
||
t.Fatalf("publish the Fiend War failure: %v", err)
|
||
}
|
||
|
||
after := s.raids[state.RaidKey]
|
||
if after == nil {
|
||
t.Fatal("攻坚失败后攻坚队被解散了:the raid row was deleted")
|
||
}
|
||
if len(after.Members) != 2 || countRuntimeRaidGroupedMembers(*after) != 2 {
|
||
t.Fatalf("the failure damaged the 编制: members=%d grouped=%d, want 2/2",
|
||
len(after.Members), countRuntimeRaidGroupedMembers(*after))
|
||
}
|
||
for _, peer := range []*gameSession{f.leader, f.member} {
|
||
peer.townMu.Lock()
|
||
position := peer.townPositionSnapshot
|
||
ready := peer.townSceneReadyCharacterID
|
||
peer.townMu.Unlock()
|
||
if position.TownID != byte(currentFiendWarRaidTownID) ||
|
||
position.AreaID != currentFiendWarWaitingAreaID ||
|
||
!position.PositionValid {
|
||
t.Fatalf("char %d landed at town %d area %d (valid=%t), want the waiting room %d/%d",
|
||
peer.selectedCharacterID, position.TownID, position.AreaID, position.PositionValid,
|
||
currentFiendWarRaidTownID, currentFiendWarWaitingAreaID)
|
||
}
|
||
if ready != peer.selectedCharacterID {
|
||
t.Fatalf("char %d waiting-area scene was not finalized (ready=%d)",
|
||
peer.selectedCharacterID, ready)
|
||
}
|
||
for _, msgID := range upperMsgIDSequence(t, peer.conn.(*bufferConn).write.Bytes()) {
|
||
if msgID == 665 {
|
||
t.Fatalf("char %d received op665: that is the 「已经强制退出攻坚队。」 popup",
|
||
peer.selectedCharacterID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// upperMsgIDSequence lists the message ids one session received, in order.
|
||
func upperMsgIDSequence(t *testing.T, raw []byte) []uint16 {
|
||
t.Helper()
|
||
var ids []uint16
|
||
for len(raw) != 0 {
|
||
packet, rest := splitGameServerUpperPacket(t, raw)
|
||
raw = rest
|
||
ids = append(ids, packet.Header.MsgID)
|
||
}
|
||
return ids
|
||
}
|
||
|
||
// isMsgIDSubsequence reports whether want appears in got in that relative order.
|
||
func isMsgIDSubsequence(got, want []uint16) bool {
|
||
index := 0
|
||
for _, id := range got {
|
||
if index < len(want) && id == want[index] {
|
||
index++
|
||
}
|
||
}
|
||
return index == len(want)
|
||
}
|
||
|
||
// encodedRaidStateForTest reads the last 0x024B state byte one session received.
|
||
func encodedRaidStateForTest(t *testing.T, session *gameSession, msgID uint16) byte {
|
||
t.Helper()
|
||
raw := append([]byte(nil), session.conn.(*bufferConn).write.Bytes()...)
|
||
state := byte(0xff)
|
||
for len(raw) != 0 {
|
||
packet, rest := splitGameServerUpperPacket(t, raw)
|
||
raw = rest
|
||
if packet.Header.MsgID != msgID || len(packet.Body) < 1 {
|
||
continue
|
||
}
|
||
state = packet.Body[0]
|
||
}
|
||
if state == 0xff {
|
||
t.Fatalf("char %d never received a %d notification", session.selectedCharacterID, msgID)
|
||
}
|
||
return state
|
||
}
|