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

468 lines
20 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 (
"bytes"
"context"
"os"
"strconv"
"testing"
"time"
"golang.org/x/text/encoding/simplifiedchinese"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/raid"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// TestFiendWarEncounterSquadResolutionHasDefinedBehaviourForEveryDivergence pins the
// decision table of runtimeRaidFiendWarEncounterSquad.
//
// The two roster rows are NOT interchangeable and the disagreement between them used
// to be a silent dead end: the group was read from the FROZEN row (group 1), the LIVE
// groups were searched for it, nothing matched, and the encounter returned nil
// without a word. Live 2026-10-01, raid 12058625, char 1:
//
// 11:34:24 game-raid-squad-unassigned-on-solo-town-return char_id=1 previous_group=1
// group=0 slot=0 frozen_row=kept_for_phase_credit
// 11:35:55 game-town-set-user-area-blocked area_id=10
// reason=fiendwar source area requires combat
//
// The area's monster could never be cleared, and FiendWarBattle.Move refuses to let
// anyone walk past an uncleared area, so the member was locked in for good.
func TestFiendWarEncounterSquadResolutionHasDefinedBehaviourForEveryDivergence(t *testing.T) {
// Two members, both frozen in squad 1 -- the phase roster the failure left alone.
newState := func(liveSquads [2]byte, liveRows bool) runtimeRaidState {
live := []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: liveSquads[0], SlotOrder: 0},
{CharID: 1002, Name: "member", GroupIndex: liveSquads[1], SlotOrder: 1},
}
if !liveRows {
live = nil
}
return runtimeRaidState{
RaidKey: 12058625,
Members: live,
FrozenMembers: []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0},
{CharID: 1002, Name: "member", GroupIndex: 1, SlotOrder: 1},
},
}
}
cases := []struct {
name string
state runtimeRaidState
characterID uint16
wantGroup byte
wantSource runtimeRaidFiendWarSquadSource
wantRefusal string
wantMembers int
}{
{
name: "live row names the squad",
state: newState([2]byte{1, 1}, true),
characterID: 1001,
wantGroup: 1,
wantSource: runtimeRaidFiendWarSquadSourceLive,
wantMembers: 2,
},
{
name: "a member re-assigned through the raid manager wins over the frozen row",
// The frozen row still says squad 1; the live row says squad 2, and the
// live roster really has a squad 2 for him.
state: runtimeRaidState{
RaidKey: 12058625,
Members: []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 2, SlotOrder: 0},
{CharID: 1002, Name: "member", GroupIndex: 2, SlotOrder: 1},
},
FrozenMembers: []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0},
{CharID: 1002, Name: "member", GroupIndex: 1, SlotOrder: 1},
},
},
characterID: 1001,
wantGroup: 2,
wantSource: runtimeRaidFiendWarSquadSourceLive,
wantMembers: 2,
},
{
name: "live row unassigned and frozen row still in squad 1 is a squadless member",
state: newState([2]byte{0, 1}, true),
characterID: 1001,
wantRefusal: "member_not_in_any_squad",
},
{
name: "live row unassigned and no live squad at all is still squadless",
// Nobody in the live roster carries squad 1: the absent squad the frozen
// row names must not be resurrected into a party nobody is in.
state: newState([2]byte{0, 0}, true),
characterID: 1001,
wantRefusal: "member_not_in_any_squad",
},
{
name: "no live row for the member at all falls back to the frozen row",
state: newState([2]byte{0, 0}, false),
characterID: 1001,
wantGroup: 1,
wantSource: runtimeRaidFiendWarSquadSourceFrozen,
wantMembers: 1,
},
{
name: "no live row for the member joins the squad's live peers",
// The live roster lost this member's row but still holds his squad, so the
// frozen row puts him back into a party that really exists.
state: runtimeRaidState{
RaidKey: 12058625,
Members: []runtimeRaidMemberState{
{CharID: 1002, Name: "member", GroupIndex: 1, SlotOrder: 1},
},
FrozenMembers: []runtimeRaidMemberState{
{CharID: 1001, Name: "leader", GroupIndex: 1, SlotOrder: 0},
{CharID: 1002, Name: "member", GroupIndex: 1, SlotOrder: 1},
},
},
characterID: 1001,
wantGroup: 1,
wantSource: runtimeRaidFiendWarSquadSourceFrozen,
wantMembers: 2,
},
{
name: "member absent from the frozen roster is refused by name",
state: newState([2]byte{1, 1}, true),
characterID: 1099,
wantRefusal: "member_not_in_frozen_roster",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := runtimeRaidFiendWarEncounterSquad(tc.state, tc.characterID)
if got.Refusal != tc.wantRefusal {
t.Fatalf("refusal = %q, want %q", got.Refusal, tc.wantRefusal)
}
if tc.wantRefusal != "" {
if len(got.Members) != 0 {
t.Fatalf("refused resolution still carried %d members", len(got.Members))
}
return
}
if got.Group != tc.wantGroup || got.Source != tc.wantSource || len(got.Members) != tc.wantMembers {
t.Fatalf("resolution = {group %d source %q members %d}, want {group %d source %q members %d}",
got.Group, got.Source, len(got.Members), tc.wantGroup, tc.wantSource, tc.wantMembers)
}
})
}
}
// TestRealPVFFiendWarSquadlessMemberIsNotSilentlyDeadlocked is the regression test for
// the live deadlock, driven through the real Script.pvf.
//
// It builds the exact divergent state the live log recorded -- LIVE row squad 0, FROZEN
// row squad 1, the member standing in an open area with the battle model still holding
// him under the frozen squad -- and asserts both halves of the fix:
//
// (a) the encounter refuses EXPLICITLY (a named log row and a client notice) instead
// of silently returning nil, and
// (b) after the member re-assigns himself in the raid manager the very same arrival
// fires the encounter normally.
func TestRealPVFFiendWarSquadlessMemberIsNotSilentlyDeadlocked(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.preloadDungeonWorldMap(context.Background()); err != nil {
t.Fatal(err)
}
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
// Both members in squad 1, on both rows: the state a squad enters an area from.
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, err = newRuntimeRaidLifecycleStateFromModeAndType(
config.Battle.Raid, runtimeRaidOwnerFiendWar, f.now, false, 0)
if err != nil {
t.Fatal(err)
}
state.Lifecycle.FiendWar, err = raid.NewFiendWarBattle(config.Battle)
if err != nil {
t.Fatal(err)
}
b := state.Lifecycle.FiendWar
b.GroupAreas[1] = int(currentFiendWarWaitingAreaID)
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
// Area 14 owns dungeon 4962 and is open because a native defender stands in it.
// The defender is seeded explicitly instead of relying on the PVF's random orc
// spawn, so "this area requires combat" is a fact of the fixture.
b.Areas[14] = raid.FiendWarAreaState{Monster: raid.FiendWarUnit{ID: 1, Type: 1}}
syncRuntimeFiendWarObjectives(state)
s.onlinePlayers = newOnlinePlayerManager()
for i, peer := range []*gameSession{f.leader, f.member} {
peer.accountID = "account-" + strconv.Itoa(i+1)
peer.channel = channelcatalog.Channel{ID: 261, Type: 42}
peer.selectPreviewObjectStateSent, peer.currentSceneObjectListSent = true, true
if err := f.repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: strconv.Itoa(int(peer.selectedCharacterID)),
AccountID: peer.accountID,
Name: "member",
Level: 90,
Stats: map[string]int64{
"fatigue": 156, "town_id": 38, "area_id": 1,
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 0,
},
}); err != nil {
t.Fatal(err)
}
quests := dnfrepo.QuestRecord{
CharacterID: strconv.Itoa(int(peer.selectedCharacterID)),
States: map[int64]dnfrepo.QuestState{},
}
for _, id := range []int64{2, 3, 6, 10, 9, 14} {
area, found := s.townArea(98, id)
if !found {
t.Fatalf("waiting town area %d missing from PVF", id)
}
for _, quest := range area.NeedQuests {
quests.States[quest] = dnfrepo.QuestState{Status: "completed"}
}
}
if err := f.repositories.Quest.Save(context.Background(), quests); err != nil {
t.Fatal(err)
}
bindDungeonSelectorOriginForTestAt(t, s, peer, 98, 14, 450, 234)
registerPartyPeerEndpointForTest(peer, byte(i+2), uint16(5100+i))
seedPartyOwnerActorProjectionForTest(t, s, peer, peer.selectedCharacterID, 0)
}
if err := s.applyRuntimeRaidParties(*state); err != nil {
t.Fatal(err)
}
b.GroupAreas[1] = 14
syncRuntimeFiendWarObjectives(state)
leader := f.leader
// Both members stand in area 14, which owns dungeon 4962 and holds a native
// defender: the exact state the live member was in when the encounter stopped
// firing.
//
// peerArea 是**同伴物理上站在哪一格**。区域名单按客户端的实时坐标算
// (runtimeRaidFiendWarAreaParty),所以这里必须连实时坐标一起搬 ——
// 只改战场账本的话,同伴在坐标上还站在这一格里,"他一个人在区域里"这个前提
// 就不成立了。
reset := func(liveSquad byte, peerArea byte) {
state.Members[0].GroupIndex, state.Members[0].SlotOrder = liveSquad, 0
b.MemberAreas[leader.selectedCharacterID] = 14
b.MemberAreas[f.member.selectedCharacterID] = int(peerArea)
b.GroupAreas[1] = 14
bindDungeonSelectorOriginForTestAt(t, s, leader, 98, 14, 450, 234)
bindDungeonSelectorOriginForTestAt(t, s, f.member, 98, peerArea, 450, 234)
// 进图名单按**实时位置**取自 area 实例表(runtimeRaidFiendWarAreaRoster →
// onlinePlayers.GetAreaInstancePlayers)。真机上每次 op36 都会把人写进这张表
// (town_move.go 的 EnterArea),fixture 必须照样登记,否则"同格的同伴"在
// 服务端眼里根本不存在,(c) 就变成了在验单人进图。
s.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: leader.selectedCharacterID, ChannelID: townPresenceChannelID(leader),
TownID: 98, AreaID: 14, InstanceKey: state.RaidKey,
PositionX: 450, PositionY: 234, Session: leader,
})
s.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: f.member.selectedCharacterID, ChannelID: townPresenceChannelID(f.member),
TownID: 98, AreaID: peerArea, InstanceKey: state.RaidKey,
PositionX: 450, PositionY: 234, Session: f.member,
})
// 上一小段可能已经把人送进了副本;留着它,后面每一段的进图尝试都会在
// "已经在副本里"那一支静默 return,什么都观察不到。
//
// **清会话侧还不算完**。进图同时会在**攻坚账本**上留下这一格的 run 与座位
// 占位(beginRuntimeRaidDungeon 写的 Lifecycle.Dungeons[runKey] 与
// GroupOccupancy[seat]),只清 session.dungeon.runtime 会造出一个在真机上
// 不可能存在的状态 —— "拥有这条 run 的会话手上已经没有 run 了,攻坚却还
// 认为这一格被占着"。于是后面每一段都在 beginRuntimeRaidDungeon 里被当成
// **自己的 racing duplicate** 拒掉(真机同样两条错误的形状,见
// raid_lifecycle.go:673/720/1019):
//
// game-dungeon-select-blocked reason=dungeon_runtime_commit_failed
// error=runtime raid dungeon lifecycle token is invalid:
// raid=13108201 phase=1 map=4962 token=3
// (兜底重试那一次)
// game-dungeon-select-blocked reason=dungeon_runtime_commit_failed
// error=runtime raid group already occupies a dungeon:
// group=1 char=0 map=4962
//
// 注意这两条与"本人 live 行是 0 队"**无关**:beginRuntimeRaidDungeon 解析
// 座位用的是**冻结行**(raid_lifecycle.go:657 runtimeRaidFrozenMember),
// 所以 seat 一直都是 {Group: 1},char=0 就是座位本身没有个人字符。
//
// 真机上把"run 还在、拥有它的会话已经没了"收回的是
// releaseRuntimeRaidStaleGateOccupancy(它判定这条 run 不再 live 之后调用的
// 正是下面这个函数),所以 fixture 用同一个函数收回,而不是自己动手删 map:
// 这一段要验的是**进图门禁**,不是"副本重进"。
s.raidMu.Lock()
s.releaseRuntimeRaidRunsOwnedByLocked(state, leader.selectedCharacterID)
s.releaseRuntimeRaidRunsOwnedByLocked(state, f.member.selectedCharacterID)
s.raidMu.Unlock()
leader.dungeon.mu.Lock()
leader.dungeon.runtime = nil
leader.dungeon.mu.Unlock()
leader.conn.(*bufferConn).write.Reset()
}
writtenMsgIDs := func() map[uint16]bool {
raw := append([]byte(nil), leader.conn.(*bufferConn).write.Bytes()...)
ids := map[uint16]bool{}
for len(raw) != 0 {
packet, rest := splitGameServerUpperPacket(t, raw)
raw = rest
ids[packet.Header.MsgID] = true
}
return ids
}
// The divergence, exactly as the live log recorded it: the solo town return
// rewrote the LIVE row only (group 0) and the member walked into area 14. The 1 Hz
// board tick is the pass that has to answer, and it is the pass that used to
// return nil in silence.
//
// 2026-10-01 按**客户端原生规格**改写(逆向结论见
// 超时空_同区域进图_客户端逆向规格_20261001.md):
//
// 弹窗 sub_9AE390(VA 0x9AE390)第一件事是 `cmp eax,0x2a` —— 只在频道类型 42
// (超时空)弹;文案 dstr 75467「移动后,会与处于当前城镇的同伴一同开始战斗。
// 确认要移动吗?」。弹窗分支只是暂停并返回,客户端不组队、不收集成员。
//
// 所以**小队编制不再影响超时空进图**:那条
// game-fiendwar-encounter-blocked / currentFiendWarSquadlessNotice("你不在任何
// 小队里,请先在攻坚队管理器中重新编队")已经删除,名单改由
// runtimeRaidFiendWarAreaRoster 按"这一格里实时站着谁"现算。
//
// 这里 peer 站在区域 2、本人站在 14 —— 他一个人在这一格里,于是名单只有他自己,
// 应当**单人照常进图**,而不是被拒之门外。
reset(0, 2)
if err := s.tryRuntimeFiendWarEncounter(leader, f.now.Add(7*time.Second)); err != nil {
t.Fatalf("squadless encounter pass: %v", err)
}
leader.dungeon.mu.Lock()
entered := leader.dungeon.runtime != nil
leader.dungeon.mu.Unlock()
if !entered {
t.Fatalf("没编制的人独自站在有怪的区域里,应当照常进图;报文=%x",
leader.conn.(*bufferConn).write.Bytes())
}
// 编队提示不该再出现 —— 小队编制已经不是超时空进图的条件。
squadlessText, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("不在任何小队"))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(leader.conn.(*bufferConn).write.Bytes(), squadlessText) {
t.Fatalf("编队提示仍然出现(%q):小队编制不该再影响超时空进图",
currentFiendWarSquadlessNotice)
}
// (b) The operator's escape hatch: re-assigning himself in the raid manager (the
// regroup command, type=669) makes the very same pass drive the encounter: the
// squad projection (op11 member list + op9 party state) and the dungeon select
// (op153) that carries the area's dungeon. In the squadless pass none of that is
// written at all, which is what makes this a discriminating assertion.
reset(1, 14)
if err := s.tryRuntimeFiendWarEncounter(leader, f.now.Add(9*time.Second)); err != nil {
t.Fatalf("regrouped encounter: %v", err)
}
ids := writtenMsgIDs()
if len(ids) == 0 {
t.Fatal("a re-assigned member still could not drive the encounter: nothing was written")
}
if !ids[11] || !ids[9] {
t.Fatalf("the encounter did not project its squad into a party; msg ids=%v", ids)
}
if !ids[153] {
t.Fatalf("the encounter did not drive the dungeon select; msg ids=%v", ids)
}
leader.dungeon.mu.Lock()
runtime := leader.dungeon.runtime
leader.dungeon.mu.Unlock()
if runtime == nil {
t.Fatalf("重新编队后进图没有提交:报文=%v", ids)
}
if runtime.raidDungeon.RaidKey != state.RaidKey {
t.Fatalf("encounter entered outside the raid: binding=%+v, want raid %d",
runtime.raidDungeon, state.RaidKey)
}
// (c) 队友站在**同一格**,而本人没有编制:按客户端原生规格,这正是"一起进图"
// 的标准场景 —— 名单由 runtimeRaidFiendWarAreaRoster 按这一格里站着谁来算,
// 编制不参与。所以两人都该进,且不该再出现任何编队提示。
//
// 注意:本人 live 行是 group 0(solo town return 造成的分歧),队友在 1 队;
// 旧实现下这个分歧会让整场静默死锁,新实现下它只影响 battle 的 group 记法。
reset(0, 14)
leader.fiendWarEncounterAt = time.Time{}
leader.fiendWarEncounterArea = 0
if err := s.tryRuntimeFiendWarEncounter(leader, f.now.Add(10*time.Second)); err != nil {
t.Fatalf("squadmate-in-area encounter: %v", err)
}
raw := append([]byte(nil), leader.conn.(*bufferConn).write.Bytes()...)
leader.conn.(*bufferConn).write.Reset()
squadlessWithPeer, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("不在任何小队"))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, squadlessWithPeer) {
t.Fatalf("同格有队友时仍然弹编队提示;报文=%x", raw)
}
leader.dungeon.mu.Lock()
leaderRuntime := leader.dungeon.runtime
leader.dungeon.mu.Unlock()
if leaderRuntime == nil {
t.Fatalf("同格有队友+自己没编制,进图没有发生;报文=%x", raw)
}
// 而"一起进图"的另一半:同格的队友也必须真的走进**同一场**副本 ——
// 这才是这条规格的目的(客户端弹的 75467 就是「会与处于当前城镇的同伴一同
// 开始战斗」),只验驱动者自己进去等于把功能验成了"单人进图"。
f.member.dungeon.mu.Lock()
peerRuntime := f.member.dungeon.runtime
f.member.dungeon.mu.Unlock()
if peerRuntime == nil {
t.Fatalf("同格的队友没有被搬进副本:只有驱动者(char=%d)进去了",
leader.selectedCharacterID)
}
// "同一场"按 (raid, phase, map) 判,**不能**比 LifecycleToken:
// 超时空一个区域共用一条 run,但每个成员手里是**自己那份** token
// (runtimeFiendWarPartyBinding 给的 binding 带的就是本人的 token),
// 后到的那位靠 runtimeRaidDungeonLifecycleReportAccepted 的放松被静默接受
// —— 那正是 raid_fiendwar_area_seat_test.go 钉住的设计。
if peerRuntime.raidDungeon.RaidKey != leaderRuntime.raidDungeon.RaidKey ||
peerRuntime.raidDungeon.Phase != leaderRuntime.raidDungeon.Phase ||
peerRuntime.raidDungeon.MapID != leaderRuntime.raidDungeon.MapID {
t.Fatalf("队友与驱动者进的不是同一场副本:peer=%+v leader=%+v",
peerRuntime.raidDungeon, leaderRuntime.raidDungeon)
}
// 而且这一格在攻坚账本上只有**一条** run:后到的人采用先到者的那份,
// 不是各开一份(各开一份会让战场记的"区域归属"与两个人的副本实例对不上)。
s.raidMu.Lock()
runs := 0
for key := range state.Lifecycle.Dungeons {
if key.MapID == int(leaderRuntime.raidDungeon.MapID) {
runs++
}
}
s.raidMu.Unlock()
if runs != 1 {
t.Fatalf("副本 %d 在攻坚账本上有 %d 条 run,want 1(一场只开一条)",
leaderRuntime.raidDungeon.MapID, runs)
}
}