DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,434 @@
package dnfbridge
import (
"context"
"os"
"strconv"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
"longheng.io/server/internal/modules/dnf/worldmap"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// The party-wide destroy-object clear.
//
// 线上事实(2026-09-29 22:28-22:38 本地,raid 1835059,二阶段暗卢克):
// 5066 能源储藏室的 boss 房 1:1/74247 里两个人**同时**进去,全队只有**一个人**的
// 客户端会报那个共享被动对象的 op39 → 只有他被结算,另一个人 11~28 秒后被迫 op42
// 弃权(三次 run 三次如此)。修法见
// completeCurrentDungeonDestroyObjectClearForPartyLocked。
//
// 这一组用例用真 Script.pvf 跑完整链路:两个人各自的实例、各自走到同一个 boss 房、
// 只有一个人报 op39,然后断言**两个人各自**都拿到 op115 + op31(且只有一次)。
// 走迷宫时不设队伍状态,队伍状态只在最后一步之前装上 —— 这样被测的只有 fan-out
// 本身,不牵扯组队进图的加载屏障。
// destroyObjectPartyCase is one real dungeon whose boss room is cleared by
// destroying a client-owned passive object.
type destroyObjectPartyCase struct {
name string
dungeonID uint32
bossMapID int64
bossObject uint32
}
// 5063 能源控制室(祭坛那个,上一轮已修)与 5066 能源储藏室(机械王座那个,
// 服主这轮报的)逐字同形:都是 [clear condition][destroy object] <id> 1,boss 房
// 里没有 [monster]、只有一个 [passive object]。见
// TestRealScriptPVFNoAntonDungeonDeclaresDestroyObjectClearCondition 的全表扫描。
var destroyObjectPartyCases = []destroyObjectPartyCase{
{name: "5063 energy control", dungeonID: 5063, bossMapID: 74232, bossObject: 64062},
{name: "5066 energy save", dungeonID: 5066, bossMapID: 74247, bossObject: 64060},
}
// destroyObjectPartyMember is one walked member of the fixture party.
type destroyObjectPartyMember struct {
charID uint16
session *gameSession
runtime *runtimeDungeonState
}
// destroyObjectPartyFixture prepares one real dungeon run per character and walks
// every one of them into the dungeon's boss room.
func destroyObjectPartyFixture(
t *testing.T,
testCase destroyObjectPartyCase,
charIDs []uint16,
) (*Service, []destroyObjectPartyMember, []worldmap.RoomCoordinate) {
t.Helper()
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to run the real-PVF party destroy-object smoke")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
table, err := worldmap.LoadSource(context.Background(), archive, worldmap.Options{})
if err != nil {
t.Fatal(err)
}
resolver, err := worldmap.NewResolver(table)
if err != nil {
t.Fatal(err)
}
monsters, err := newPVFDungeonMonsterCatalog(archive)
if err != nil {
t.Fatal(err)
}
aiCharacters, err := newPVFDungeonAICharacterCatalog(archive)
if err != nil {
t.Fatal(err)
}
repositories := dnfrepoMemoryGroup(t, charIDs)
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
initialEquipmentArchive: archive,
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonAICharacterTable: aiCharacters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 1, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameSessions: map[uint16]*gameSession{},
onlinePlayers: newOnlinePlayerManager(),
}
var path []worldmap.RoomCoordinate
members := make([]destroyObjectPartyMember, 0, len(charIDs))
for _, charID := range charIDs {
session := &gameSession{
conn: &bufferConn{},
connID: "party-destroy-object-" + strconv.Itoa(int(charID)),
selectedCharacterID: charID,
}
runtime, startScene, err := service.prepareDungeonRuntime(
context.Background(),
session,
dungeoncmd.SelectDungeonRequest{DungeonID: testCase.dungeonID, Difficulty: 0},
)
if err != nil {
t.Fatalf("char %d prepare real dungeon %d: %v", charID, testCase.dungeonID, err)
}
session.dungeon.runtime = runtime
service.mu.Lock()
service.gameSessions[charID] = session
service.mu.Unlock()
if path == nil {
path = destroyObjectPartyBossPath(t, resolver, runtime, startScene)
}
members = append(members, destroyObjectPartyMember{
charID: charID, session: session, runtime: runtime,
})
}
if len(path) < 2 {
t.Fatalf("dungeon %d has no path to its boss room: %v", testCase.dungeonID, path)
}
boss := path[len(path)-1]
for index := range members {
destroyObjectPartyWalk(t, service, members[index], path)
scene := members[index].runtime.Session.Snapshot().Scene
if !scene.Boss || scene.Map.Map.ID != testCase.bossMapID || scene.Coordinate != boss {
t.Fatalf("char %d landed in %+v map=%d want boss %s map %d",
members[index].charID, scene.Coordinate, scene.Map.Map.ID, boss, testCase.bossMapID)
}
if source := currentDungeonDestroyObjectClearConditionSource(
members[index].runtime, scene, testCase.bossObject); source == "" {
t.Fatalf("char %d boss room does not carry the maze's destroy-object condition: %+v",
members[index].charID, scene.PassiveObjects)
}
}
return service, members, path
}
// dnfrepoMemoryGroup stores the characters the party fixture needs.
func dnfrepoMemoryGroup(t *testing.T, charIDs []uint16) dnfrepo.Group {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
for _, charID := range charIDs {
id := strconv.Itoa(int(charID))
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: id,
AccountID: "account-1",
Name: "party-member",
Job: "2",
Level: 90,
Stats: map[string]int64{"fatigue": 200},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: id,
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
}
return repositories
}
// joinDestroyObjectParty puts the walked members into one ordinary party. It runs
// after the walk so the dungeon-entry machinery is untouched by the new behaviour.
func joinDestroyObjectParty(members []destroyObjectPartyMember) {
partyMembers := make([]alignedcmd.PartyMemberState, 0, len(members))
for slot, member := range members {
partyMembers = append(partyMembers, alignedcmd.PartyMemberState{
UserID: member.charID,
Slot: byte(slot),
})
}
for _, member := range members {
storeRuntimePartyState(member.session, alignedcmd.PartyState{
PartyID: 3,
MaxMembers: 4,
Members: partyMembers,
})
}
}
// destroyObjectPartyBossPath is the maze's own start -> boss room walk.
func destroyObjectPartyBossPath(
t *testing.T,
resolver *worldmap.Resolver,
runtime *runtimeDungeonState,
startScene worldmap.DungeonRoomScene,
) []worldmap.RoomCoordinate {
t.Helper()
topology, err := worldmap.BuildDungeonLayout(
resolver,
runtime.Dungeon.ID,
runtime.MazeIndex,
func(choice worldmap.DungeonMapChoice) (int64, error) { return choice.Candidates[0].ID, nil },
)
if err != nil {
t.Fatal(err)
}
queue := []worldmap.RoomCoordinate{startScene.Coordinate}
visited := map[worldmap.RoomCoordinate]bool{startScene.Coordinate: true}
previous := make(map[worldmap.RoomCoordinate]worldmap.RoomCoordinate)
for len(queue) != 0 && !visited[runtime.BossCoordinate] {
current := queue[0]
queue = queue[1:]
room, ok := topology.Room(current)
if !ok {
t.Fatalf("path room missing: %s", current)
}
for _, neighbor := range room.Neighbors {
if visited[neighbor.Coordinate] {
continue
}
visited[neighbor.Coordinate] = true
previous[neighbor.Coordinate] = current
queue = append(queue, neighbor.Coordinate)
}
}
if !visited[runtime.BossCoordinate] {
t.Fatalf("boss room %s unreachable from %s", runtime.BossCoordinate, startScene.Coordinate)
}
path := []worldmap.RoomCoordinate{runtime.BossCoordinate}
for path[len(path)-1] != startScene.Coordinate {
path = append(path, previous[path[len(path)-1]])
}
for left, right := 0, len(path)-1; left < right; left, right = left+1, right-1 {
path[left], path[right] = path[right], path[left]
}
return path
}
// destroyObjectPartyWalk drives one member along the path, clearing each room the
// way the real client does.
func destroyObjectPartyWalk(
t *testing.T,
service *Service,
member destroyObjectPartyMember,
path []worldmap.RoomCoordinate,
) {
t.Helper()
clearRoom := func() {
room := member.runtime.Room.Snapshot()
planned := false
for _, monster := range room.Monsters {
planned = planned || monster.State == runtimeDungeonMonsterPlanned
}
for _, actor := range room.ExtendedActors {
planned = planned || actor.State == runtimeDungeonMonsterPlanned
}
if planned {
if _, err := member.runtime.Room.AnnounceAllActors(member.runtime.Session); err != nil {
t.Fatalf("char %d announce room: %v", member.charID, err)
}
}
scene := member.runtime.Session.Snapshot().Scene
for _, reference := range scene.BlockingHostiles {
var objectKey uint32
for candidate, bound := range scene.RuntimeObjects {
if bound == reference {
objectKey = candidate
break
}
}
if objectKey == 0 {
t.Fatalf("char %d blocker has no runtime object: %+v", member.charID, reference)
}
if _, _, defeated := currentDungeonDefeatedActor(member.runtime, scene, objectKey); defeated {
continue
}
if _, _, err := member.runtime.Room.CommitActorDeathReport(objectKey, member.runtime.Session); err != nil {
t.Fatalf("char %d defeat blocker key=%d: %v", member.charID, objectKey, err)
}
scene = member.runtime.Session.Snapshot().Scene
}
if !member.runtime.Session.Snapshot().Scene.Cleared {
t.Fatalf("char %d room did not clear: %+v", member.charID, member.runtime.Session.Snapshot().Scene)
}
}
for _, target := range path[1:] {
clearRoom()
body := make([]byte, dungeoncmd.MoveMapRequestSize)
body[0] = byte(target.X)
body[1] = byte(target.Y)
member.session.conn.(*bufferConn).write.Reset()
if err := service.handleDungeonMoveMap(member.session, body); err != nil {
t.Fatalf("char %d move to %s: %v", member.charID, target, err)
}
if scene := member.runtime.Session.Snapshot().Scene; scene.Coordinate != target {
t.Fatalf("char %d move committed %s want %s", member.charID, scene.Coordinate, target)
}
}
}
// settlementPacketsOnWire counts the two packets that make up one member's own
// settlement entry: op115 (boss-die check) and op31 (settlement entry).
func settlementPacketsOnWire(t *testing.T, member destroyObjectPartyMember) (bossDieChecks, settlementEntries int) {
t.Helper()
for _, packet := range splitAllFinishBridgePackets(t, member.session.conn.(*bufferConn).write.Bytes()) {
switch packet.Header.MsgID {
case uint16(dnfenum.CmdPacketNotifyBossDieCheck):
bossDieChecks++
case currentDungeonSettlementEntryMsgID:
settlementEntries++
}
}
return bossDieChecks, settlementEntries
}
// TestRealScriptPVFDestroyObjectClearReachesEveryPartyMember is the fix's pin:
// one shared object, one op39, and **both** members of the party must each get
// their own op115 + op31. The second half is the idempotence half: when the other
// member's own op39 arrives afterwards, nobody may be settled a second time.
func TestRealScriptPVFDestroyObjectClearReachesEveryPartyMember(t *testing.T) {
for _, testCase := range destroyObjectPartyCases {
t.Run(testCase.name, func(t *testing.T) {
service, members, _ := destroyObjectPartyFixture(t, testCase, []uint16{51, 10})
joinDestroyObjectParty(members)
reporter, peer := members[0], members[1]
for _, member := range members {
member.session.conn.(*bufferConn).write.Reset()
}
// Only ONE client reports the shared object, exactly like the live run.
if err := service.handleDungeonMonsterDeath(
reporter.session,
tutorialScopeVariableZeroCombatDeathBody(testCase.bossObject),
); err != nil {
t.Fatalf("reporter op39: %v", err)
}
for _, member := range members {
acks, entries := settlementPacketsOnWire(t, member)
if acks != 1 || entries != 1 {
t.Fatalf("char %d got op115=%d op31=%d want one each "+
"(the shared clear object must reach every member standing in the boss room)",
member.charID, acks, entries)
}
if !member.runtime.ordinaryFinalRoomClearAccepted {
t.Fatalf("char %d did not commit its final-room clear", member.charID)
}
}
// Idempotence: the peer's own op39 arrives late. Nobody settles twice.
for _, member := range members {
member.session.conn.(*bufferConn).write.Reset()
}
if err := service.handleDungeonMonsterDeath(
peer.session,
tutorialScopeVariableZeroCombatDeathBody(testCase.bossObject),
); err != nil {
t.Fatalf("peer op39: %v", err)
}
for _, member := range members {
acks, entries := settlementPacketsOnWire(t, member)
if acks != 0 || entries != 0 {
t.Fatalf("char %d was settled twice: op115=%d op31=%d", member.charID, acks, entries)
}
}
})
}
}
// TestRealScriptPVFDestroyObjectClearLeavesOtherRoomsAlone is the boundary pin:
// a party member still standing in another room must NOT be settled by somebody
// else's clear.
func TestRealScriptPVFDestroyObjectClearLeavesOtherRoomsAlone(t *testing.T) {
testCase := destroyObjectPartyCases[0]
service, members, path := destroyObjectPartyFixture(t, testCase, []uint16{51, 10})
joinDestroyObjectParty(members)
// Walk the peer back one room, so the two members are in different rooms of the
// same run when the reporter clears the boss room. The step is the room the
// walk came through, which is adjacent by construction.
body := make([]byte, dungeoncmd.MoveMapRequestSize)
body[0] = byte(path[len(path)-2].X)
body[1] = byte(path[len(path)-2].Y)
if err := service.handleDungeonMoveMap(members[1].session, body); err != nil {
t.Fatalf("peer move back one room: %v", err)
}
if scene := members[1].runtime.Session.Snapshot().Scene; scene.Coordinate == members[0].runtime.BossCoordinate {
t.Fatal("the peer is still standing in the boss room")
}
for _, member := range members {
member.session.conn.(*bufferConn).write.Reset()
}
if err := service.handleDungeonMonsterDeath(
members[0].session,
tutorialScopeVariableZeroCombatDeathBody(testCase.bossObject),
); err != nil {
t.Fatalf("reporter op39: %v", err)
}
if acks, entries := settlementPacketsOnWire(t, members[0]); acks != 1 || entries != 1 {
t.Fatalf("reporter got op115=%d op31=%d want one each", acks, entries)
}
if acks, entries := settlementPacketsOnWire(t, members[1]); acks != 0 || entries != 0 {
t.Fatalf("a member in another room was settled: op115=%d op31=%d", acks, entries)
}
if members[1].runtime.ordinaryFinalRoomClearAccepted {
t.Fatal("a member in another room had its final room committed")
}
}
// TestRealScriptPVFDestroyObjectClearSoloRunUnchanged keeps the single-player run
// byte-for-byte on the old behaviour: one member, one op39, one settlement.
func TestRealScriptPVFDestroyObjectClearSoloRunUnchanged(t *testing.T) {
for _, testCase := range destroyObjectPartyCases {
t.Run(testCase.name, func(t *testing.T) {
service, members, _ := destroyObjectPartyFixture(t, testCase, []uint16{51})
solo := members[0]
solo.session.conn.(*bufferConn).write.Reset()
if err := service.handleDungeonMonsterDeath(
solo.session,
tutorialScopeVariableZeroCombatDeathBody(testCase.bossObject),
); err != nil {
t.Fatalf("solo op39: %v", err)
}
if acks, entries := settlementPacketsOnWire(t, solo); acks != 1 || entries != 1 {
t.Fatalf("solo run got op115=%d op31=%d want one each", acks, entries)
}
})
}
}