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