服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
255 lines
10 KiB
Go
255 lines
10 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"strconv"
|
||
"testing"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
||
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||
"longheng.io/server/internal/modules/dnf/raid"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
"longheng.io/server/internal/modules/dnf/worldmap"
|
||
)
|
||
|
||
// testFiendWarArgs 把一个整数包成规则指令的参数,形状与 PVF 解析出来的 token 一致
|
||
// (matches/act 只读 Args[0].Int)。
|
||
func testFiendWarArgs(values ...int) []dnfpvf.Token {
|
||
tokens := make([]dnfpvf.Token, 0, len(values))
|
||
for _, value := range values {
|
||
tokens = append(tokens, dnfpvf.Token{Kind: dnfpvf.TokenInt, Raw: strconv.Itoa(value), Int: int64(value)})
|
||
}
|
||
return tokens
|
||
}
|
||
|
||
// 超时空:走进 **BOSS 那张地图** 才把这一侧的 BOSS 机制推进到"四颗星球出场",而
|
||
// 不是走进副本就算。
|
||
//
|
||
// 真机 2026-10-02(两两进罗什,分片 20261002-006)的形状:四个球一个都不出现、
|
||
// 罗什不给打,62 条 op1511 全是 kind=0 value=100(客户端压根没进机制阶段)。
|
||
// PVF 的状态机是:
|
||
//
|
||
// [ON ENTER DUNGEON] 4978 → top boss state = 2 (还没进机制)
|
||
// [ON ENTER MAP] 306000463 → top boss state = 5 (四颗星球出场)
|
||
// 客户端每打掉一颗报一条 kind=1 → 5→6→7→8 → 9(伤害窗口)→ BOSS 死 → 10
|
||
//
|
||
// 而服务端只在**副本 finish-loading** 那一次触发 on enter map,那一刻人在
|
||
// 0:0 起始房(306000461),于是带过去的是起始地图 ID,什么规则都没命中;
|
||
// 玩家随后走进 BOSS 房时没有任何代码再触发它 —— state 永远停在 2。
|
||
//
|
||
// 本用例用一条合成规则钉住"房间边"这件事:只有走进 306000463 才推进,
|
||
// 走起始房不推进、重复走不重复推进。
|
||
func TestFiendWarBossRoomEnterMapOpensTheBossPattern(t *testing.T) {
|
||
const (
|
||
raidKey = uint32(0x00c803e8)
|
||
startRoomMapID = 306000461
|
||
bossRoomMapID = 306000463
|
||
bossStateSymbol = 100
|
||
)
|
||
config := &raid.FiendWarBattleConfig{
|
||
Raid: runtimeRaidBridgeTestAntonConfig(),
|
||
WaitingTown: 98,
|
||
WaitingArea: 2,
|
||
Areas: map[int]raid.FiendWarArea{
|
||
2: {Index: 2},
|
||
30: {Index: 30, Dungeon: 4978, Kind: "top boss", Symbol: bossStateSymbol},
|
||
},
|
||
Symbols: map[string]uint32{"top boss state": bossStateSymbol, "boss pattern state": 101},
|
||
// 与真 PVF 里 [ON ENTER MAP] 306000463 那一条同义:走进 BOSS 房 → state 5。
|
||
Rules: []raid.FiendWarRule{{
|
||
Conditions: []raid.FiendWarInstruction{{Op: "on enter map", Args: testFiendWarArgs(bossRoomMapID)}},
|
||
Actions: []raid.FiendWarInstruction{{
|
||
Op: "set", Symbol: bossStateSymbol, Args: testFiendWarArgs(5),
|
||
}},
|
||
}},
|
||
}
|
||
battle, err := raid.NewFiendWarBattle(config)
|
||
if err != nil {
|
||
t.Fatalf("fixture 战场: %v", err)
|
||
}
|
||
// 入场值:真机 2026-10-01 实测是 2(还没有任何机制)。
|
||
battle.Symbols[bossStateSymbol] = 2
|
||
state := &runtimeRaidState{
|
||
RaidKey: raidKey,
|
||
Started: true,
|
||
EntryCostPaid: true,
|
||
Lifecycle: runtimeRaidLifecycleState{
|
||
Status: runtimeRaidLifecyclePhaseActive,
|
||
Phase: 1,
|
||
FiendWar: battle,
|
||
},
|
||
}
|
||
service := &Service{
|
||
raids: map[uint32]*runtimeRaidState{raidKey: state},
|
||
gameSessions: map[uint16]*gameSession{},
|
||
}
|
||
session := &gameSession{
|
||
conn: &bufferConn{},
|
||
connID: "fiendwar-boss-map-enter",
|
||
channel: channelcatalog.Channel{ID: 184, Type: 42},
|
||
selectedCharacterID: 1,
|
||
characterGeneration: 1,
|
||
}
|
||
runtime := &runtimeDungeonState{
|
||
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 4978},
|
||
raidDungeon: runtimeRaidDungeonBinding{
|
||
RaidKey: raidKey, Phase: 1, MapID: 4978, LifecycleToken: 1, loadedNotified: true,
|
||
},
|
||
}
|
||
session.dungeon.runtime = runtime
|
||
|
||
// 读回状态必须走 state.Lifecycle.FiendWar:切换路径是在**克隆**上跑规则、
|
||
// 成功后才替换回去的,本地那个 battle 变量已经不是权威了。
|
||
value := func() int { return state.Lifecycle.FiendWar.Value("top boss state") }
|
||
|
||
// [反例] 起始房:一个字都不该改。真机上 finish-loading 带的就是这一张地图,
|
||
// 它不推进机制正是这条缺陷的另一半。
|
||
service.notifyRuntimeFiendWarMapEnteredLocked(session, runtime, startRoomMapID, "test")
|
||
if got := value(); got != 2 {
|
||
t.Fatalf("起始房推进了 BOSS 机制:state=%d,want 2", got)
|
||
}
|
||
|
||
// [本用例的核心] 走进 BOSS 房:必须推到 5 —— 客户端只有看到 5 才放那四颗星球。
|
||
service.notifyRuntimeFiendWarMapEnteredLocked(session, runtime, bossRoomMapID, "test")
|
||
if got := value(); got != 5 {
|
||
t.Fatalf("走进 BOSS 房没有开启机制:top boss state=%d,want 5\n"+
|
||
"(真机 2026-10-02 两两进罗什:四个球一个都不出现、罗什不给打)", got)
|
||
}
|
||
|
||
// 幂等:同一次进图里再走一遍不能重复推进(state != 2 那条守卫)。
|
||
state.Lifecycle.FiendWar.Symbols[bossStateSymbol] = 3
|
||
service.notifyRuntimeFiendWarMapEnteredLocked(session, runtime, bossRoomMapID, "test")
|
||
if got := value(); got != 3 {
|
||
t.Fatalf("重复进房把已经推进过的状态改了:state=%d,want 3", got)
|
||
}
|
||
state.Lifecycle.FiendWar.Symbols[bossStateSymbol] = 2
|
||
|
||
// [反例] 不是超时空频道:一次都不触发。
|
||
other := &gameSession{
|
||
conn: &bufferConn{},
|
||
connID: "fiendwar-boss-map-enter-not-fiendwar",
|
||
channel: channelcatalog.Channel{ID: 184, Type: 1},
|
||
selectedCharacterID: 1,
|
||
characterGeneration: 1,
|
||
}
|
||
other.dungeon.runtime = runtime
|
||
service.notifyRuntimeFiendWarMapEnteredLocked(other, runtime, bossRoomMapID, "test")
|
||
if got := value(); got != 2 {
|
||
t.Fatalf("非超时空频道推进了 BOSS 机制:state=%d,want 2", got)
|
||
}
|
||
|
||
// [反例] 没有 loadedNotified(还没进副本):不触发。
|
||
notLoaded := *runtime
|
||
notLoaded.raidDungeon.loadedNotified = false
|
||
service.notifyRuntimeFiendWarMapEnteredLocked(session, ¬Loaded, bossRoomMapID, "test")
|
||
if got := value(); got != 2 {
|
||
t.Fatalf("没进副本就推进了 BOSS 机制:state=%d,want 2", got)
|
||
}
|
||
|
||
// [反例] 别的副本(不在任何 BOSS 区域名下):不触发。
|
||
otherDungeon := *runtime
|
||
otherDungeon.Request = dungeoncmd.SelectDungeonRequest{DungeonID: 4955}
|
||
service.notifyRuntimeFiendWarMapEnteredLocked(session, &otherDungeon, bossRoomMapID, "test")
|
||
if got := value(); got != 2 {
|
||
t.Fatalf("不相干的副本推进了 BOSS 机制:state=%d,want 2", got)
|
||
}
|
||
}
|
||
|
||
// 超时空:**队长**按下撤退(无包体 op42)时,整支临时队伍一起回城;队员自己按不算。
|
||
//
|
||
// 操作员 2026-10-02 定案;真机 10:29:10 只有队长一个人回了城(同队的 char 21 留在
|
||
// 格里,23 秒后连点 op72 被 settlement_result_not_ready 拒了 119 次)。
|
||
func TestFiendWarLeaderRetreatPullsTheWholeTemporaryParty(t *testing.T) {
|
||
service := &Service{gameSessions: map[uint16]*gameSession{}}
|
||
owner := &runtimePartyDungeonOwner{
|
||
partyID: 22,
|
||
leaderID: 1,
|
||
members: []runtimePartyDungeonMember{
|
||
{slot: 0, userID: 1},
|
||
{slot: 1, userID: 21},
|
||
},
|
||
}
|
||
runtime := &runtimeDungeonState{
|
||
raidDungeon: runtimeRaidDungeonBinding{RaidKey: 12058625, Phase: 1, MapID: 4978, LifecycleToken: 20},
|
||
partyDungeonOwner: owner,
|
||
}
|
||
fiendWar := &gameSession{
|
||
conn: &bufferConn{},
|
||
channel: channelcatalog.Channel{ID: 184, Type: 42},
|
||
selectedCharacterID: 1,
|
||
}
|
||
fiendWar.dungeon.runtime = runtime
|
||
// gameSession 里带锁:不能靠拷贝造变体(go vet 会拦),每条都用新会话。
|
||
newSession := func(channelType byte, charID uint16) *gameSession {
|
||
session := &gameSession{
|
||
conn: &bufferConn{},
|
||
connID: "fiendwar-retreat-" + strconv.Itoa(int(charID)),
|
||
channel: channelcatalog.Channel{ID: 184, Type: channelType},
|
||
selectedCharacterID: charID,
|
||
}
|
||
session.dungeon.runtime = runtime
|
||
return session
|
||
}
|
||
|
||
// 队长:拉全队,名单就是这支临时队伍。
|
||
plan := service.runtimeFiendWarPartyRetreatPlanFor(fiendWar, runtime)
|
||
if !plan.armed {
|
||
t.Fatal("队长的撤退没有拉全队(真机 10:29:10 就是这一脚,只有他一个人回了城)")
|
||
}
|
||
if plan.leader != 1 || plan.partyID != 22 || plan.raidKey != 12058625 {
|
||
t.Fatalf("撤退计划抓错了人/队:%+v", plan)
|
||
}
|
||
if len(plan.members) != 2 || plan.members[1] != 21 {
|
||
t.Fatalf("撤退计划的成员名单不对:%+v", plan.members)
|
||
}
|
||
|
||
// 队员:自己不带队,走原来的单人退出通道。
|
||
if got := service.runtimeFiendWarPartyRetreatPlanFor(newSession(42, 21), runtime); got.armed {
|
||
t.Fatalf("队员自己按撤退把全队拉走了:%+v", got)
|
||
}
|
||
|
||
// 非超时空:一个字不改(安东/卢克)。
|
||
if got := service.runtimeFiendWarPartyRetreatPlanFor(newSession(1, 1), runtime); got.armed {
|
||
t.Fatalf("非超时空也拉全队了:%+v", got)
|
||
}
|
||
|
||
// 没有临时队伍(单人进图):没人可拉。
|
||
soloRuntime := &runtimeDungeonState{raidDungeon: runtime.raidDungeon}
|
||
solo := newSession(42, 1)
|
||
solo.dungeon.runtime = soloRuntime
|
||
if got := service.runtimeFiendWarPartyRetreatPlanFor(solo, soloRuntime); got.armed {
|
||
t.Fatalf("没有临时队伍却立了撤退计划:%+v", got)
|
||
}
|
||
|
||
// 队员离线时那一趟是个空操作,不许崩、也不许发任何东西。
|
||
service.runRuntimeFiendWarPartyRetreat(plan, "test")
|
||
// 未立计划时同样是空操作。
|
||
service.runRuntimeFiendWarPartyRetreat(runtimeFiendWarPartyRetreatPlan{}, "test")
|
||
}
|
||
|
||
// dungeonRuntimeOwnsCharacter 的语义在本用例里被间接依赖:撤退扇出只带"这一份
|
||
// runtime 真正属于他"的成员,免得把别人的副本算成他的。这条把它钉住,
|
||
// 免得将来"谁的副本"变了而撤退带错人。
|
||
func TestFiendWarPartyRetreatOwnershipPredicate(t *testing.T) {
|
||
runtime := &runtimeDungeonState{
|
||
raidDungeon: runtimeRaidDungeonBinding{RaidKey: 1},
|
||
}
|
||
if dungeonRuntimeOwnsCharacter(runtime, 1) {
|
||
t.Fatal("没有角色绑定的 runtime 不该认领任何人")
|
||
}
|
||
runtime.Character = dnfrepo.CharacterRecord{CharacterID: "1"}
|
||
if !dungeonRuntimeOwnsCharacter(runtime, 1) {
|
||
t.Fatal("runtime 绑定的角色应当被认领")
|
||
}
|
||
if dungeonRuntimeOwnsCharacter(runtime, 2) {
|
||
t.Fatal("别人的角色不该被这份 runtime 认领")
|
||
}
|
||
runtime.partyDungeonOwner = &runtimePartyDungeonOwner{leaderID: 1}
|
||
if !dungeonRuntimeOwnsCharacter(runtime, 1) {
|
||
t.Fatal("有队伍时队长本人仍应被认领")
|
||
}
|
||
_ = worldmap.DungeonRunActive
|
||
_ = time.Second
|
||
}
|