服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
332 lines
13 KiB
Go
332 lines
13 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"longheng.io/server/internal/modules/dnf/worldmap"
|
||
)
|
||
|
||
var (
|
||
errDungeonExtendedActorCodeRange = errors.New("dnf dungeon extended actor code exceeds current EXE range")
|
||
errDungeonExtendedActorIndexRange = errors.New("dnf dungeon extended actor index exceeds current EXE range")
|
||
errDungeonExtendedActorObjectKeyRange = errors.New("dnf dungeon extended actor object key exceeds current EXE range")
|
||
errDungeonSpecialSpawnKind = errors.New("dnf dungeon special object spawn kind is unsupported")
|
||
errDungeonSpecialSpawnLevel = errors.New("dnf dungeon special object monster level is invalid")
|
||
errDungeonAICharacterDefinitionMiss = errors.New("dnf dungeon AI character definition is missing")
|
||
errDungeonAICharacterType = errors.New("dnf dungeon AI character type is unsupported")
|
||
errDungeonAICharacterFaction = errors.New("dnf dungeon AI character faction is unsupported")
|
||
)
|
||
|
||
type runtimeDungeonExtendedActorKind string
|
||
|
||
const (
|
||
runtimeDungeonActorSpecialObject runtimeDungeonExtendedActorKind = "special_object"
|
||
runtimeDungeonActorSpecialMonster runtimeDungeonExtendedActorKind = "special_monster"
|
||
runtimeDungeonActorAICharacter runtimeDungeonExtendedActorKind = "ai_character"
|
||
runtimeDungeonActorHellParty runtimeDungeonExtendedActorKind = "hell_party"
|
||
runtimeDungeonActorTournament runtimeDungeonExtendedActorKind = "tournament"
|
||
)
|
||
|
||
type runtimeDungeonRetainedSpecialSpawn struct {
|
||
ObjectIndex int
|
||
SpawnIndex int
|
||
Spawn worldmap.SpecialObjectSpawn
|
||
}
|
||
|
||
type runtimeDungeonExtendedActor struct {
|
||
Kind runtimeDungeonExtendedActorKind
|
||
ObjectKey uint32
|
||
Packet currentDungeonStartMapActor
|
||
HostileReference *worldmap.HostileReference
|
||
AICharacter *worldmap.AICharacter
|
||
AICharacterMetadata *pvfDungeonAICharacterDefinition
|
||
SpecialObjectIndex int
|
||
SpecialSpawnIndex int
|
||
State runtimeDungeonMonsterState
|
||
}
|
||
|
||
type runtimeDungeonExtendedActorPlan struct {
|
||
Actors []runtimeDungeonExtendedActor
|
||
RetainedSpecialSpawns []runtimeDungeonRetainedSpecialSpawn
|
||
Diagnostics []string
|
||
NextObjectKey uint32
|
||
SpecialObjectCount int
|
||
AICharacterCount int
|
||
}
|
||
|
||
func planRuntimeDungeonExtendedActors(
|
||
scene worldmap.DungeonRoomScene,
|
||
monsterCatalog *pvfDungeonMonsterCatalog,
|
||
aiCharacterCatalog *pvfDungeonAICharacterCatalog,
|
||
basis worldmap.OptionalInt,
|
||
firstObjectKey uint32,
|
||
companionEnabled ...bool,
|
||
) (runtimeDungeonExtendedActorPlan, error) {
|
||
plan := runtimeDungeonExtendedActorPlan{
|
||
NextObjectKey: firstObjectKey,
|
||
SpecialObjectCount: len(scene.SpecialPassiveObjects),
|
||
AICharacterCount: len(scene.AICharacters),
|
||
}
|
||
for objectIndex, object := range scene.SpecialPassiveObjects {
|
||
// Inline [hellparty] metadata belongs to the dedicated Hell runtime.
|
||
// Keep the pillar in the room's passive-object metadata, but do not
|
||
// project a duplicate ordinary type-9 actor for the same object.
|
||
if object.ObjectID <= 0 {
|
||
plan.Diagnostics = append(plan.Diagnostics, fmt.Sprintf("special object index %d has non-positive object id %d", objectIndex, object.ObjectID))
|
||
} else if len(object.HellParty) == 0 {
|
||
compatible, listedPath, err := monsterCatalog.specialObjectStartActorCompatibility(object.ObjectID)
|
||
if err != nil {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("resolve special object index %d id %d: %w", objectIndex, object.ObjectID, err)
|
||
}
|
||
if !compatible {
|
||
plan.Diagnostics = append(plan.Diagnostics, fmt.Sprintf(
|
||
"retain map-owned special object index %d id %d path %q outside op29 actor list",
|
||
objectIndex,
|
||
object.ObjectID,
|
||
listedPath,
|
||
))
|
||
} else {
|
||
actor, err := newRuntimeDungeonExtendedActor(
|
||
runtimeDungeonActorSpecialObject,
|
||
plan.NextObjectKey,
|
||
object.ObjectID,
|
||
currentDungeonStartMapActor{PacketIndex: uint32(objectIndex), Level: 0, Type: 9},
|
||
)
|
||
if err != nil {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("special object index %d: %w", objectIndex, err)
|
||
}
|
||
actor.SpecialObjectIndex = objectIndex
|
||
plan.Actors = append(plan.Actors, actor)
|
||
plan.NextObjectKey++
|
||
}
|
||
}
|
||
|
||
for spawnIndex, spawn := range object.Spawns {
|
||
kind := normalizeDungeonPVFSymbol(spawn.Kind)
|
||
if kind != "monster" {
|
||
plan.RetainedSpecialSpawns = append(plan.RetainedSpecialSpawns, runtimeDungeonRetainedSpecialSpawn{
|
||
ObjectIndex: objectIndex,
|
||
SpawnIndex: spawnIndex,
|
||
Spawn: spawn,
|
||
})
|
||
switch kind {
|
||
case "item", "trap", "hellparty", "quest":
|
||
continue
|
||
default:
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf(
|
||
"%w: object_index=%d spawn_index=%d kind=%q",
|
||
errDungeonSpecialSpawnKind,
|
||
objectIndex,
|
||
spawnIndex,
|
||
spawn.Kind,
|
||
)
|
||
}
|
||
}
|
||
if monsterCatalog == nil {
|
||
return runtimeDungeonExtendedActorPlan{}, errDungeonMonsterCatalogUnavailable
|
||
}
|
||
if objectIndex > int(^uint16(0)) || objectIndex > int(^uint8(0)) {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: special_object_index=%d", errDungeonExtendedActorIndexRange, objectIndex)
|
||
}
|
||
if spawnIndex > int(^uint32(0)) {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: special_spawn_index=%d", errDungeonExtendedActorIndexRange, spawnIndex)
|
||
}
|
||
if spawn.Code <= 0 || spawn.Code > int64(^uint32(0)) {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: special_object_index=%d spawn_index=%d code=%d", errDungeonExtendedActorCodeRange, objectIndex, spawnIndex, spawn.Code)
|
||
}
|
||
if _, found, err := monsterCatalog.Find(spawn.Code); err != nil {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("resolve special monster code=%d: %w", spawn.Code, err)
|
||
} else if !found {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: special monster code=%d", errDungeonMonsterDefinitionMiss, spawn.Code)
|
||
}
|
||
level, err := currentDungeonSpecialMonsterLevel(spawn, basis)
|
||
if err != nil {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("special object index %d spawn index %d: %w", objectIndex, spawnIndex, err)
|
||
}
|
||
actor, err := newRuntimeDungeonExtendedActor(
|
||
runtimeDungeonActorSpecialMonster,
|
||
plan.NextObjectKey,
|
||
spawn.Code,
|
||
currentDungeonStartMapActor{
|
||
TemplateOrder: uint16(objectIndex),
|
||
PacketIndex: uint32(spawnIndex),
|
||
Level: level,
|
||
Type: 0,
|
||
Flag0: 1,
|
||
Flag1: byte(objectIndex),
|
||
},
|
||
)
|
||
if err != nil {
|
||
return runtimeDungeonExtendedActorPlan{}, err
|
||
}
|
||
actor.SpecialObjectIndex = objectIndex
|
||
actor.SpecialSpawnIndex = spawnIndex
|
||
plan.Actors = append(plan.Actors, actor)
|
||
plan.NextObjectKey++
|
||
}
|
||
}
|
||
|
||
for actorIndex, actorValue := range scene.AICharacters {
|
||
if aiCharacterCatalog == nil {
|
||
return runtimeDungeonExtendedActorPlan{}, errDungeonAICharacterUnavailable
|
||
}
|
||
if actorValue.Code <= 0 || actorValue.Code > int64(^uint32(0)) {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: AI_character_index=%d code=%d", errDungeonExtendedActorCodeRange, actorIndex, actorValue.Code)
|
||
}
|
||
hostile, err := currentDungeonAICharacterHostile(actorValue.Faction)
|
||
if err != nil {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("AI character index %d code %d: %w", actorIndex, actorValue.Code, err)
|
||
}
|
||
definition, found, err := aiCharacterCatalog.Find(actorValue.Code)
|
||
if err != nil {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("resolve AI character index=%d code=%d: %w", actorIndex, actorValue.Code, err)
|
||
}
|
||
if !found {
|
||
if !hostile {
|
||
plan.Diagnostics = append(plan.Diagnostics, fmt.Sprintf(
|
||
"skip non-hostile AI character index %d code %d because its definition is missing",
|
||
actorIndex,
|
||
actorValue.Code,
|
||
))
|
||
continue
|
||
}
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: index=%d code=%d", errDungeonAICharacterDefinitionMiss, actorIndex, actorValue.Code)
|
||
}
|
||
actorType, err := currentDungeonAICharacterType(actorValue.AIType)
|
||
if err != nil {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("AI character index %d code %d: %w", actorIndex, actorValue.Code, err)
|
||
}
|
||
blocking, err := currentDungeonAICharacterBlocking(actorValue.Faction)
|
||
if err != nil {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("AI character index %d code %d: %w", actorIndex, actorValue.Code, err)
|
||
}
|
||
actor, err := newRuntimeDungeonExtendedActor(
|
||
runtimeDungeonActorAICharacter,
|
||
plan.NextObjectKey,
|
||
actorValue.Code,
|
||
currentDungeonStartMapActor{
|
||
PacketIndex: uint32(actorIndex),
|
||
Level: definition.Level,
|
||
Type: actorType,
|
||
Blocking: blocking,
|
||
},
|
||
)
|
||
if err != nil {
|
||
return runtimeDungeonExtendedActorPlan{}, err
|
||
}
|
||
actor.AICharacter = cloneRuntimeDungeonAICharacter(actorValue)
|
||
definitionCopy := cloneDungeonAICharacterDefinition(definition)
|
||
actor.AICharacterMetadata = &definitionCopy
|
||
if hostile {
|
||
reference := worldmap.HostileReference{Kind: worldmap.HostileAICharacter, Index: actorIndex}
|
||
actor.HostileReference = &reference
|
||
}
|
||
plan.Actors = append(plan.Actors, actor)
|
||
plan.NextObjectKey++
|
||
}
|
||
// 支援兵:把官方 APC(code 5405,阵营 "[character]")作为一条 start-map
|
||
// 扩展 actor 行注入。它走的是「AI 角色」通道而不是「远端玩家 actor」通道 —
|
||
// 后者只接受真实在线玩家,塞离线角色记录会让客户端原生处理器崩。
|
||
//
|
||
// 变参形式(companionEnabled ...bool)是有意的:现有全部调用点无需改动,
|
||
// 行为保持原样;只有副本运行时那条路径会显式传入冻结后的 enabled。
|
||
injectCompanion := currentDungeonCompanionP0StartMapEnabled()
|
||
if len(companionEnabled) != 0 {
|
||
injectCompanion = companionEnabled[0]
|
||
}
|
||
if err := appendCurrentDungeonCompanionP0StartMapActorForRun(
|
||
&plan,
|
||
aiCharacterCatalog,
|
||
injectCompanion,
|
||
); err != nil {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("plan companion P0 START_MAP actor: %w", err)
|
||
}
|
||
if len(scene.Monsters)+len(plan.Actors) > 0xff {
|
||
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: normal=%d extended=%d", errDungeonStartMapActorCount, len(scene.Monsters), len(plan.Actors))
|
||
}
|
||
return plan, nil
|
||
}
|
||
|
||
func newRuntimeDungeonExtendedActor(
|
||
kind runtimeDungeonExtendedActorKind,
|
||
objectKey uint32,
|
||
code int64,
|
||
packet currentDungeonStartMapActor,
|
||
) (runtimeDungeonExtendedActor, error) {
|
||
if objectKey == 0 || objectKey > uint32(^uint16(0)) {
|
||
return runtimeDungeonExtendedActor{}, fmt.Errorf("%w: object_key=%d", errDungeonExtendedActorObjectKeyRange, objectKey)
|
||
}
|
||
if code <= 0 || code > int64(^uint32(0)) {
|
||
return runtimeDungeonExtendedActor{}, fmt.Errorf("%w: code=%d", errDungeonExtendedActorCodeRange, code)
|
||
}
|
||
packet.ObjectKey = uint16(objectKey)
|
||
packet.Code = uint32(code)
|
||
return runtimeDungeonExtendedActor{
|
||
Kind: kind,
|
||
ObjectKey: objectKey,
|
||
Packet: packet,
|
||
State: runtimeDungeonMonsterPlanned,
|
||
}, nil
|
||
}
|
||
|
||
func currentDungeonSpecialMonsterLevel(spawn worldmap.SpecialObjectSpawn, basis worldmap.OptionalInt) (byte, error) {
|
||
level := spawn.Level
|
||
if level <= 0 {
|
||
if !basis.Set {
|
||
return 0, fmt.Errorf("%w: code=%d level=%d basis=unset", errDungeonSpecialSpawnLevel, spawn.Code, spawn.Level)
|
||
}
|
||
level = basis.Value
|
||
}
|
||
if level <= 0 || level > 0xff {
|
||
return 0, fmt.Errorf("%w: code=%d level=%d basis=%d basis_set=%t resolved=%d", errDungeonSpecialSpawnLevel, spawn.Code, spawn.Level, basis.Value, basis.Set, level)
|
||
}
|
||
return byte(level), nil
|
||
}
|
||
|
||
func currentDungeonAICharacterType(value string) (byte, error) {
|
||
switch normalizeDungeonPVFSymbol(value) {
|
||
case "normal":
|
||
return 5, nil
|
||
case "champion":
|
||
return 6, nil
|
||
case "super champion", "superchampion":
|
||
return 7, nil
|
||
case "boss":
|
||
return 8, nil
|
||
default:
|
||
return 0, fmt.Errorf("%w: type=%q", errDungeonAICharacterType, value)
|
||
}
|
||
}
|
||
|
||
func currentDungeonAICharacterBlocking(value string) (byte, error) {
|
||
if _, err := currentDungeonAICharacterHostile(value); err != nil {
|
||
return 0, err
|
||
}
|
||
// Current check_grid_clear and the old C# behavior both exclude every
|
||
// AICharacter/APC row (types 5..8) from the room-door blocking set. Faction
|
||
// still determines combat ownership, but never this wire flag.
|
||
return 0, nil
|
||
}
|
||
|
||
func currentDungeonAICharacterHostile(value string) (bool, error) {
|
||
switch normalizeDungeonPVFSymbol(value) {
|
||
case "monster":
|
||
return true, nil
|
||
case "character", "neutral":
|
||
return false, nil
|
||
default:
|
||
return false, fmt.Errorf("%w: faction=%q", errDungeonAICharacterFaction, value)
|
||
}
|
||
}
|
||
|
||
func normalizeDungeonPVFSymbol(value string) string {
|
||
return strings.ToLower(strings.Trim(value, "[] \t\r\n"))
|
||
}
|
||
|
||
func cloneRuntimeDungeonAICharacter(value worldmap.AICharacter) *worldmap.AICharacter {
|
||
value.Params = append([]int64(nil), value.Params...)
|
||
return &value
|
||
}
|