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

841 lines
30 KiB
Go

package dnfbridge
import (
"errors"
"fmt"
"sync"
dnfmonster "longheng.io/server/internal/modules/dnf/monster"
"longheng.io/server/internal/modules/dnf/worldmap"
)
var (
errDungeonMonsterIDInvalid = errors.New("dnf dungeon monster id is invalid")
errDungeonMonsterDefinitionMiss = errors.New("dnf dungeon monster definition is missing")
errDungeonMonsterObjectKeyRange = errors.New("dnf dungeon monster object key exceeds current EXE range")
errDungeonMonsterObjectNotFound = errors.New("dnf dungeon monster runtime object is not found")
errDungeonMonsterAlreadyAnnounced = errors.New("dnf dungeon monster was already announced")
errDungeonMonsterNotAnnounced = errors.New("dnf dungeon monster was not announced to the client")
errDungeonMonsterAlreadyDefeated = errors.New("dnf dungeon monster was already defeated")
errDungeonActorAlreadyAnnounced = errors.New("dnf dungeon actor was already announced")
errDungeonActorOwnerConflict = errors.New("dnf dungeon actor owner conflicts with the room session")
errDungeonActorNotHostile = errors.New("dnf dungeon actor is not a hostile room owner")
errDungeonNonHostileRetirement = errors.New("dnf non-hostile AI actor retirement is not eligible")
)
const firstDungeonMonsterObjectKey = uint32(currentSceneBootstrapObjectKey) + 1
type runtimeDungeonMonsterState string
const (
runtimeDungeonMonsterPlanned runtimeDungeonMonsterState = "planned"
runtimeDungeonMonsterAnnounced runtimeDungeonMonsterState = "announced"
runtimeDungeonMonsterDefeated runtimeDungeonMonsterState = "defeated"
)
type runtimeDungeonMonster struct {
ObjectKey uint32
Reference worldmap.HostileReference
Spawn worldmap.MonsterSpawn
Definition dnfmonster.Monster
State runtimeDungeonMonsterState
RoomClearSkipByte byte
// Boss mutation is client-executed, but its one-shot lottery is owned by
// the server. Keeping both bits on the announced room actor prevents a
// client from retrying the 10% draw or replaying an approval in a later
// room while leaving normal monster combat client-authoritative.
BossMutationAttempted bool
BossMutationAllowed bool
}
type runtimeDungeonRoomSnapshot struct {
Coordinate worldmap.RoomCoordinate
MapID int64
Monsters []runtimeDungeonMonster
ExtendedActors []runtimeDungeonExtendedActor
RetainedSpecialSpawns []runtimeDungeonRetainedSpecialSpawn
Diagnostics []string
OpaqueHostiles []worldmap.HostileReference
SpecialObjectCount int
AICharacterCount int
EventMonsterConditionPublished bool
}
type runtimeDungeonRoom struct {
mu sync.RWMutex
coordinate worldmap.RoomCoordinate
mapID int64
monsters []runtimeDungeonMonster
byObjectKey map[uint32]int
opaqueHostiles []worldmap.HostileReference
extendedActors []runtimeDungeonExtendedActor
extendedByObjectKey map[uint32]int
retainedSpecialSpawns []runtimeDungeonRetainedSpecialSpawn
diagnostics []string
specialObjectCount int
aiCharacterCount int
eventMonsterConditionPublished bool
}
func newRuntimeDungeonRoom(
scene worldmap.DungeonRoomScene,
catalog *pvfDungeonMonsterCatalog,
firstObjectKey uint32,
) (*runtimeDungeonRoom, uint32, error) {
if catalog == nil {
return nil, firstObjectKey, errDungeonMonsterCatalogUnavailable
}
room := &runtimeDungeonRoom{
coordinate: scene.Coordinate,
mapID: scene.Map.Map.ID,
monsters: make([]runtimeDungeonMonster, 0, len(scene.Monsters)),
byObjectKey: make(map[uint32]int, len(scene.Monsters)),
}
nextObjectKey := firstObjectKey
for index, spawn := range scene.Monsters {
if spawn.MonsterID <= 0 {
return nil, firstObjectKey, fmt.Errorf(
"%w: map=%d room=%s index=%d id=%d",
errDungeonMonsterIDInvalid, room.mapID, room.coordinate, index, spawn.MonsterID,
)
}
definition, ok, findErr := catalog.Find(spawn.MonsterID)
if findErr != nil {
return nil, firstObjectKey, fmt.Errorf(
"resolve monster definition: map=%d room=%s index=%d id=%d: %w",
room.mapID, room.coordinate, index, spawn.MonsterID, findErr,
)
}
if !ok {
return nil, firstObjectKey, fmt.Errorf(
"%w: map=%d room=%s index=%d id=%d",
errDungeonMonsterDefinitionMiss, room.mapID, room.coordinate, index, spawn.MonsterID,
)
}
if nextObjectKey == 0 || nextObjectKey > uint32(^uint16(0)) {
return nil, firstObjectKey, fmt.Errorf(
"%w: map=%d room=%s index=%d object_key=%d",
errDungeonMonsterObjectKeyRange, room.mapID, room.coordinate, index, nextObjectKey,
)
}
reference := worldmap.HostileReference{Kind: worldmap.HostileMonster, Index: index}
roomClearSkipByte := byte(0)
// PVF [monster team] is positional to [monster]. Team 0 belongs to the
// player/story side (quest 3344 Nonora in map 76329), so it must be
// announced to the client as a non-room-clearing actor, matching the
// server-side blocking set (worldmap monsterSpawnBlocksRoom) which already
// excludes team 0. Missing team rows keep the historical hostile default.
if index < len(scene.Map.Map.MonsterTeam) && scene.Map.Map.MonsterTeam[index] == 0 {
roomClearSkipByte = 1
}
room.byObjectKey[nextObjectKey] = len(room.monsters)
room.monsters = append(room.monsters, runtimeDungeonMonster{
ObjectKey: nextObjectKey,
Reference: reference,
Spawn: spawn,
Definition: definition,
State: runtimeDungeonMonsterPlanned,
RoomClearSkipByte: roomClearSkipByte,
})
nextObjectKey++
}
for _, reference := range scene.ExpectedHostiles {
if reference.Kind != worldmap.HostileMonster {
room.opaqueHostiles = append(room.opaqueHostiles, reference)
}
}
return room, nextObjectKey, nil
}
func (r *runtimeDungeonRoom) MonsterCount() int {
if r == nil {
return 0
}
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.monsters)
}
func (r *runtimeDungeonRoom) OpaqueHostileCount() int {
if r == nil {
return 0
}
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.opaqueHostiles)
}
// HostsRuntimeActor reports whether this room installed objectKey in either
// runtime actor table. A code that the room actually installed is owned by the
// monster/AI retirement paths, so the client-owned passive-object paths must
// never claim it. A code with no runtime actor here can only be a PVF map
// object the client spawned on its own.
func (r *runtimeDungeonRoom) HostsRuntimeActor(objectKey uint32) bool {
if r == nil || objectKey == 0 {
return false
}
r.mu.RLock()
defer r.mu.RUnlock()
if _, ok := r.byObjectKey[objectKey]; ok {
return true
}
_, ok := r.extendedByObjectKey[objectKey]
return ok
}
func (r *runtimeDungeonRoom) MarkMonsterNonBlocking(monsterIndex int) (runtimeDungeonMonster, error) {
if r == nil {
return runtimeDungeonMonster{}, errDungeonMonsterObjectNotFound
}
r.mu.Lock()
defer r.mu.Unlock()
if monsterIndex < 0 || monsterIndex >= len(r.monsters) {
return runtimeDungeonMonster{}, fmt.Errorf("%w: monster_index=%d", errDungeonMonsterObjectNotFound, monsterIndex)
}
r.monsters[monsterIndex].RoomClearSkipByte = 1
return r.monsters[monsterIndex], nil
}
func (r *runtimeDungeonRoom) NonBlockingMonsterReferences() []worldmap.HostileReference {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]worldmap.HostileReference, 0)
for _, monster := range r.monsters {
if monster.RoomClearSkipByte != 0 {
result = append(result, monster.Reference)
}
}
return result
}
func (r *runtimeDungeonRoom) Snapshot() runtimeDungeonRoomSnapshot {
if r == nil {
return runtimeDungeonRoomSnapshot{}
}
r.mu.RLock()
defer r.mu.RUnlock()
return runtimeDungeonRoomSnapshot{
Coordinate: r.coordinate,
MapID: r.mapID,
Monsters: cloneRuntimeDungeonMonsters(r.monsters),
ExtendedActors: cloneRuntimeDungeonExtendedActors(r.extendedActors),
RetainedSpecialSpawns: cloneRuntimeDungeonRetainedSpecialSpawns(r.retainedSpecialSpawns),
Diagnostics: append([]string(nil), r.diagnostics...),
OpaqueHostiles: append([]worldmap.HostileReference(nil), r.opaqueHostiles...),
SpecialObjectCount: r.specialObjectCount,
AICharacterCount: r.aiCharacterCount,
EventMonsterConditionPublished: r.eventMonsterConditionPublished,
}
}
func (r *runtimeDungeonRoom) eventMonsterConditionWasPublished() bool {
if r == nil {
return false
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.eventMonsterConditionPublished
}
func (r *runtimeDungeonRoom) markEventMonsterConditionPublished() {
if r == nil {
return
}
r.mu.Lock()
r.eventMonsterConditionPublished = true
r.mu.Unlock()
}
// AnnouncedHostileAICharacterCode resolves the PVF AI-character identity
// behind one current-room object key before its authoritative death is
// committed. Quest [enemy reward item] rows use that AI character code with
// enemy_type=2; the op39 object key is only a per-room runtime identity.
func (r *runtimeDungeonRoom) AnnouncedHostileAICharacterCode(objectKey uint32) (int64, bool) {
if r == nil || objectKey == 0 {
return 0, false
}
r.mu.RLock()
defer r.mu.RUnlock()
index, found := r.extendedByObjectKey[objectKey]
if !found || index < 0 || index >= len(r.extendedActors) {
return 0, false
}
actor := r.extendedActors[index]
if actor.Kind != runtimeDungeonActorAICharacter || actor.HostileReference == nil ||
actor.State != runtimeDungeonMonsterAnnounced || actor.Packet.Code == 0 {
return 0, false
}
if actor.AICharacter == nil || actor.AICharacter.Code != int64(actor.Packet.Code) {
return 0, false
}
return actor.AICharacter.Code, true
}
func (r *runtimeDungeonRoom) AttachExtendedActors(plan runtimeDungeonExtendedActorPlan) error {
if r == nil {
return errDungeonWorldMapUnavailable
}
r.mu.Lock()
defer r.mu.Unlock()
extendedByObjectKey := make(map[uint32]int, len(plan.Actors))
handled := make(map[worldmap.HostileReference]struct{})
for index, actor := range plan.Actors {
if _, exists := r.byObjectKey[actor.ObjectKey]; exists {
return fmt.Errorf("%w: object_key=%d normal_and_extended", errDungeonActorOwnerConflict, actor.ObjectKey)
}
if _, exists := extendedByObjectKey[actor.ObjectKey]; exists {
return fmt.Errorf("%w: object_key=%d duplicate_extended", errDungeonActorOwnerConflict, actor.ObjectKey)
}
extendedByObjectKey[actor.ObjectKey] = index
if actor.HostileReference != nil {
handled[*actor.HostileReference] = struct{}{}
}
}
remaining := make([]worldmap.HostileReference, 0, len(r.opaqueHostiles))
for _, reference := range r.opaqueHostiles {
if _, ok := handled[reference]; !ok {
remaining = append(remaining, reference)
}
}
r.extendedActors = cloneRuntimeDungeonExtendedActors(plan.Actors)
r.extendedByObjectKey = extendedByObjectKey
r.retainedSpecialSpawns = cloneRuntimeDungeonRetainedSpecialSpawns(plan.RetainedSpecialSpawns)
r.diagnostics = append([]string(nil), plan.Diagnostics...)
r.specialObjectCount = plan.SpecialObjectCount
r.aiCharacterCount = plan.AICharacterCount
r.opaqueHostiles = remaining
return nil
}
func (r *runtimeDungeonRoom) AnnounceAllActors(session *worldmap.DungeonSession) (int, error) {
if r == nil {
return 0, errDungeonMonsterObjectNotFound
}
if session == nil {
return 0, worldmap.ErrDungeonRunRequired
}
r.mu.Lock()
defer r.mu.Unlock()
type binding struct {
reference worldmap.HostileReference
objectKey uint32
}
bindings := make([]binding, 0, len(r.monsters)+len(r.extendedActors))
for _, monster := range r.monsters {
if monster.State != runtimeDungeonMonsterPlanned {
return 0, fmt.Errorf("%w: object_key=%d state=%s", errDungeonActorAlreadyAnnounced, monster.ObjectKey, monster.State)
}
bindings = append(bindings, binding{reference: monster.Reference, objectKey: monster.ObjectKey})
}
for _, actor := range r.extendedActors {
if actor.State != runtimeDungeonMonsterPlanned {
return 0, fmt.Errorf("%w: object_key=%d state=%s", errDungeonActorAlreadyAnnounced, actor.ObjectKey, actor.State)
}
if actor.HostileReference != nil {
bindings = append(bindings, binding{reference: *actor.HostileReference, objectKey: actor.ObjectKey})
}
}
scene, ok := session.Scene()
if !ok {
return 0, worldmap.ErrDungeonRunRequired
}
seenReferences := make(map[worldmap.HostileReference]struct{}, len(bindings))
seenObjectKeys := make(map[uint32]struct{}, len(bindings))
for _, candidate := range bindings {
if !runtimeSceneContainsHostile(scene.ExpectedHostiles, candidate.reference) {
return 0, fmt.Errorf("%w: kind=%s index=%d object_key=%d reference_missing",
errDungeonActorOwnerConflict,
candidate.reference.Kind,
candidate.reference.Index,
candidate.objectKey,
)
}
if _, exists := seenReferences[candidate.reference]; exists {
return 0, fmt.Errorf("%w: kind=%s index=%d duplicate_reference",
errDungeonActorOwnerConflict,
candidate.reference.Kind,
candidate.reference.Index,
)
}
if _, exists := seenObjectKeys[candidate.objectKey]; exists {
return 0, fmt.Errorf("%w: object_key=%d duplicate_key", errDungeonActorOwnerConflict, candidate.objectKey)
}
if previous, exists := scene.RuntimeObjects[candidate.objectKey]; exists {
return 0, fmt.Errorf("%w: object_key=%d existing_kind=%s existing_index=%d",
errDungeonActorOwnerConflict,
candidate.objectKey,
previous.Kind,
previous.Index,
)
}
for objectKey, previous := range scene.RuntimeObjects {
if previous == candidate.reference {
return 0, fmt.Errorf("%w: kind=%s index=%d existing_object_key=%d",
errDungeonActorOwnerConflict,
candidate.reference.Kind,
candidate.reference.Index,
objectKey,
)
}
}
seenReferences[candidate.reference] = struct{}{}
seenObjectKeys[candidate.objectKey] = struct{}{}
}
for _, candidate := range bindings {
if err := session.BindHostileObject(candidate.reference, candidate.objectKey); err != nil {
return 0, fmt.Errorf("bind announced dungeon actor: %w", err)
}
}
for index := range r.monsters {
r.monsters[index].State = runtimeDungeonMonsterAnnounced
}
for index := range r.extendedActors {
r.extendedActors[index].State = runtimeDungeonMonsterAnnounced
}
return len(r.monsters) + len(r.extendedActors), nil
}
func runtimeSceneContainsHostile(values []worldmap.HostileReference, want worldmap.HostileReference) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func (r *runtimeDungeonRoom) AnnounceMonster(objectKey uint32, session *worldmap.DungeonSession) error {
if r == nil {
return errDungeonMonsterObjectNotFound
}
r.mu.Lock()
defer r.mu.Unlock()
index, ok := r.byObjectKey[objectKey]
if !ok {
return fmt.Errorf("%w: object_key=%d", errDungeonMonsterObjectNotFound, objectKey)
}
monster := &r.monsters[index]
if monster.State != runtimeDungeonMonsterPlanned {
return fmt.Errorf("%w: object_key=%d state=%s", errDungeonMonsterAlreadyAnnounced, objectKey, monster.State)
}
if session == nil {
return worldmap.ErrDungeonRunRequired
}
if err := session.BindHostileObject(monster.Reference, objectKey); err != nil {
return err
}
monster.State = runtimeDungeonMonsterAnnounced
return nil
}
func (r *runtimeDungeonRoom) ContainsAnnouncedActorObjectKey(objectKey uint32) bool {
if r == nil || objectKey == 0 {
return false
}
r.mu.RLock()
defer r.mu.RUnlock()
if index, ok := r.byObjectKey[objectKey]; ok {
return r.monsters[index].State != runtimeDungeonMonsterPlanned
}
if index, ok := r.extendedByObjectKey[objectKey]; ok {
return r.extendedActors[index].State != runtimeDungeonMonsterPlanned
}
return false
}
func (r *runtimeDungeonRoom) ContainsAnnouncedNonHostileAICharacterObjectKey(objectKey uint32) bool {
if r == nil || objectKey == 0 {
return false
}
r.mu.RLock()
defer r.mu.RUnlock()
index, ok := r.extendedByObjectKey[objectKey]
if !ok {
return false
}
actor := r.extendedActors[index]
return actor.Kind == runtimeDungeonActorAICharacter &&
actor.HostileReference == nil &&
actor.State == runtimeDungeonMonsterAnnounced
}
// AnnouncedMonster resolves only an ordinary PVF [monster] owned by this room
// and only while it is in the announced state. Extended actors, planned
// monsters, defeated monsters, and stale keys are intentionally excluded.
func (r *runtimeDungeonRoom) AnnouncedMonster(objectKey uint32) (runtimeDungeonMonster, bool) {
if r == nil || objectKey == 0 {
return runtimeDungeonMonster{}, false
}
r.mu.RLock()
defer r.mu.RUnlock()
index, ok := r.byObjectKey[objectKey]
if !ok || r.monsters[index].State != runtimeDungeonMonsterAnnounced {
return runtimeDungeonMonster{}, false
}
return r.monsters[index], true
}
// MonsterByReference resolves the PVF monster definition behind a room
// HostileReference regardless of whether the live actor object is the ordinary
// monster row or a current-EXE extended actor bound to the same reference. 86JP
// performs kill reward/quest checks from the logical monster/enemy code after a
// death event; callers still own the actor state transition before using this
// helper for EXP, drops, or quest trigger inputs.
func (r *runtimeDungeonRoom) MonsterByReference(
reference worldmap.HostileReference,
allowedStates ...runtimeDungeonMonsterState,
) (runtimeDungeonMonster, bool) {
if r == nil || reference.Kind != worldmap.HostileMonster || reference.Index < 0 {
return runtimeDungeonMonster{}, false
}
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
}
r.mu.RLock()
defer r.mu.RUnlock()
for _, monster := range r.monsters {
if monster.Reference == reference && stateAllowed(monster.State) {
return monster, true
}
}
return runtimeDungeonMonster{}, false
}
type runtimeDungeonDeathTarget struct {
ObjectKey uint32
Reference worldmap.HostileReference
ResponseKind currentDungeonDeathResponseKind
}
func (r *runtimeDungeonRoom) CommitActorDeathReport(
objectKey uint32,
session *worldmap.DungeonSession,
) (runtimeDungeonDeathTarget, bool, error) {
if r == nil {
return runtimeDungeonDeathTarget{}, false, errDungeonMonsterObjectNotFound
}
r.mu.Lock()
defer r.mu.Unlock()
target, state, err := r.deathTargetLocked(objectKey)
if err != nil {
return runtimeDungeonDeathTarget{}, false, err
}
switch *state {
case runtimeDungeonMonsterAnnounced:
case runtimeDungeonMonsterDefeated:
return runtimeDungeonDeathTarget{}, false, fmt.Errorf("%w: object_key=%d", errDungeonMonsterAlreadyDefeated, objectKey)
default:
return runtimeDungeonDeathTarget{}, false, fmt.Errorf("%w: object_key=%d state=%s", errDungeonMonsterNotAnnounced, objectKey, *state)
}
if session == nil {
return runtimeDungeonDeathTarget{}, false, worldmap.ErrDungeonRunRequired
}
scene, ok := session.Scene()
if !ok {
return runtimeDungeonDeathTarget{}, false, worldmap.ErrDungeonRunRequired
}
reference, bound := scene.RuntimeObjects[objectKey]
if !bound || reference != target.Reference {
return runtimeDungeonDeathTarget{}, false, fmt.Errorf(
"%w: object_key=%d expected=%s/%d actual=%s/%d bound=%t",
errDungeonActorOwnerConflict,
objectKey,
target.Reference.Kind,
target.Reference.Index,
reference.Kind,
reference.Index,
bound,
)
}
cleared, err := session.MarkHostileDefeated(objectKey)
if err != nil {
return runtimeDungeonDeathTarget{}, false, err
}
*state = runtimeDungeonMonsterDefeated
return target, cleared, nil
}
// CommitRemainingAnnouncedHostilesAfterBoss mirrors the 86JP dungeon-clear
// domain rule used after a real boss death: the boss death is the authoritative
// end of combat, so remaining already-announced hostile actors in the same
// room must not keep the room/settlement chain stuck. It intentionally does
// not fabricate a boss death; callers invoke it only after CommitActorDeathReport
// accepted the boss actor's own op39.
func (r *runtimeDungeonRoom) CommitRemainingAnnouncedHostilesAfterBoss(
bossObjectKey uint32,
session *worldmap.DungeonSession,
) ([]runtimeDungeonDeathTarget, bool, error) {
if r == nil {
return nil, false, errDungeonMonsterObjectNotFound
}
if session == nil {
return nil, false, worldmap.ErrDungeonRunRequired
}
snapshot := r.Snapshot()
var forced []runtimeDungeonDeathTarget
cleared := false
for _, monster := range snapshot.Monsters {
if monster.ObjectKey == bossObjectKey || monster.State != runtimeDungeonMonsterAnnounced {
continue
}
target, roomCleared, err := r.CommitActorDeathReport(monster.ObjectKey, session)
if err != nil {
if errors.Is(err, errDungeonMonsterAlreadyDefeated) || errors.Is(err, worldmap.ErrHostileAlreadyDefeated) {
continue
}
return forced, cleared, err
}
forced = append(forced, target)
cleared = cleared || roomCleared
}
for _, actor := range snapshot.ExtendedActors {
if actor.ObjectKey == bossObjectKey || actor.State != runtimeDungeonMonsterAnnounced ||
actor.HostileReference == nil {
continue
}
target, roomCleared, err := r.CommitActorDeathReport(actor.ObjectKey, session)
if err != nil {
if errors.Is(err, errDungeonMonsterAlreadyDefeated) || errors.Is(err, worldmap.ErrHostileAlreadyDefeated) {
continue
}
return forced, cleared, err
}
forced = append(forced, target)
cleared = cleared || roomCleared
}
return forced, cleared, nil
}
// RetireNonHostileAIActor marks an announced, non-blocking character AI as
// retired without touching the hostile-object/session clear state. The
// current client still reports this visual helper through DIE_MONSTER with
// owner=0xffff, but it has no HostileReference and must not be committed via
// MarkHostileDefeated. A repeated report for the same already-retired actor
// returns the same target so the caller can safely replay the ACK.
func (r *runtimeDungeonRoom) RetireNonHostileAIActor(
objectKey uint32,
) (runtimeDungeonDeathTarget, error) {
if r == nil {
return runtimeDungeonDeathTarget{}, errDungeonMonsterObjectNotFound
}
r.mu.Lock()
defer r.mu.Unlock()
index, ok := r.extendedByObjectKey[objectKey]
if !ok {
return runtimeDungeonDeathTarget{}, fmt.Errorf("%w: object_key=%d", errDungeonMonsterObjectNotFound, objectKey)
}
actor := &r.extendedActors[index]
if actor.Kind != runtimeDungeonActorAICharacter || actor.HostileReference != nil || actor.Packet.Blocking != 0 {
return runtimeDungeonDeathTarget{}, fmt.Errorf(
"%w: object_key=%d kind=%s blocking=%d hostile_reference=%t",
errDungeonNonHostileRetirement,
objectKey,
actor.Kind,
actor.Packet.Blocking,
actor.HostileReference != nil,
)
}
if actor.State != runtimeDungeonMonsterAnnounced && actor.State != runtimeDungeonMonsterDefeated {
return runtimeDungeonDeathTarget{}, fmt.Errorf(
"%w: object_key=%d state=%s",
errDungeonNonHostileRetirement,
objectKey,
actor.State,
)
}
actor.State = runtimeDungeonMonsterDefeated
return runtimeDungeonDeathTarget{
ObjectKey: objectKey,
ResponseKind: currentDungeonDeathResponseAICharacter,
}, nil
}
// RetireSpecialMonsterActor acknowledges a PVF [special passive object] child
// [monster] actor that the current client reports through DIE_MONSTER, but that
// is intentionally not a hostile room owner. These actors are visual/runtime
// children of a map object (for example enemies spawned out of a breakable
// building), so retiring them must not call MarkHostileDefeated and must not
// affect room-clear, EXP, drops, or quest kill accounting.
func (r *runtimeDungeonRoom) RetireSpecialMonsterActor(
objectKey uint32,
) (runtimeDungeonDeathTarget, bool, error) {
if r == nil {
return runtimeDungeonDeathTarget{}, false, errDungeonMonsterObjectNotFound
}
r.mu.Lock()
defer r.mu.Unlock()
index, ok := r.extendedByObjectKey[objectKey]
if !ok {
return runtimeDungeonDeathTarget{}, false, nil
}
actor := &r.extendedActors[index]
if actor.Kind != runtimeDungeonActorSpecialMonster {
return runtimeDungeonDeathTarget{}, false, nil
}
if actor.HostileReference != nil {
return runtimeDungeonDeathTarget{}, true, fmt.Errorf(
"%w: object_key=%d kind=%s hostile_reference=%t",
errDungeonNonHostileRetirement,
objectKey,
actor.Kind,
true,
)
}
if actor.State != runtimeDungeonMonsterAnnounced && actor.State != runtimeDungeonMonsterDefeated {
return runtimeDungeonDeathTarget{}, true, fmt.Errorf(
"%w: object_key=%d kind=%s state=%s",
errDungeonNonHostileRetirement,
objectKey,
actor.Kind,
actor.State,
)
}
actor.State = runtimeDungeonMonsterDefeated
return runtimeDungeonDeathTarget{
ObjectKey: objectKey,
ResponseKind: currentDungeonDeathResponseMonster,
}, true, nil
}
// RetireTournamentActor mirrors an accepted tournament death in the room
// actor table without affecting ordinary hostile-clear accounting.
func (r *runtimeDungeonRoom) RetireTournamentActor(objectKey uint32, responseKind currentDungeonDeathResponseKind) (runtimeDungeonDeathTarget, error) {
if r == nil {
return runtimeDungeonDeathTarget{}, errDungeonMonsterObjectNotFound
}
r.mu.Lock()
defer r.mu.Unlock()
index, ok := r.extendedByObjectKey[objectKey]
if !ok {
return runtimeDungeonDeathTarget{}, fmt.Errorf("%w: object_key=%d", errDungeonMonsterObjectNotFound, objectKey)
}
actor := &r.extendedActors[index]
if actor.Kind != runtimeDungeonActorTournament {
return runtimeDungeonDeathTarget{}, fmt.Errorf("%w: object_key=%d kind=%s", errDungeonNonHostileRetirement, objectKey, actor.Kind)
}
if actor.State == runtimeDungeonMonsterDefeated {
return runtimeDungeonDeathTarget{ObjectKey: objectKey, ResponseKind: responseKind}, nil
}
if actor.State != runtimeDungeonMonsterAnnounced {
return runtimeDungeonDeathTarget{}, fmt.Errorf("%w: object_key=%d state=%s", errDungeonNonHostileRetirement, objectKey, actor.State)
}
actor.State = runtimeDungeonMonsterDefeated
return runtimeDungeonDeathTarget{ObjectKey: objectKey, ResponseKind: responseKind}, nil
}
func (r *runtimeDungeonRoom) RetireHellPartyActor(objectKey uint32) (runtimeDungeonDeathTarget, bool, error) {
if r == nil {
return runtimeDungeonDeathTarget{}, false, errDungeonMonsterObjectNotFound
}
r.mu.Lock()
defer r.mu.Unlock()
index, ok := r.extendedByObjectKey[objectKey]
if !ok {
return runtimeDungeonDeathTarget{}, false, nil
}
actor := &r.extendedActors[index]
if actor.Kind != runtimeDungeonActorHellParty {
return runtimeDungeonDeathTarget{}, false, nil
}
if actor.HostileReference != nil {
return runtimeDungeonDeathTarget{}, true, fmt.Errorf("%w: hell actor object_key=%d", errDungeonActorOwnerConflict, objectKey)
}
if actor.State != runtimeDungeonMonsterAnnounced && actor.State != runtimeDungeonMonsterDefeated {
return runtimeDungeonDeathTarget{}, true, fmt.Errorf("%w: hell actor object_key=%d state=%s", errDungeonNonHostileRetirement, objectKey, actor.State)
}
actor.State = runtimeDungeonMonsterDefeated
responseKind := currentDungeonDeathResponseMonster
if actor.Packet.Type >= 5 && actor.Packet.Type <= 8 {
responseKind = currentDungeonDeathResponseAICharacter
}
return runtimeDungeonDeathTarget{ObjectKey: objectKey, ResponseKind: responseKind}, true, nil
}
func (r *runtimeDungeonRoom) deathTargetLocked(
objectKey uint32,
) (runtimeDungeonDeathTarget, *runtimeDungeonMonsterState, error) {
if index, ok := r.byObjectKey[objectKey]; ok {
monster := &r.monsters[index]
return runtimeDungeonDeathTarget{
ObjectKey: objectKey,
Reference: monster.Reference,
ResponseKind: currentDungeonDeathResponseMonster,
}, &monster.State, nil
}
index, ok := r.extendedByObjectKey[objectKey]
if !ok {
return runtimeDungeonDeathTarget{}, nil, fmt.Errorf("%w: object_key=%d", errDungeonMonsterObjectNotFound, objectKey)
}
actor := &r.extendedActors[index]
if actor.HostileReference == nil {
return runtimeDungeonDeathTarget{}, nil, fmt.Errorf("%w: object_key=%d kind=%s", errDungeonActorNotHostile, objectKey, actor.Kind)
}
responseKind := currentDungeonDeathResponseMonster
if actor.Kind == runtimeDungeonActorAICharacter {
responseKind = currentDungeonDeathResponseAICharacter
}
return runtimeDungeonDeathTarget{
ObjectKey: objectKey,
Reference: *actor.HostileReference,
ResponseKind: responseKind,
}, &actor.State, nil
}
func cloneRuntimeDungeonMonsters(values []runtimeDungeonMonster) []runtimeDungeonMonster {
out := make([]runtimeDungeonMonster, len(values))
for index, value := range values {
out[index] = value
out[index].Definition.Scalars = cloneFloatMap(value.Definition.Scalars)
out[index].Definition.Sections = append([]string(nil), value.Definition.Sections...)
}
return out
}
func cloneRuntimeDungeonExtendedActors(values []runtimeDungeonExtendedActor) []runtimeDungeonExtendedActor {
out := make([]runtimeDungeonExtendedActor, len(values))
for index, value := range values {
out[index] = value
if value.HostileReference != nil {
reference := *value.HostileReference
out[index].HostileReference = &reference
}
if value.AICharacter != nil {
out[index].AICharacter = cloneRuntimeDungeonAICharacter(*value.AICharacter)
}
if value.AICharacterMetadata != nil {
definition := cloneDungeonAICharacterDefinition(*value.AICharacterMetadata)
out[index].AICharacterMetadata = &definition
}
}
return out
}
func cloneRuntimeDungeonRetainedSpecialSpawns(values []runtimeDungeonRetainedSpecialSpawn) []runtimeDungeonRetainedSpecialSpawn {
return append([]runtimeDungeonRetainedSpecialSpawn(nil), values...)
}
func cloneFloatMap(values map[string]float64) map[string]float64 {
if len(values) == 0 {
return nil
}
out := make(map[string]float64, len(values))
for key, value := range values {
out[key] = value
}
return out
}