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

255 lines
9.8 KiB
Go
Raw Permalink 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"
"slices"
"sort"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/raid"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// 「简易战况表」上那一行编制徽章 = **这一侧房间里所有编制**。
//
// 客户端那行徽章来自 op1455 成员行({u32 op, u8 area, u32 charID, u32 复活时间})
// 加上名册里每个人的编制。真机 2026-10-02 13:50(raid 12058625)操作员报的是:
//
// 「黑暗有 1、2 队进了 只显示 1 队(当前队伍队长编制是 1);
// 纯白 3、4 队进了 只显示 4 队(当前队伍队长编制是 4)」
//
// 那一侧只画得出一个徽章,是因为战场上只有**驱动者**的区域被记下来了:队友是
// prepareRuntimePartyDungeonEntry 备好、由驱动者按进图那一脚一起搬进去的,
// 他们**没有走过 op36**,battle.MemberAreas 里他们仍停在进图之前站的那一格
// (日志实证:上侧 c1 → 30、c20 停在 28;下侧 c22 → 31、c21 停在 29)。
//
// 官方资料图里那一行是 1/3/4 与 7/5/6/8 —— 四个编制都在,所以这一笔必须按
// "所有被带进去的人"补。
//
// 本用例按**客户端视角**判定:把 op1455 的行依次吃进去(首帧全量、之后只发变化),
// 最后客户端应当认为这一格里有两个人。
func TestFiendWarPartyAreaMarkPublishesEveryMemberRow(t *testing.T) {
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
if path == "" {
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatalf("open real Script.pvf: %v", err)
}
config, err := raid.LoadFiendWarConfig(archive)
if err != nil {
t.Fatalf("load Fiend War config: %v", err)
}
battle, err := raid.NewFiendWarBattle(config.Battle)
if err != nil {
t.Fatalf("battle: %v", err)
}
// 真机形状:c1(1 队)走进 30;c20(2 队)被一起带进 4978,但账本停在 28。
battle.MemberAreas = map[uint16]int{1: 30, 20: 28}
battle.GroupAreas = map[byte]int{1: 30, 2: 28}
members := []runtimeRaidMemberState{
{CharID: 1, Name: "top-leader", GroupIndex: 1, SlotOrder: 0},
{CharID: 20, Name: "top-member", GroupIndex: 2, SlotOrder: 1},
}
const raidKey = uint32(0x00c80450)
state := runtimeRaidState{
RaidKey: raidKey,
RaidMode: runtimeRaidOwnerFiendWar,
Started: true,
EntryCostPaid: true,
Members: append([]runtimeRaidMemberState(nil), members...),
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: 1,
FiendWar: battle,
},
}
service := &Service{raids: map[uint32]*runtimeRaidState{raidKey: &state}, gameSessions: map[uint16]*gameSession{}}
now := time.Now().UTC()
// 客户端视角:首帧(上一份快照是空的 → 全量)把两个人现在的区域吃进去。
client := map[uint32]int{}
first, err := runtimeFiendWarBoardPackets(state, runtimeRaidState{}, 0, now)
if err != nil {
t.Fatalf("board packets (first frame): %v", err)
}
applyOp1455(client, first)
if client[1] != 30 || client[20] != 28 {
t.Fatalf("夹具没造出问题:首帧后客户端认为 c1=%d c20=%d,want 30/28", client[1], client[20])
}
previous := cloneRuntimeRaidState(state)
// 进图那一脚之后补的一笔:整队都在这一格里。
if err := service.markRuntimeFiendWarPartyInArea(raidKey, members, 30, "test_encounter_entry"); err != nil {
t.Fatalf("mark party area: %v", err)
}
marked := *service.raids[raidKey]
after, err := runtimeFiendWarBoardPackets(marked, previous, 0, now)
if err != nil {
t.Fatalf("board packets (after mark): %v", err)
}
applyOp1455(client, after)
for _, member := range members {
if client[uint32(member.CharID)] != 30 {
t.Fatalf("op1455 之后客户端认为 char %d 在区域 %d,want 30(整队都被带进这一格)\n"+
"(真机 13:50:账本里被带进去的队友停在上一格,客户端那一侧就只画得出一个编制徽章)",
member.CharID, client[uint32(member.CharID)])
}
}
}
// applyOp1455 按客户端语义把 op1455 的行吃进 client:op=1 记位置,op=2 清位置。
func applyOp1455(client map[uint32]int, packets []runtimeRaidWireNotification) {
for _, packet := range packets {
if packet.msgID != 1455 || len(packet.body) == 0 {
continue
}
body := packet.body
count := int(body[0])
for row := 0; row < count; row++ {
start := 1 + row*13
if start+13 > len(body) {
break
}
op := binary.LittleEndian.Uint32(body[start : start+4])
area := int(body[start+4])
charID := binary.LittleEndian.Uint32(body[start+5 : start+9])
switch op {
case 1:
client[charID] = area
case 2:
delete(client, charID)
}
}
}
}
// 账本里的个人区域一旦滞后,**玩家就走不动**:`MoveMember` 的相邻判定读的是账本
// (module 层 `b.GroupAreas[group] = b.MemberArea(character, group)` → `Move`),
// 于是"人在 11 点 7"会被按"16 与 7 不相邻"拒掉。
//
// 真机 2026-10-02 14:20(礼白666,raid 12058625,分片 20261002-016):
//
// 06:18:58 game-fiendwar-party-area-marked area=16 char_ids=1 ← 被队友带进 area16 的副本
// 06:19:05 game-dungeon-full-town-route-send ... area_id=11 ← 打完回到 11
// 06:20:59 game-fiendwar-move-refused requested_area_id=7 keep_area_id=11
// reason=fiendwar areas are not adjacent
//
// PVF 的区域图:area 11 的 next = [7 10 12 16] **含 7**,area 16 的 next = [11 21] 不含 7。
// 所以这一脚本来就该放行 —— 挡它的是账本里的旧格子。
func TestFiendWarStaleLedgerAreaDoesNotBlockTheMove(t *testing.T) {
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
if path == "" {
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatalf("open real Script.pvf: %v", err)
}
config, err := raid.LoadFiendWarConfig(archive)
if err != nil {
t.Fatalf("load Fiend War config: %v", err)
}
// 先确认这份 PVF 的相邻关系(用例的判据全靠它)。
nextOf := func(area int) []int {
out := append([]int(nil), config.Battle.Areas[area].Next...)
sort.Ints(out)
return out
}
if !slices.Contains(nextOf(11), 7) || slices.Contains(nextOf(16), 7) {
t.Fatalf("区域图与用例假设不符:next(11)=%v next(16)=%v", nextOf(11), nextOf(16))
}
battle, err := raid.NewFiendWarBattle(config.Battle)
if err != nil {
t.Fatalf("battle: %v", err)
}
// 真机形状:账本说 16(他刚被带进 area16 的副本),人其实已经回到 11。
battle.MemberAreas = map[uint16]int{1: 16}
battle.GroupAreas = map[byte]int{1: 16}
members := []runtimeRaidMemberState{{CharID: 1, Name: "top-leader", GroupIndex: 1, SlotOrder: 0}}
const raidKey = uint32(0x00c80451)
state := runtimeRaidState{
RaidKey: raidKey,
RaidMode: runtimeRaidOwnerFiendWar,
Started: true,
EntryCostPaid: true,
Members: append([]runtimeRaidMemberState(nil), members...),
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: 1,
FiendWar: battle,
},
}
service := &Service{raids: map[uint32]*runtimeRaidState{raidKey: &state}, gameSessions: map[uint16]*gameSession{}}
now := time.Now().UTC()
// (1) 账本滞后时这一脚必须被拒 —— 复刻真机那句 move-refused。
if err := state.Lifecycle.FiendWar.MoveMember(1, 1, 7, now); err == nil {
t.Fatal("夹具没造出问题:账本停在 16 时 11→7 竟然放行了")
}
// (2) live-first 对齐之后必须放行(这就是 town_move 那一脚做的第一件事)。
synced := service.syncRuntimeFiendWarMemberAreaFromLivePosition(1, 11, "test_fiendwar_move")
if synced.RaidKey == 0 || synced.Lifecycle.FiendWar == nil {
t.Fatal("live-first 对齐没有返回战场状态")
}
if got := synced.Lifecycle.FiendWar.MemberArea(1, 1); got != 11 {
t.Fatalf("live-first 之后账本 = %d,want 11", got)
}
if err := synced.Lifecycle.FiendWar.MoveMember(1, 1, 7, now); err != nil {
t.Fatalf("对齐之后 11→7 仍被拒: %v(真机 14:20 就是这样卡住的)", err)
}
}
// 出副本那一脚(打完自动回城)必须把账本落到**目的地那一格**,否则下一脚移动会被
// 旧格子挡死 —— 这是上面那条用例的上游。
func TestFiendWarDungeonExitMarksTheDestinationArea(t *testing.T) {
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
if path == "" {
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatalf("open real Script.pvf: %v", err)
}
config, err := raid.LoadFiendWarConfig(archive)
if err != nil {
t.Fatalf("load Fiend War config: %v", err)
}
battle, err := raid.NewFiendWarBattle(config.Battle)
if err != nil {
t.Fatalf("battle: %v", err)
}
battle.MemberAreas = map[uint16]int{1: 16}
battle.GroupAreas = map[byte]int{1: 16}
members := []runtimeRaidMemberState{{CharID: 1, Name: "top-leader", GroupIndex: 1, SlotOrder: 0}}
const raidKey = uint32(0x00c80452)
state := runtimeRaidState{
RaidKey: raidKey,
RaidMode: runtimeRaidOwnerFiendWar,
Started: true,
EntryCostPaid: true,
Members: append([]runtimeRaidMemberState(nil), members...),
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: 1,
FiendWar: battle,
},
}
service := &Service{raids: map[uint32]*runtimeRaidState{raidKey: &state}, gameSessions: map[uint16]*gameSession{}}
if err := service.markRuntimeFiendWarMemberArea(1, 11, "test_fiendwar_clear_auto_return"); err != nil {
t.Fatalf("mark member area: %v", err)
}
got := service.raids[raidKey].Lifecycle.FiendWar.MemberArea(1, 1)
if got != 11 {
t.Fatalf("出副本之后账本 = %d,want 11(回去的那一格)", got)
}
}