DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,254 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user