服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
408 lines
15 KiB
Go
408 lines
15 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
)
|
|
|
|
// currentDungeonOwnerSentinelAnnouncedOrdinaryMonster proves the exact case
|
|
// where the current EXE reports an ordinary hostile monster death with
|
|
// owner=0xffff. Live packet evidence shows that some ordinary room monsters use
|
|
// this sentinel even though neighboring monsters use the player object key.
|
|
// The sentinel is never sufficient by itself: the target must be the exact
|
|
// announced, still-live ordinary monster bound by the active room scene.
|
|
//
|
|
// This deliberately excludes extended APC/AI actors, passive objects, friendly
|
|
// team actors, stale object keys, already-defeated actors, and actors from
|
|
// another room. Tutorial owner-ffff deaths remain owned by the exact PVF+CMT
|
|
// proof in the caller. There are no dungeon, map, monster, or quest ID
|
|
// exceptions.
|
|
func currentDungeonOwnerSentinelAnnouncedOrdinaryMonster(
|
|
runtime *runtimeDungeonState,
|
|
scene worldmap.DungeonRoomScene,
|
|
objectKey uint32,
|
|
) (runtimeDungeonMonster, bool) {
|
|
// A room whose spawns are all on team 0 (cinematic dummy rooms such as quest
|
|
// 3599 / map 76114) is Cleared from the moment it is entered, yet the client
|
|
// still reports the dummy boss death through this sentinel shape. Do not
|
|
// gate on scene.Cleared here: the exact announced/still-live binding below
|
|
// already excludes stale and defeated targets.
|
|
if runtime == nil || runtime.Room == nil || runtime.Session == nil || objectKey == 0 {
|
|
return runtimeDungeonMonster{}, false
|
|
}
|
|
// A malformed or explicitly disabled tutorial definition must not fall
|
|
// through to this ordinary-dungeon compatibility path. Tutorial owner-ffff
|
|
// deaths remain owned by the exact PVF metadata+CMT proof above the caller;
|
|
// the namespace check keeps that boundary fail-closed when the metadata flag
|
|
// itself is absent or zero.
|
|
if isPVFTutorialDungeon(runtime) || currentDungeonPathUsesTutorialNamespace(runtime.Dungeon.Path) {
|
|
return runtimeDungeonMonster{}, false
|
|
}
|
|
room := runtime.Room.Snapshot()
|
|
if room.Coordinate != scene.Coordinate || room.MapID != scene.Map.Map.ID {
|
|
return runtimeDungeonMonster{}, false
|
|
}
|
|
monster, announced := runtime.Room.AnnouncedMonster(objectKey)
|
|
if !announced || monster.Reference.Kind != worldmap.HostileMonster {
|
|
return runtimeDungeonMonster{}, false
|
|
}
|
|
reference, bound := scene.RuntimeObjects[objectKey]
|
|
if !bound || reference != monster.Reference ||
|
|
!runtimeSceneContainsHostile(scene.ExpectedHostiles, reference) ||
|
|
dungeonSceneObjectDefeated(scene.DefeatedObjects, objectKey) {
|
|
return runtimeDungeonMonster{}, false
|
|
}
|
|
return monster, true
|
|
}
|
|
|
|
// currentDungeonOwnerSentinelConsumedLayerBaseBossTarget proves the story
|
|
// completion signal used after the client restores a consumed layered boss
|
|
// room. The base room is already clear because its dummy boss is on team 0,
|
|
// but the client still reports that exact announced dummy/boss through the
|
|
// variable owner-ffff op39 before starting settlement.
|
|
func currentDungeonOwnerSentinelConsumedLayerBaseBossTarget(
|
|
runtime *runtimeDungeonState,
|
|
scene worldmap.DungeonRoomScene,
|
|
objectKey uint32,
|
|
) (runtimeDungeonMonster, string, bool) {
|
|
if runtime == nil || runtime.Room == nil || runtime.Session == nil || objectKey == 0 ||
|
|
!scene.Cleared || !scene.Boss || runtime.LayeredMapActive || runtime.LayeredMapIndex != -1 ||
|
|
!runtime.BossSet || scene.Coordinate != runtime.BossCoordinate {
|
|
return runtimeDungeonMonster{}, "", false
|
|
}
|
|
chain := runtime.LayerChains[scene.Coordinate]
|
|
if chain == nil || !chain.Consumed || !chain.FinalAckPending ||
|
|
chain.BaseKey != runtimeDungeonRoomKeyFromScene(scene) {
|
|
return runtimeDungeonMonster{}, "", false
|
|
}
|
|
room := runtime.Room.Snapshot()
|
|
if room.Coordinate != scene.Coordinate || room.MapID != scene.Map.Map.ID {
|
|
return runtimeDungeonMonster{}, "", false
|
|
}
|
|
monster, announced := runtime.Room.AnnouncedMonster(objectKey)
|
|
if !announced || monster.Reference.Kind != worldmap.HostileMonster ||
|
|
!currentDungeonOrdinaryMonsterLooksBoss(monster) {
|
|
return runtimeDungeonMonster{}, "", false
|
|
}
|
|
reference, bound := scene.RuntimeObjects[objectKey]
|
|
if !bound || reference != monster.Reference ||
|
|
!runtimeSceneContainsHostile(scene.ExpectedHostiles, reference) ||
|
|
runtimeSceneContainsHostile(scene.BlockingHostiles, reference) ||
|
|
dungeonSceneObjectDefeated(scene.DefeatedObjects, objectKey) {
|
|
return runtimeDungeonMonster{}, "", false
|
|
}
|
|
return monster, "current_pvf_consumed_layer_base_nonblocking_boss", true
|
|
}
|
|
|
|
// currentDungeonOwnerSentinelAnnouncedHostileAI proves the corresponding
|
|
// owner=0xffff form for an extended hostile AI actor. Some PVF APCs are
|
|
// announced with a hostile reference but report DIE_MONSTER with the sentinel
|
|
// owner; they must use the normal hostile death transaction so the client
|
|
// receives the death ACK without being treated as a friendly visual helper.
|
|
func currentDungeonOwnerSentinelAnnouncedHostileAI(
|
|
runtime *runtimeDungeonState,
|
|
scene worldmap.DungeonRoomScene,
|
|
objectKey uint32,
|
|
) bool {
|
|
if runtime == nil || runtime.Room == nil || runtime.Session == nil || objectKey == 0 {
|
|
return false
|
|
}
|
|
if isPVFTutorialDungeon(runtime) || currentDungeonPathUsesTutorialNamespace(runtime.Dungeon.Path) {
|
|
return false
|
|
}
|
|
room := runtime.Room.Snapshot()
|
|
if room.Coordinate != scene.Coordinate || room.MapID != scene.Map.Map.ID {
|
|
return false
|
|
}
|
|
for _, actor := range room.ExtendedActors {
|
|
if actor.ObjectKey != objectKey || actor.Kind != runtimeDungeonActorAICharacter ||
|
|
actor.State != runtimeDungeonMonsterAnnounced || actor.HostileReference == nil {
|
|
continue
|
|
}
|
|
reference := *actor.HostileReference
|
|
bound, ok := scene.RuntimeObjects[objectKey]
|
|
return ok && bound == reference &&
|
|
runtimeSceneContainsHostile(scene.ExpectedHostiles, reference) &&
|
|
!dungeonSceneObjectDefeated(scene.DefeatedObjects, objectKey)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// currentDungeonOwnerSentinelBlockingClearTarget is the narrower subset that
|
|
// may trigger the existing boss/explicit-clear-target combat-end behavior.
|
|
// Accepting another announced ordinary owner-sentinel death must not promote it
|
|
// to a boss or force-retire neighboring hostiles.
|
|
func currentDungeonOwnerSentinelBlockingClearTarget(
|
|
runtime *runtimeDungeonState,
|
|
scene worldmap.DungeonRoomScene,
|
|
objectKey uint32,
|
|
) (runtimeDungeonMonster, string, bool) {
|
|
monster, ok := currentDungeonOwnerSentinelAnnouncedOrdinaryMonster(runtime, scene, objectKey)
|
|
if !ok || !runtimeSceneContainsHostile(scene.BlockingHostiles, monster.Reference) {
|
|
return runtimeDungeonMonster{}, "", false
|
|
}
|
|
|
|
if normalizeDungeonPVFSymbol(monster.Spawn.SuffixMarker) == "boss" {
|
|
return monster, "current_pvf_monster_suffix_boss", true
|
|
}
|
|
if normalizeDungeonPVFSymbol(monster.Spawn.Rank) == "boss" {
|
|
return monster, "current_pvf_monster_spawn_rank_boss", true
|
|
}
|
|
if normalizeDungeonPVFSymbol(monster.Definition.Rank) == "boss" {
|
|
return monster, "current_pvf_monster_definition_rank_boss", true
|
|
}
|
|
if source := currentDungeonClearConditionSource(runtime, monster); source != "" {
|
|
return monster, source, true
|
|
}
|
|
return runtimeDungeonMonster{}, "", false
|
|
}
|
|
|
|
func currentDungeonPathUsesTutorialNamespace(value string) bool {
|
|
normalized := "/" + strings.Trim(normalizeDungeonRuntimePath(value), "/") + "/"
|
|
return strings.Contains(normalized, "/tutorial/") || strings.Contains(normalized, "/newtutorial/")
|
|
}
|
|
|
|
func currentDungeonRuntimeObjectBossActor(
|
|
runtime *runtimeDungeonState,
|
|
objectKey uint32,
|
|
allowedStates ...runtimeDungeonMonsterState,
|
|
) (bool, byte, string) {
|
|
if runtime == nil || runtime.Room == nil || objectKey == 0 {
|
|
return false, 0, ""
|
|
}
|
|
allowed := make(map[runtimeDungeonMonsterState]struct{}, len(allowedStates))
|
|
for _, state := range allowedStates {
|
|
allowed[state] = struct{}{}
|
|
}
|
|
stateAllowed := func(state runtimeDungeonMonsterState) bool {
|
|
if len(allowed) == 0 {
|
|
return true
|
|
}
|
|
_, ok := allowed[state]
|
|
return ok
|
|
}
|
|
|
|
room := runtime.Room.Snapshot()
|
|
for _, monster := range room.Monsters {
|
|
if monster.ObjectKey != objectKey || !stateAllowed(monster.State) {
|
|
continue
|
|
}
|
|
if currentDungeonOrdinaryMonsterLooksBoss(monster) {
|
|
return true, 3, "86jp_monster_pvf_boss_rank"
|
|
}
|
|
if currentDungeonOrdinaryMonsterMatchesClearCondition(runtime, monster) {
|
|
return true, 3, "86jp_maze_clear_condition_target"
|
|
}
|
|
actorType, err := currentDungeonMonsterActorType(monster.Spawn)
|
|
if err == nil && currentDungeonIsBossActorType(actorType) {
|
|
return true, actorType, "86jp_monster_actor_type"
|
|
}
|
|
return false, actorType, "monster_actor_type_not_boss"
|
|
}
|
|
for _, actor := range room.ExtendedActors {
|
|
if actor.ObjectKey != objectKey || actor.HostileReference == nil ||
|
|
!stateAllowed(actor.State) {
|
|
continue
|
|
}
|
|
if currentDungeonIsBossActorType(actor.Packet.Type) {
|
|
return true, actor.Packet.Type, "86jp_extended_actor_type"
|
|
}
|
|
return false, actor.Packet.Type, "extended_actor_type_not_boss"
|
|
}
|
|
return false, 0, ""
|
|
}
|
|
|
|
func currentDungeonOrdinaryMonsterLooksBoss(monster runtimeDungeonMonster) bool {
|
|
if normalizeDungeonPVFSymbol(monster.Spawn.Rank) == "boss" ||
|
|
normalizeDungeonPVFSymbol(monster.Spawn.SuffixMarker) == "boss" ||
|
|
normalizeDungeonPVFSymbol(monster.Definition.Rank) == "boss" {
|
|
return true
|
|
}
|
|
actorType, err := currentDungeonMonsterActorType(monster.Spawn)
|
|
return err == nil && currentDungeonIsBossActorType(actorType)
|
|
}
|
|
|
|
func currentDungeonOrdinaryMonsterMatchesClearCondition(
|
|
runtime *runtimeDungeonState,
|
|
monster runtimeDungeonMonster,
|
|
) bool {
|
|
return currentDungeonClearConditionSource(runtime, monster) != ""
|
|
}
|
|
|
|
func currentDungeonClearConditionSource(runtime *runtimeDungeonState, monster runtimeDungeonMonster) string {
|
|
if runtime == nil || monster.Spawn.MonsterID <= 0 ||
|
|
runtime.MazeIndex < 0 || runtime.MazeIndex >= len(runtime.Dungeon.Mazes) {
|
|
return ""
|
|
}
|
|
for _, condition := range runtime.Dungeon.Mazes[runtime.MazeIndex].ClearConditions {
|
|
if condition.TargetID != monster.Spawn.MonsterID {
|
|
continue
|
|
}
|
|
kind := normalizeDungeonPVFSymbol(condition.Type)
|
|
switch kind {
|
|
case "hunt boss":
|
|
if currentDungeonOrdinaryMonsterLooksBoss(monster) {
|
|
return fmt.Sprintf("86jp_clear_condition_%s_target_%d", kind, condition.TargetID)
|
|
}
|
|
case "hunt monster", "destroy object":
|
|
return fmt.Sprintf("86jp_clear_condition_%s_target_%d", kind, condition.TargetID)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type currentDungeonForcedCombatEnd struct {
|
|
Targets []runtimeDungeonDeathTarget
|
|
Scene worldmap.DungeonRoomScene
|
|
Cleared bool
|
|
SceneOK bool
|
|
}
|
|
|
|
// prepareCurrentDungeonForcedCombatEndLocked commits every remaining hostile
|
|
// before any follow-up map transition can be accepted. Its visual op38 packets
|
|
// are deliberately deferred so a staged-story boss response can carry the
|
|
// next-room coordinate first.
|
|
func (s *Service) prepareCurrentDungeonForcedCombatEndLocked(
|
|
runtime *runtimeDungeonState,
|
|
sourceObjectKey uint32,
|
|
) (currentDungeonForcedCombatEnd, error) {
|
|
if runtime == nil || runtime.Room == nil || runtime.Session == nil {
|
|
return currentDungeonForcedCombatEnd{}, nil
|
|
}
|
|
targets, forcedCleared, err := runtime.Room.CommitRemainingAnnouncedHostilesAfterBoss(
|
|
sourceObjectKey,
|
|
runtime.Session,
|
|
)
|
|
if err != nil {
|
|
return currentDungeonForcedCombatEnd{Targets: targets}, err
|
|
}
|
|
scene, sceneOK := runtime.Session.Scene()
|
|
roomCleared := forcedCleared
|
|
if sceneOK {
|
|
roomCleared = roomCleared || scene.Cleared
|
|
}
|
|
return currentDungeonForcedCombatEnd{
|
|
Targets: targets,
|
|
Scene: scene,
|
|
Cleared: roomCleared,
|
|
SceneOK: sceneOK,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) flushCurrentDungeonForcedCombatEndLocked(
|
|
session *gameSession,
|
|
runtime *runtimeDungeonState,
|
|
sourceObjectKey uint32,
|
|
source string,
|
|
prepared currentDungeonForcedCombatEnd,
|
|
) (int, bool, error) {
|
|
forcedVisualDeathCount := 0
|
|
for _, forcedTarget := range prepared.Targets {
|
|
forcedBody, buildErr := buildCurrentDungeonDeathNotificationBodyWithDrops(
|
|
forcedTarget.ObjectKey,
|
|
forcedTarget.ResponseKind,
|
|
nil,
|
|
)
|
|
if buildErr != nil {
|
|
return forcedVisualDeathCount, prepared.Cleared, buildErr
|
|
}
|
|
if sendErr := s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketNotifyDieMonster),
|
|
forcedBody,
|
|
0,
|
|
); sendErr != nil {
|
|
return forcedVisualDeathCount, prepared.Cleared, sendErr
|
|
}
|
|
forcedVisualDeathCount++
|
|
}
|
|
|
|
if len(prepared.Targets) > 0 || prepared.Cleared {
|
|
if !prepared.SceneOK {
|
|
s.logGameEvent(session, "game-dungeon-combat-end-forced-room-clear",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"source_object_key", sourceObjectKey,
|
|
"source", source,
|
|
"forced_defeated_count", len(prepared.Targets),
|
|
"forced_visual_death_count", forcedVisualDeathCount,
|
|
"room_cleared", prepared.Cleared,
|
|
"flow_source", "86jp_boss_or_clear_decision_kills_remaining_hostiles")
|
|
return forcedVisualDeathCount, prepared.Cleared, nil
|
|
}
|
|
scene := prepared.Scene
|
|
s.logGameEvent(session, "game-dungeon-combat-end-forced-room-clear",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"source_object_key", sourceObjectKey,
|
|
"source", source,
|
|
"forced_defeated_count", len(prepared.Targets),
|
|
"forced_visual_death_count", forcedVisualDeathCount,
|
|
"defeated_actor_count", len(scene.DefeatedObjects),
|
|
"tracked_hostile_count", len(scene.ExpectedHostiles),
|
|
"blocking_hostile_count", len(scene.BlockingHostiles),
|
|
"room_cleared", prepared.Cleared,
|
|
"flow_source", "86jp_boss_or_clear_decision_kills_remaining_hostiles")
|
|
// Live evidence (2026-07-17 quest 3146 run) proves the client-side
|
|
// quest-scene object dies with this forced room clear while its later
|
|
// owner-0xffff op39 reports can no longer be matched, so the guarded
|
|
// type-3 hunt-enemy credit is owned here. A credit failure must never
|
|
// break the combat-end or settlement flow.
|
|
if _, creditErr := s.creditCurrentDungeonQuestObjectRoomClearLocked(
|
|
session,
|
|
runtime,
|
|
scene,
|
|
sourceObjectKey,
|
|
"boss_forced_room_clear_quest_connected_maze_unique_active_pvf_type3_target",
|
|
); creditErr != nil {
|
|
s.logGameEvent(session, "game-dungeon-quest-object-room-clear-deferred",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"source_object_key", sourceObjectKey,
|
|
"source", source,
|
|
"reason", "boss_forced_room_clear_quest_object_credit_failed",
|
|
"error", creditErr)
|
|
}
|
|
}
|
|
return forcedVisualDeathCount, prepared.Cleared, nil
|
|
}
|
|
|
|
// forceCurrentDungeonRemainingHostilesForCombatEndLocked preserves the
|
|
// immediate visual-death behavior for ordinary boss flows. Staged story maps
|
|
// call prepare/flush separately around their source boss op38.
|
|
func (s *Service) forceCurrentDungeonRemainingHostilesForCombatEndLocked(
|
|
session *gameSession,
|
|
runtime *runtimeDungeonState,
|
|
sourceObjectKey uint32,
|
|
source string,
|
|
) (int, int, bool, error) {
|
|
if session == nil || runtime == nil || runtime.Room == nil || runtime.Session == nil {
|
|
return 0, 0, false, nil
|
|
}
|
|
prepared, err := s.prepareCurrentDungeonForcedCombatEndLocked(runtime, sourceObjectKey)
|
|
if err != nil {
|
|
return len(prepared.Targets), 0, false, err
|
|
}
|
|
forcedVisualDeathCount, roomCleared, err := s.flushCurrentDungeonForcedCombatEndLocked(
|
|
session,
|
|
runtime,
|
|
sourceObjectKey,
|
|
source,
|
|
prepared,
|
|
)
|
|
return len(prepared.Targets), forcedVisualDeathCount, roomCleared, err
|
|
}
|
|
|
|
func isCurrentDungeonForceClearAlreadyDefeated(err error) bool {
|
|
return errors.Is(err, errDungeonMonsterAlreadyDefeated) ||
|
|
errors.Is(err, errDungeonActorNotHostile)
|
|
}
|