服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
190 lines
7.4 KiB
Go
190 lines
7.4 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/raid"
|
||
"longheng.io/server/internal/platform/timequeue"
|
||
)
|
||
|
||
// 「开始/成功都有横幅,失败没有」——失败那一条的包**一个都不缺**,缺的是**顺序**。
|
||
//
|
||
// 真机 2026-10-02 00:00:36(raid 12058625,分片 20261002-001)逐条:
|
||
//
|
||
// 16:00:36.619 1453{6,0} / 585 / 597{0,0} / 587{6,1} / 601{ResultType 1} ← 门点亮
|
||
// 16:00:36.621 game-initial-town-route-armed
|
||
// 16:00:36.666 game-dungeon-full-town-route-send source=fiendwar_failed_waiting_area
|
||
// 16:00:36.717 同上(另一个成员)
|
||
// 16:00:36.927 game-dungeon-full-town-route-committed
|
||
// 16:00:41.620 587{0} 释放(+591/633/753 的收尾)
|
||
//
|
||
// 也就是说横幅门点亮之后 **48 毫秒**客户端就收到了回待机区的场景切换,5 秒的"演出
|
||
// 时间"全花在一个已经被换掉的场景上。客户端那个 30210 横幅是逐帧判断
|
||
// `raidObj+0x28 == 6 && raidObj+0x2C == 1` 的(FUN_01f67bc0,
|
||
// tools/_fw3_fixes_spec.md:240-256、:313-318),所以修法是在待机区落地之后
|
||
// **把同一对字节再点一次**:电平判断重复发同值只重新点亮,不改任何状态。
|
||
//
|
||
// 本用例钉住三件事:
|
||
//
|
||
// ① 上弦两件事:先补灯(+2s)、后释放(+5s),补灯必须是先臂上的那个;
|
||
// ② 补灯真的再发一次 587{6,1} 与 601{ResultType 1}(两个成员各一份);
|
||
// ③ 释放之后再跑补灯是**空转** —— 攻坚队已经回到大厅,不许再点亮横幅。
|
||
func TestFiendWarFailureBannerIsRelitAfterTheWaitingRoomTeleport(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
|
||
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 := &fiendWarFailureScheduleQueue{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 err := s.publishFiendWarFailurePresentation(failed); err != nil {
|
||
t.Fatalf("publish failure presentation: %v", err)
|
||
}
|
||
|
||
// ① 先补灯、后释放。
|
||
reassertIndex := queue.indexOf("fiendwar-failure-banner:")
|
||
releaseIndex := queue.indexOf("fiendwar-failure-release:")
|
||
if reassertIndex < 0 || releaseIndex < 0 {
|
||
t.Fatalf("失败收尾只上弦了 %v,want 补灯 + 释放两条", queue.names())
|
||
}
|
||
if reassertIndex > releaseIndex {
|
||
t.Fatalf("补灯排在释放之后(%v):待机区落地时门已经被 587{0} 放掉了", queue.names())
|
||
}
|
||
if got := queue.delays()[reassertIndex]; got != runtimeRaidFiendWarFailureBannerReassert {
|
||
t.Fatalf("补灯的延后 = %s, want %s", got, runtimeRaidFiendWarFailureBannerReassert)
|
||
}
|
||
if got := queue.delays()[releaseIndex]; got != runtimeRaidFiendWarFailureHold {
|
||
t.Fatalf("释放的延后 = %s, want %s", got, runtimeRaidFiendWarFailureHold)
|
||
}
|
||
|
||
// ② 补灯真的再发一对。
|
||
for _, peer := range []*gameSession{f.leader, f.member} {
|
||
peer.conn.(*bufferConn).write.Reset()
|
||
}
|
||
queue.callbacks[reassertIndex](f.now.Add(runtimeRaidFiendWarFailureBannerReassert))
|
||
for _, peer := range []*gameSession{f.leader, f.member} {
|
||
raw := peer.conn.(*bufferConn).write.Bytes()
|
||
stateByte, stateFound := fixtureLastRaidStateByte(t, raw)
|
||
if !stateFound || stateByte != runtimeRaidFiendWarFailureState {
|
||
t.Fatalf("char %d 补灯后没有 587{%d}(读到 %d, found=%t):横幅门的 state 那一半没点亮",
|
||
peer.selectedCharacterID, runtimeRaidFiendWarFailureState, stateByte, stateFound)
|
||
}
|
||
resultType, resultFound := fixtureLastRaidResultType(t, raw)
|
||
if !resultFound || resultType != runtimeRaidFiendWarFailResultType {
|
||
t.Fatalf("char %d 补灯后没有 601{ResultType %d}(读到 %d, found=%t):"+
|
||
"raidObj+0x2C 只有 601 能写,少了它就只剩一半的门",
|
||
peer.selectedCharacterID, runtimeRaidFiendWarFailResultType, resultType, resultFound)
|
||
}
|
||
}
|
||
|
||
// ③ 释放之后补灯空转。
|
||
for _, peer := range []*gameSession{f.leader, f.member} {
|
||
peer.conn.(*bufferConn).write.Reset()
|
||
}
|
||
queue.callbacks[releaseIndex](f.now.Add(runtimeRaidFiendWarFailureHold))
|
||
after := make([][]byte, 2)
|
||
for i, peer := range []*gameSession{f.leader, f.member} {
|
||
after[i] = append([]byte(nil), peer.conn.(*bufferConn).write.Bytes()...)
|
||
peer.conn.(*bufferConn).write.Reset()
|
||
}
|
||
queue.callbacks[reassertIndex](f.now.Add(runtimeRaidFiendWarFailureHold + time.Second))
|
||
for i, peer := range []*gameSession{f.leader, f.member} {
|
||
if wrote := peer.conn.(*bufferConn).write.Bytes(); len(wrote) != 0 {
|
||
t.Fatalf("释放之后补灯还在写信箱(char %d, % x);释放前那一段是 % x",
|
||
peer.selectedCharacterID, wrote, after[i])
|
||
}
|
||
if current := s.raids[state.RaidKey]; current == nil || current.Lifecycle.Status == runtimeRaidLifecycleFailed {
|
||
t.Fatalf("释放没有把攻坚队带回大厅:status=%v", current)
|
||
}
|
||
}
|
||
}
|
||
|
||
// fiendWarFailureScheduleQueue 记录**全部**上弦项(共享的
|
||
// runtimeRaidPhaseTestQueue 只留最后一次,补灯那一条会被释放覆盖掉)。
|
||
type fiendWarFailureScheduleQueue struct {
|
||
now time.Time
|
||
names_ []string
|
||
delays_ []time.Duration
|
||
callbacks []timequeue.Callback
|
||
}
|
||
|
||
func (q *fiendWarFailureScheduleQueue) Now() time.Time { return q.now }
|
||
|
||
func (q *fiendWarFailureScheduleQueue) ScheduleAfter(
|
||
name string,
|
||
delay time.Duration,
|
||
callback timequeue.Callback,
|
||
) error {
|
||
q.names_ = append(q.names_, name)
|
||
q.delays_ = append(q.delays_, delay)
|
||
q.callbacks = append(q.callbacks, callback)
|
||
return nil
|
||
}
|
||
|
||
func (q *fiendWarFailureScheduleQueue) RegisterDaily(string, int, int, timequeue.Callback) error {
|
||
return nil
|
||
}
|
||
|
||
func (q *fiendWarFailureScheduleQueue) Cancel(string) bool { return false }
|
||
|
||
func (q *fiendWarFailureScheduleQueue) Start(context.Context) {}
|
||
|
||
func (q *fiendWarFailureScheduleQueue) names() []string { return q.names_ }
|
||
|
||
func (q *fiendWarFailureScheduleQueue) delays() []time.Duration { return q.delays_ }
|
||
|
||
func (q *fiendWarFailureScheduleQueue) indexOf(prefix string) int {
|
||
for index, name := range q.names_ {
|
||
if len(name) >= len(prefix) && name[:len(prefix)] == prefix {
|
||
return index
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// fixtureLastRaidStateByte 读最后一条 587 的 state 字节。
|
||
func fixtureLastRaidStateByte(t *testing.T, raw []byte) (byte, bool) {
|
||
t.Helper()
|
||
value, found := byte(0), false
|
||
for len(raw) != 0 {
|
||
packet, rest := splitGameServerUpperPacket(t, raw)
|
||
raw = rest
|
||
if packet.Header.MsgID != raid.RaidStateMsgID || len(packet.Body) < 1 {
|
||
continue
|
||
}
|
||
value, found = packet.Body[0], true
|
||
}
|
||
return value, found
|
||
}
|
||
|
||
// fixtureLastRaidResultType 读最后一条 601 的 ResultType(首字节)。
|
||
func fixtureLastRaidResultType(t *testing.T, raw []byte) (byte, bool) {
|
||
t.Helper()
|
||
value, found := byte(0), false
|
||
for len(raw) != 0 {
|
||
packet, rest := splitGameServerUpperPacket(t, raw)
|
||
raw = rest
|
||
if packet.Header.MsgID != raid.RaidResultMsgID || len(packet.Body) != 10 {
|
||
continue
|
||
}
|
||
value, found = packet.Body[0], true
|
||
}
|
||
return value, found
|
||
}
|