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,292 @@
package dnfbridge
import (
"fmt"
"longheng.io/server/internal/modules/dnf/worldmap"
)
// currentDungeonDestroyObjectClearConditionSource binds a client-owned
// passive object to the active maze's explicit one-object clear condition.
// The object is proven from the current map, the current maze and the room's
// runtime actor table instead of from op38, whose target field is u16.
//
// The object code is deliberately NOT filtered by width. An earlier version
// rejected every code that fits u16 on the assumption that such a code is
// always an installed actor. Luke phase two disproves that assumption: the
// energy-control boss room declares its clear target as a plain map object.
// dungeon/Luke/Phase2/Energy_control.dgn carries
//
// [clear condition]
// [destroy object]
// 64062 1
// [/clear condition]
//
// and the boss map map/Luke_Raid/Phase2/Energy_control/74232.map declares that
// same 64062 only under [passive object], with no [monster] section at all. The
// client therefore kills it locally and reports an op39 that no runtime actor
// owns, while the maze still waits for it. Rejecting the u16 code stranded the
// room: the live log shows
// game-dungeon-monster-death-blocked object_key=64062 reason=non_hostile_ai_retirement_rejected
// error="dnf dungeon monster runtime object is not found: object_key=64062"
// and the run never settled.
//
// The width rule is replaced by the property it was standing in for: a code is
// only accepted here when the room holds no runtime actor for it, so a real
// announced monster/AI keeps its own path. Everything else stays fail-closed -
// exactly one matching passive placement in the current map AND an exact
// [destroy object] target/count row in the current maze are still required.
func currentDungeonDestroyObjectClearConditionSource(
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
objectKey uint32,
) string {
if runtime == nil || runtime.Room == nil || runtime.Session == nil ||
!scene.Cleared || !scene.Boss ||
!runtime.BossSet || scene.Coordinate != runtime.BossCoordinate ||
runtime.MazeIndex < 0 || runtime.MazeIndex >= len(runtime.Dungeon.Mazes) {
return ""
}
room := runtime.Room.Snapshot()
if room.Coordinate != scene.Coordinate || room.MapID != scene.Map.Map.ID {
return ""
}
if runtime.Room.HostsRuntimeActor(objectKey) {
return ""
}
placementCount := int64(0)
for _, object := range scene.PassiveObjects {
if object.ObjectID == int64(objectKey) {
placementCount++
}
}
if placementCount != 1 {
return ""
}
for _, condition := range runtime.Dungeon.Mazes[runtime.MazeIndex].ClearConditions {
if normalizeDungeonPVFSymbol(condition.Type) == "destroy object" &&
condition.TargetID == int64(objectKey) && condition.Count == placementCount {
return fmt.Sprintf(
"current_pvf_clear_condition_destroy_object_target_%d_count_%d",
condition.TargetID,
condition.Count,
)
}
}
return ""
}
// handleCurrentDungeonDestroyObjectClearConditionDeathLocked accepts the
// variable op39 emitted after the current client has already removed a PVF
// passive object locally. No op38 is sent because its target field is u16.
// The caller owns session.dungeon.mu.
func (s *Service) handleCurrentDungeonDestroyObjectClearConditionDeathLocked(
session *gameSession,
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
objectKey uint32,
) (bool, error) {
source := currentDungeonDestroyObjectClearConditionSource(runtime, scene, objectKey)
if source == "" {
return false, nil
}
s.logGameEvent(session, "game-dungeon-destroy-object-clear-condition-accepted",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"object_key", objectKey,
"death_ack_sent", false,
"reason", "current_exe_op38_target_is_u16_and_client_owned_passive_object_is_already_destroyed",
"source", source)
if err := s.completeCurrentDungeonOrdinaryFinalRoomAfterDeathLocked(
session,
runtime,
scene,
objectKey,
); err != nil {
return true, err
}
// 通关目标在客户端那边是**队伍共享**的:boss 房一只怪都没有,全队只有**一个**
// 客户端会报这个 op39(线上 2026-09-29 的 5066 能源储藏室,三次 run 都是两人
// 同时进 boss 房、只有一人被结算、另一人 11~28 秒后被迫 op42 弃权)。所以接受
// 之后必须替**同队还在同一个房间**的成员各做一遍收尾,否则他们没有任何触发点,
// 只能按 ESC。
s.completeCurrentDungeonDestroyObjectClearForPartyLocked(session, runtime, scene, objectKey)
return true, nil
}
// completeCurrentDungeonDestroyObjectClearForPartyLocked 把一次已接受的
// destroy-object 通关收尾**补发给同队的其他成员**。
//
// 为什么需要它:`[clear condition][destroy object]` 的房间(卢克二阶段 5063
// 能源控制室 64062 / 5066 能源储藏室 64060,见
// TestRealScriptPVFNoAntonDungeonDeclaresDestroyObjectClearCondition 的全表扫描)
// 的 boss 房只有**一个客户端自有被动对象**、没有怪物。那个对象是全队共享的,所以
// 只有一名成员的客户端会发 op39;其他人既没怪可打、房间又已经是 clear,服务端不
// 替他们收尾就永远等不到结算。
//
// 边界(一条都不能松,逐条对应测试):
//
// 1. 只对**同一个团 + 同一个副本迷宫 + 同一个房间坐标 + 同一张地图**的成员fan-out;
// 跨团(raidDungeon.RaidKey 不同)一律不动。
// 2. 对方的房间必须是 `Cleared && Boss`(由 completeCurrentDungeonOrdinaryFinalRoomAfterDeathLocked
// 内部的 currentDungeonOrdinaryFinalRoomReady 再确认一次)。
// 3. 对方**尚未** ordinaryFinalRoomClearAccepted —— 这是幂等的第一道闸;第二道是
// completeCurrentDungeonOrdinaryFinalRoomAfterDeathLocked 开头的同名字段检查,
// 所以两个人**都**报了 op39 也不会有人拿到两次 op115/op31。
// 4. 还在别的房间的人**不结算**(坐标或地图不同直接跳过)。
// 5. 锁纪律同战况表回城:onlineGameSession + dungeon.mu.TryLock(),**忙则跳过、
// 绝不阻塞对端**(等待会把两个会话互锁),跳过计数写进汇总日志而不是静默。
//
// 调用方持有 session.dungeon.mu(本函数的调用点就是这样),只对**其他**会话 TryLock,
// 不会与自己的锁重入。
func (s *Service) completeCurrentDungeonDestroyObjectClearForPartyLocked(
session *gameSession,
runtime *runtimeDungeonState,
scene worldmap.DungeonRoomScene,
targetObjectKey uint32,
) {
if s == nil || session == nil || runtime == nil || runtime.Session == nil {
return
}
party := runtimePartyStateSnapshot(session)
if party.PartyID <= 0 {
// 单人 run:没有别人要补,行为与改动前逐字相同。
return
}
members := runtimePartyMembers(party)
if len(members) <= 1 {
return
}
callerID := session.selectedCharacterID
callerRaidKey := runtime.raidDungeon.RaidKey
completed, skippedBusy, skippedOtherRoom, offline := 0, 0, 0, 0
for _, member := range members {
if member.UserID == 0 || member.UserID == callerID {
continue
}
outcome := s.completeCurrentDungeonDestroyObjectClearForMember(
member.UserID, callerID, callerRaidKey, runtime, scene, targetObjectKey)
switch outcome {
case destroyObjectPartyCompleted:
completed++
case destroyObjectPartySkippedBusy:
skippedBusy++
case destroyObjectPartySkippedOtherRoom:
skippedOtherRoom++
case destroyObjectPartyOffline:
offline++
}
}
if completed == 0 && skippedBusy == 0 && skippedOtherRoom == 0 && offline == 0 {
return
}
s.logGameEvent(session, "game-dungeon-destroy-object-clear-party-fanout",
"char_id", callerID,
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"raid_key", callerRaidKey,
"object_key", targetObjectKey,
"members", len(members),
"completed", completed,
"skipped_busy", skippedBusy,
"skipped_other_room", skippedOtherRoom,
"offline", offline,
"reason", "the_shared_clear_object_is_reported_by_one_client_only")
}
// destroyObjectPartyOutcome is what one party member's fan-out attempt did.
type destroyObjectPartyOutcome int
const (
destroyObjectPartyNotEligible destroyObjectPartyOutcome = iota
destroyObjectPartyCompleted
destroyObjectPartyOffline
destroyObjectPartySkippedBusy
destroyObjectPartySkippedOtherRoom
)
// completeCurrentDungeonDestroyObjectClearForMember runs the same final-room
// completion for one teammate. Every guard that makes the fan-out safe lives here.
func (s *Service) completeCurrentDungeonDestroyObjectClearForMember(
memberID uint16,
callerID uint16,
callerRaidKey uint32,
callerRuntime *runtimeDungeonState,
callerScene worldmap.DungeonRoomScene,
targetObjectKey uint32,
) destroyObjectPartyOutcome {
target, online := s.onlineGameSession(memberID)
if !online || target == nil {
return destroyObjectPartyOffline
}
if !target.dungeon.mu.TryLock() {
// Documented discipline (see scheduleRuntimeRaidForcedGroupReturn and the
// war-table return): that member is inside its own packet path, and
// waiting here would deadlock two sessions against each other.
s.logPacketEvent("game-dungeon-destroy-object-clear-party-member-skipped",
"char_id", callerID,
"member_char_id", memberID,
"dungeon_id", callerRuntime.Dungeon.ID,
"room", callerScene.Coordinate.String(),
"map_id", callerScene.Map.Map.ID,
"reason", "peer_session_busy_never_block_a_packet_path")
return destroyObjectPartySkippedBusy
}
defer target.dungeon.mu.Unlock()
targetRuntime := target.dungeon.runtime
if targetRuntime == nil || targetRuntime == callerRuntime ||
targetRuntime.Session == nil || !dungeonRuntimeOwnsCharacter(targetRuntime, memberID) {
return destroyObjectPartyNotEligible
}
// 1) same raid (a raid member never completes a non-raid run and vice versa)
// and 2) the same dungeon + maze.
if targetRuntime.raidDungeon.RaidKey != callerRaidKey ||
targetRuntime.Dungeon.ID != callerRuntime.Dungeon.ID ||
targetRuntime.MazeIndex != callerRuntime.MazeIndex {
return destroyObjectPartyNotEligible
}
targetScene, ok := targetRuntime.Session.Scene()
if !ok {
return destroyObjectPartyNotEligible
}
// 3) the same room coordinate on the same map, and 4) it is a cleared boss
// room -- i.e. exactly the room whose shared object was just reported.
if targetScene.Coordinate != callerScene.Coordinate ||
targetScene.Map.Map.ID != callerScene.Map.Map.ID {
return destroyObjectPartySkippedOtherRoom
}
if !targetScene.Cleared || !targetScene.Boss {
return destroyObjectPartyNotEligible
}
// 5) idempotence: a member that already settled never settles again, whichever
// of the two op39 reports arrives first. (The completion function re-checks the
// same field, so this is the outer half of the same guarantee.)
if targetRuntime.ordinaryFinalRoomClearAccepted {
return destroyObjectPartyNotEligible
}
if err := s.completeCurrentDungeonOrdinaryFinalRoomAfterDeathLocked(
target,
targetRuntime,
targetScene,
targetObjectKey,
); err != nil {
s.logGameEvent(target, "game-dungeon-destroy-object-clear-party-member-failed",
"char_id", memberID,
"caller_char_id", callerID,
"dungeon_id", targetRuntime.Dungeon.ID,
"room", targetScene.Coordinate.String(),
"map_id", targetScene.Map.Map.ID,
"object_key", targetObjectKey,
"error", err)
return destroyObjectPartyNotEligible
}
return destroyObjectPartyCompleted
}