服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
186 lines
8.6 KiB
Go
186 lines
8.6 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"encoding/binary"
|
||
"os"
|
||
"strconv"
|
||
"testing"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
||
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
||
"longheng.io/server/internal/modules/dnf/raid"
|
||
)
|
||
|
||
// 真机 2026-10-02 12:34~12:38(raid 12058625,分片 012)的**原样复刻**:
|
||
//
|
||
// 04:34:58 char 21 走进 306000460(下侧 BOSS 房)→ bot boss state = 4
|
||
// 04:35:02 char 20 走进 306000463(上侧 BOSS 房)→ top = 4,且 bot 已是 4
|
||
// ⇒ PVF rule 131/132 触发 boss pattern init ⇒ 两侧各分到一颗球、pattern = 1111
|
||
// 04:35:35 起 char 1(上侧队长)与 char 22(下侧队长)各自反复报 kind=1 value=5
|
||
// 服务端:rejected 0、dropped 0 —— 但机制**不推进**,球永远是同一颗。
|
||
//
|
||
// 本用例用**真 PVF 配置**把这段摆出来,然后要求:两队各自打掉**自己手上那颗球**
|
||
// 之后,机制条按颗数掉位(1111 → 111 → 11),而且打自己那颗不影响对面。
|
||
func TestRealPVFFiendWarRoshanPatternAdvancesFromBothSidesReports(t *testing.T) {
|
||
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
|
||
if path == "" {
|
||
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
|
||
}
|
||
service := &Service{gameSessions: map[uint16]*gameSession{}}
|
||
service.options.pvfPath = path
|
||
config, err := service.runtimeRaidFiendWarConfig()
|
||
if err != nil {
|
||
t.Fatalf("真 PVF 配置: %v", err)
|
||
}
|
||
battle, err := raid.NewFiendWarBattle(config.Battle)
|
||
if err != nil {
|
||
t.Fatalf("战场: %v", err)
|
||
}
|
||
// 真机顺序:先下侧进 BOSS 房,再上侧。
|
||
for _, mapID := range []int{306000460, 306000463} {
|
||
if err := battle.Apply(raid.FiendWarEvent{Kind: "on enter map", ID: mapID}, time.Now().UTC()); err != nil {
|
||
t.Fatalf("on enter map %d: %v", mapID, err)
|
||
}
|
||
}
|
||
topClone := battle.Value("top boss state")
|
||
botClone := battle.Value("bot boss state")
|
||
// 客户端 0x9D80A0 那张表:5=宝石/虚无 6=火 7=冰 8=风。
|
||
if topClone < 5 || topClone > 8 {
|
||
t.Fatalf("上侧 state=%d,want 5..8(两侧都进 BOSS 房之后应当 pattern init 分球)", topClone)
|
||
}
|
||
if botClone < 5 || botClone > 8 || botClone == topClone {
|
||
t.Fatalf("两侧应当拿到两颗**不同**的球:top=%d bot=%d", topClone, botClone)
|
||
}
|
||
if got := battle.Value("boss pattern state"); got != 1111 {
|
||
t.Fatalf("机制条=%d,want 1111", got)
|
||
}
|
||
// 真机上这两个区域此刻是"被占"的(Enter 成功过)。dnfbridge 侧没有 set,
|
||
// 所以直接按战场语义摆好 —— BossReport 的第一道门就是这一条。
|
||
battle.Areas[30] = raid.FiendWarAreaState{Group: 1}
|
||
battle.Areas[31] = raid.FiendWarAreaState{Group: 2}
|
||
|
||
const raidKey = uint32(0x00c80440)
|
||
topSeat := runtimeRaidDungeonSeat{Group: 1}
|
||
botSeat := runtimeRaidDungeonSeat{Group: 2}
|
||
members := []runtimeRaidMemberState{
|
||
{CharID: 1, Name: "top-leader", GroupIndex: 1, SlotOrder: 0},
|
||
{CharID: 20, Name: "top-member", GroupIndex: 2, SlotOrder: 0},
|
||
{CharID: 22, Name: "bot-leader", GroupIndex: 3, SlotOrder: 0},
|
||
{CharID: 21, Name: "bot-member", GroupIndex: 4, SlotOrder: 0},
|
||
}
|
||
// 真机:上侧的 run 是 char 20 开的(token=11),下侧是 char 21 开的(token=13);
|
||
// 两个队长(char 1 / char 22)都是 adopt 进来的。
|
||
topProgress := runtimeRaidDungeonProgress{
|
||
Phase: 1, MapID: 4978, Seat: topSeat, CharacterID: 20,
|
||
LifecycleToken: 11, Status: runtimeRaidDungeonProgressLoaded,
|
||
}
|
||
botProgress := runtimeRaidDungeonProgress{
|
||
Phase: 1, MapID: 4953, Seat: botSeat, CharacterID: 21,
|
||
LifecycleToken: 13, Status: runtimeRaidDungeonProgressLoaded,
|
||
}
|
||
state := &runtimeRaidState{
|
||
RaidKey: raidKey, LeaderID: 1, Started: true, EntryCostPaid: true,
|
||
RaidMode: runtimeRaidOwnerFiendWar,
|
||
Members: append([]runtimeRaidMemberState(nil), members...),
|
||
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
|
||
Lifecycle: runtimeRaidLifecycleState{
|
||
Status: runtimeRaidLifecyclePhaseActive, Phase: 1, FiendWar: battle,
|
||
GroupOccupancy: map[runtimeRaidDungeonSeat]runtimeRaidDungeonBinding{
|
||
topSeat: {RaidKey: raidKey, Phase: 1, MapID: 4978, LifecycleToken: 11},
|
||
botSeat: {RaidKey: raidKey, Phase: 1, MapID: 4953, LifecycleToken: 13},
|
||
},
|
||
Dungeons: map[runtimeRaidDungeonRunKey]runtimeRaidDungeonProgress{
|
||
{MapID: 4978, Seat: topSeat}: topProgress,
|
||
{MapID: 4953, Seat: botSeat}: botProgress,
|
||
},
|
||
},
|
||
}
|
||
service.raids = map[uint32]*runtimeRaidState{raidKey: state}
|
||
|
||
// 两个队长各带一个队员;队长是 char 1(上)/ char 22(下)。
|
||
newLeaderSession := func(charID, memberID uint16, dungeonID int, localToken, runToken uint64) *gameSession {
|
||
session := &gameSession{
|
||
conn: &bufferConn{},
|
||
connID: "roschan-report-" + strconv.Itoa(int(charID)),
|
||
channel: channelcatalog.Channel{ID: 184, Type: 42},
|
||
selectedCharacterID: charID,
|
||
characterGeneration: 1,
|
||
}
|
||
session.dungeon.runtime = &runtimeDungeonState{
|
||
Request: dungeoncmd.SelectDungeonRequest{DungeonID: uint32(dungeonID)},
|
||
lifecycleToken: localToken,
|
||
raidDungeon: runtimeRaidDungeonBinding{
|
||
RaidKey: raidKey, Phase: 1, MapID: dungeonID,
|
||
LifecycleToken: runToken,
|
||
// **真机就是这个形状**:adopt 进来的成员,本地 loadedNotified 是 false
|
||
// (notifyRuntimeRaidDungeonLifecycleLocked 对"加入者"跳过 loaded,
|
||
// 只有 completed 才在本地留一笔)。
|
||
// 真机 12:34~12:38 的两个队长(char 1 / char 22)全都是 false,
|
||
// 而 kind=1 机制上报只有他们在发 ⇒ 每一条都被静默丢掉。
|
||
loadedNotified: false,
|
||
},
|
||
partyDungeonOwner: &runtimePartyDungeonOwner{
|
||
partyID: uint16(100 + dungeonID%100),
|
||
leaderID: charID,
|
||
members: []runtimePartyDungeonMember{
|
||
{slot: 0, userID: charID, generation: 1},
|
||
{slot: 1, userID: memberID, generation: 1},
|
||
},
|
||
},
|
||
}
|
||
service.gameSessions[charID] = session
|
||
return session
|
||
}
|
||
topLeader := newLeaderSession(1, 20, 4978, 9, 11)
|
||
botLeader := newLeaderSession(22, 21, 4953, 12, 13)
|
||
|
||
body := func(kind, value int) []byte {
|
||
payload := make([]byte, 8)
|
||
binary.LittleEndian.PutUint32(payload[:4], uint32(kind))
|
||
binary.LittleEndian.PutUint32(payload[4:], uint32(value))
|
||
return payload
|
||
}
|
||
|
||
// 客户端 0x9D80A0 那张表:5=宝石/虚无 6=火 7=冰 8=风;球池 boss pattern state
|
||
// 的四个十进制位就是这四颗球(1=还在)。真 PVF 的分球是随机的,所以期望值
|
||
// 必须按"这一侧实际拿到哪颗球"算出来,不能写死 111/11。
|
||
cloneWeight := func(clone int) int { return []int{1000, 100, 10, 1}[clone-5] }
|
||
|
||
// 上侧先报:只清掉**它自己**那颗球那一位,并给它补一颗新的;下侧一点不动。
|
||
if err := service.handleRuntimeFiendWarBoss(topLeader, body(1, topClone)); err != nil {
|
||
t.Fatalf("上侧机制上报: %v", err)
|
||
}
|
||
if got, want := state.Lifecycle.FiendWar.Value("boss pattern state"), 1111-cloneWeight(topClone); got != want {
|
||
t.Fatalf("上侧打掉自己那颗球(%d)之后机制条 = %d,want %d", topClone, got, want)
|
||
}
|
||
if got := state.Lifecycle.FiendWar.Value("bot boss state"); got != botClone {
|
||
t.Fatalf("上侧打自己的球却动了对面:bot boss state=%d,want %d", got, botClone)
|
||
}
|
||
topSecond := state.Lifecycle.FiendWar.Value("top boss state")
|
||
if topSecond < 5 || topSecond > 8 || topSecond == botClone {
|
||
t.Fatalf("上侧补到的球 = %d,want 5..8 且不等于对面那颗 %d", topSecond, botClone)
|
||
}
|
||
// 下侧报它手上那颗:池子里只剩上侧没拿的那一颗。
|
||
if err := service.handleRuntimeFiendWarBoss(botLeader, body(1, botClone)); err != nil {
|
||
t.Fatalf("下侧机制上报: %v", err)
|
||
}
|
||
wantAfterBot := 1111 - cloneWeight(topClone) - cloneWeight(botClone)
|
||
if got := state.Lifecycle.FiendWar.Value("boss pattern state"); got != wantAfterBot {
|
||
t.Fatalf("下侧打掉自己那颗球(%d)之后机制条 = %d,want %d\n"+
|
||
"(真机 12:35~12:38:两侧都在报 kind=1 value=5,rejected 0 / dropped 0,"+
|
||
"但球一直停在第一颗)", botClone, got, wantAfterBot)
|
||
}
|
||
botSecond := state.Lifecycle.FiendWar.Value("bot boss state")
|
||
if botSecond < 5 || botSecond > 8 || botSecond == topSecond {
|
||
t.Fatalf("下侧补到的球 = %d,want 5..8 且不等于上侧那颗 %d", botSecond, topSecond)
|
||
}
|
||
// 现在各自手上那颗也必须能被报掉 —— 这就是"球会切换"。
|
||
if err := service.handleRuntimeFiendWarBoss(topLeader, body(1, topSecond)); err != nil {
|
||
t.Fatalf("上侧第二颗球的机制上报: %v", err)
|
||
}
|
||
if got, want := state.Lifecycle.FiendWar.Value("boss pattern state"), wantAfterBot-cloneWeight(topSecond); got != want {
|
||
t.Fatalf("上侧第二颗球(%d)之后机制条 = %d,want %d", topSecond, got, want)
|
||
}
|
||
}
|