Files
90CNServer/server/internal/services/dnfbridge/raid_fiendwar_roschan_live_test.go
T
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

186 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}