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

293 lines
11 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 (
"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
}