服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
493 lines
16 KiB
Go
493 lines
16 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
|
dnfhellparty "longheng.io/server/internal/modules/dnf/hellparty"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
)
|
|
|
|
const (
|
|
currentHellPartyMonsterInfoWireOpcode uint16 = 679
|
|
currentHellPartyModeA byte = 1
|
|
)
|
|
|
|
var (
|
|
errCurrentHellPartyUnavailable = errors.New("dnf hell party is unavailable")
|
|
errCurrentHellPartyActor = errors.New("dnf hell party actor is invalid")
|
|
errCurrentHellPartyMoveBlocked = errors.New("dnf hell party encounter is incomplete")
|
|
errCurrentHellPartyLocked = errors.New("dnf hell party is not unlocked for this region")
|
|
)
|
|
|
|
type currentDungeonHellPartyRewardPlan struct {
|
|
ActorObjectKey uint32
|
|
RollCount uint32
|
|
Suppressed bool
|
|
Selected []currentDungeonHellPartySelectedDrop
|
|
}
|
|
|
|
func (s *Service) authorizeCurrentDungeonHellParty(ctx context.Context, session *gameSession, table *worldmap.Table, dungeonID int64) error {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 || table == nil {
|
|
return errCurrentHellPartyLocked
|
|
}
|
|
area, found := table.FindAreaByDungeon(dungeonID)
|
|
if !found || !area.HellDungeon.Set || area.HellDungeon.Value != 1 || len(area.HellQuestIDs) == 0 {
|
|
return errCurrentHellPartyLocked
|
|
}
|
|
repositories, available := s.repositoryGroup()
|
|
if !available || repositories.HellPartyPermission == nil {
|
|
return errCurrentHellPartyLocked
|
|
}
|
|
// The same predicate backs the selector page's hell-party button
|
|
// (currentHellPartyButtonBlockedSlots), so the button projected to the
|
|
// client can never disagree with this gate.
|
|
granted, err := s.currentHellPartyAreaGranted(ctx, repositories, session.selectedCharacterID, area)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if granted {
|
|
return nil
|
|
}
|
|
characterID := fmt.Sprintf("%d", session.selectedCharacterID)
|
|
return fmt.Errorf("%w: area=%d character=%s quests=%v", errCurrentHellPartyLocked, area.ID, characterID, area.HellQuestIDs)
|
|
}
|
|
|
|
func currentHellQuestRecordUnlocksArea(record dnfrepo.QuestRecord, area worldmap.Area) bool {
|
|
_, cleared := dungeonQuestStateSets(record)
|
|
for _, questID := range area.HellQuestIDs {
|
|
if questID <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := cleared[questID]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type currentDungeonHellPartyActor struct {
|
|
Identity dnfhellparty.ActorIdentity
|
|
Code uint32
|
|
EntityType uint8
|
|
Level byte
|
|
}
|
|
|
|
type currentDungeonHellPartyOwner struct {
|
|
Room dnfhellparty.Room
|
|
Runtime *dnfhellparty.Runtime
|
|
Mode byte
|
|
Actors map[uint32]currentDungeonHellPartyActor
|
|
MonsterInfoRows map[uint32]uint32
|
|
MonsterInfoSent bool
|
|
RewardPlans []currentDungeonHellPartyRewardPlan
|
|
DeathResponses map[uint32][]byte
|
|
PillarObjectKey uint32
|
|
}
|
|
|
|
func (s *Service) currentHellPartyCatalog() (*dnfhellparty.Catalog, error) {
|
|
if s == nil {
|
|
return nil, errCurrentHellPartyUnavailable
|
|
}
|
|
s.hellPartyMu.Lock()
|
|
defer s.hellPartyMu.Unlock()
|
|
if s.hellPartyCatalog != nil || s.hellPartyCatalogLoadErr != nil {
|
|
return s.hellPartyCatalog, s.hellPartyCatalogLoadErr
|
|
}
|
|
s.initialEquipmentMu.Lock()
|
|
archive, err := s.initialEquipmentArchiveLocked()
|
|
s.initialEquipmentMu.Unlock()
|
|
if err == nil {
|
|
s.hellPartyCatalog, err = dnfhellparty.Load(archive)
|
|
}
|
|
s.hellPartyCatalogLoadErr = err
|
|
return s.hellPartyCatalog, s.hellPartyCatalogLoadErr
|
|
}
|
|
|
|
func (s *Service) preloadHellPartyCatalog(ctx context.Context) error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
started := time.Now()
|
|
catalog, err := s.currentHellPartyCatalog()
|
|
if err != nil {
|
|
return fmt.Errorf("preload current hell party catalog: %w", err)
|
|
}
|
|
rules, groups, actors := catalog.Snapshot()
|
|
s.logPacketEvent("dnf-hell-party-catalog-loaded",
|
|
"difficulty_rules", rules,
|
|
"groups", groups,
|
|
"actors", actors,
|
|
"elapsed_ms", time.Since(started).Milliseconds())
|
|
return nil
|
|
}
|
|
|
|
// currentDungeonHellPartyRequest accepts only the two fixed 21-byte request
|
|
// shapes documented by the current select-dungeon writer. Other nonzero mode
|
|
// combinations remain fail-closed.
|
|
func currentDungeonHellPartyRequest(request dungeoncmd.SelectDungeonRequest) bool {
|
|
if request.EntryOption != 0 || request.RuntimeState != 0 || request.Reserved != 0 ||
|
|
request.PartyState != 0 || len(request.OpaqueTail) != 0 {
|
|
return false
|
|
}
|
|
dedicated := request.SelectionMode == 1 && request.RuntimeToken == 0xffff && request.SpecialMode == 0
|
|
manual := request.SelectionMode == 0 && request.RuntimeToken == 0xffff && request.SpecialMode == 1
|
|
return dedicated || manual
|
|
}
|
|
|
|
// currentDungeonSelectedMazeEscapeHell identifies the third PVF-owned hell
|
|
// route: a [hell dungeon] whose selected [maze info] carries [escape hell].
|
|
// The marker is intentionally evaluated only for the selected maze. Scanning
|
|
// every maze would misclassify an ordinary route merely because a sibling
|
|
// route owns the escape-hell state machine.
|
|
func currentDungeonSelectedMazeEscapeHell(
|
|
request dungeoncmd.SelectDungeonRequest,
|
|
dungeon worldmap.Dungeon,
|
|
mazeIndex int,
|
|
) bool {
|
|
if !currentDungeonHellPartyRequest(request) || mazeIndex < 0 || mazeIndex >= len(dungeon.Mazes) {
|
|
return false
|
|
}
|
|
hellDungeon, found := dungeon.Metadata.IntegerValues["hell dungeon"]
|
|
if !found || hellDungeon != 1 {
|
|
return false
|
|
}
|
|
escapeHell := dungeon.Mazes[mazeIndex].EscapeHell
|
|
return escapeHell.Set && escapeHell.Value == 1
|
|
}
|
|
|
|
// currentDungeonExplicitHellPartyRequest separates the shared selector wire
|
|
// shape from the dungeon's PVF-owned runtime. Inferno, "particular hell", and
|
|
// selected escape-hell mazes use selection_mode=1/token=UINT16_MAX as their
|
|
// standalone entry request; they do not own a seal-door room and must not be
|
|
// rewritten into the optional in-dungeon hell-party encounter.
|
|
func currentDungeonExplicitHellPartyRequest(
|
|
request dungeoncmd.SelectDungeonRequest,
|
|
dungeon worldmap.Dungeon,
|
|
mazeIndex int,
|
|
) bool {
|
|
return currentDungeonHellPartyRequest(request) &&
|
|
!currentDungeonStandaloneRiftForSelection(request, dungeon, mazeIndex)
|
|
}
|
|
|
|
// currentDungeonStandaloneRift identifies PVF-owned hell routes whose boss is
|
|
// the reward owner. They share parts of the selector and drop tables with an
|
|
// optional in-dungeon hell party, but do not contain a seal-door encounter.
|
|
func currentDungeonStandaloneRift(dungeon worldmap.Dungeon) bool {
|
|
return dungeon.Metadata.InfernoDungeon ||
|
|
normalizeDungeonPVFSymbol(dungeon.Metadata.DungeonType) == "particular hell"
|
|
}
|
|
|
|
func currentDungeonStandaloneRiftForSelection(
|
|
request dungeoncmd.SelectDungeonRequest,
|
|
dungeon worldmap.Dungeon,
|
|
mazeIndex int,
|
|
) bool {
|
|
return currentDungeonStandaloneRift(dungeon) ||
|
|
currentDungeonSelectedMazeEscapeHell(request, dungeon, mazeIndex)
|
|
}
|
|
|
|
func currentDungeonStandaloneRiftRuntime(runtime *runtimeDungeonState) bool {
|
|
return runtime != nil && currentDungeonStandaloneRiftForSelection(
|
|
runtime.Request,
|
|
runtime.Dungeon,
|
|
runtime.MazeIndex,
|
|
)
|
|
}
|
|
|
|
// currentDungeonTimeRiftRuntime is narrower than the boss-reward predicate:
|
|
// it includes Time Rift and selected escape-hell mazes, but deliberately
|
|
// excludes Magic Rift's [particular hell] route. Direction-specific features
|
|
// such as epic-wish snapshots must not leak onto dungeon 316 merely because
|
|
// all three routes share the standalone boss reward owner.
|
|
func currentDungeonTimeRiftRuntime(runtime *runtimeDungeonState) bool {
|
|
if runtime == nil || normalizeDungeonPVFSymbol(runtime.Dungeon.Metadata.DungeonType) == "particular hell" {
|
|
return false
|
|
}
|
|
return runtime.Dungeon.Metadata.InfernoDungeon || currentDungeonSelectedMazeEscapeHell(
|
|
runtime.Request,
|
|
runtime.Dungeon,
|
|
runtime.MazeIndex,
|
|
)
|
|
}
|
|
|
|
func (s *Service) currentDungeonStandaloneRiftRewardRollCount() (uint32, error) {
|
|
catalog, err := s.currentHellPartyCatalog()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
rule, found := catalog.Difficulty("A")
|
|
if !found || rule.RewardRollCount == 0 {
|
|
return 0, fmt.Errorf("%w: standalone rift difficulty A is unavailable", errCurrentHellPartyUnavailable)
|
|
}
|
|
return rule.RewardRollCount, nil
|
|
}
|
|
|
|
func (s *Service) prepareCurrentDungeonHellParty(
|
|
table *worldmap.Table,
|
|
topology *worldmap.DungeonTopology,
|
|
dungeon worldmap.Dungeon,
|
|
mazeIndex int,
|
|
seed uint32,
|
|
) (*currentDungeonHellPartyOwner, error) {
|
|
catalog, err := s.currentHellPartyCatalog()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
room, err := dnfhellparty.ResolveRoom(table, topology, dungeon.ID, mazeIndex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plan, err := dnfhellparty.BuildPlan(catalog, dnfhellparty.PlanOptions{
|
|
GroupKey: "A",
|
|
Seed: uint64(seed),
|
|
Entries: room.Entries,
|
|
SuppressGroupReward: func(code uint32, entityType uint8) bool {
|
|
return s.currentDungeonHellMonsterScript(code, entityType)
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
runtime, err := dnfhellparty.NewRuntime(plan)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := dnfhellparty.ApplyRoom(topology, room); err != nil {
|
|
return nil, err
|
|
}
|
|
return ¤tDungeonHellPartyOwner{
|
|
Room: room,
|
|
Runtime: runtime,
|
|
Mode: currentHellPartyModeA,
|
|
Actors: make(map[uint32]currentDungeonHellPartyActor),
|
|
MonsterInfoRows: make(map[uint32]uint32),
|
|
DeathResponses: make(map[uint32][]byte),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) currentDungeonHellMonsterScript(code uint32, entityType uint8) bool {
|
|
if code == 0 {
|
|
return false
|
|
}
|
|
if entityType == 0 {
|
|
catalog, err := s.dungeonMonsterCatalog()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
value, err := catalog.HellMonster(int64(code))
|
|
return err == nil && value
|
|
}
|
|
catalog, err := s.dungeonAICharacterCatalog()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
definition, found, err := catalog.Find(int64(code))
|
|
if err != nil || !found {
|
|
return false
|
|
}
|
|
for _, section := range definition.Sections {
|
|
if normalizeDungeonPVFSymbol(section.Name) != "hell monster" {
|
|
continue
|
|
}
|
|
for _, token := range section.Tokens {
|
|
if token.Int == 1 {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Service) appendCurrentDungeonHellPartyActors(
|
|
plan runtimeDungeonExtendedActorPlan,
|
|
owner *currentDungeonHellPartyOwner,
|
|
scene worldmap.DungeonRoomScene,
|
|
basis worldmap.OptionalInt,
|
|
monsterCatalog *pvfDungeonMonsterCatalog,
|
|
aiCatalog *pvfDungeonAICharacterCatalog,
|
|
) (runtimeDungeonExtendedActorPlan, error) {
|
|
if owner == nil || owner.Runtime == nil || scene.Coordinate != owner.Room.Coordinate || scene.Map.Map.ID != owner.Room.HellMap.ID {
|
|
return plan, nil
|
|
}
|
|
packetIndex := len(scene.Monsters) + len(plan.Actors)
|
|
for _, wave := range owner.Runtime.Plan().Waves {
|
|
if wave.Order == 0 || wave.Order > uint32(^uint16(0)) {
|
|
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: wave order=%d", errCurrentHellPartyActor, wave.Order)
|
|
}
|
|
for _, planned := range wave.Actors {
|
|
level := byte(0)
|
|
actorType := byte(0)
|
|
switch planned.EntityType {
|
|
case 0:
|
|
if monsterCatalog == nil {
|
|
return runtimeDungeonExtendedActorPlan{}, errDungeonMonsterCatalogUnavailable
|
|
}
|
|
if _, found, err := monsterCatalog.Find(int64(planned.Code)); err != nil || !found {
|
|
if err != nil {
|
|
return runtimeDungeonExtendedActorPlan{}, err
|
|
}
|
|
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: monster=%d", errDungeonMonsterDefinitionMiss, planned.Code)
|
|
}
|
|
if !basis.Set || basis.Value <= 0 || basis.Value > 0xff {
|
|
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: basis_level=%d set=%t", errCurrentHellPartyActor, basis.Value, basis.Set)
|
|
}
|
|
level = byte(basis.Value)
|
|
case 1:
|
|
if aiCatalog == nil {
|
|
var err error
|
|
aiCatalog, err = s.dungeonAICharacterCatalog()
|
|
if err != nil {
|
|
return runtimeDungeonExtendedActorPlan{}, err
|
|
}
|
|
}
|
|
definition, found, err := aiCatalog.Find(int64(planned.Code))
|
|
if err != nil {
|
|
return runtimeDungeonExtendedActorPlan{}, err
|
|
}
|
|
if !found {
|
|
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: AI=%d", errDungeonAICharacterDefinitionMiss, planned.Code)
|
|
}
|
|
level = definition.Level
|
|
actorType = 5
|
|
default:
|
|
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: entity_type=%d", errCurrentHellPartyActor, planned.EntityType)
|
|
}
|
|
actor, err := newRuntimeDungeonExtendedActor(
|
|
runtimeDungeonActorHellParty,
|
|
plan.NextObjectKey,
|
|
int64(planned.Code),
|
|
currentDungeonStartMapActor{
|
|
TemplateOrder: uint16(wave.Order),
|
|
PacketIndex: uint32(packetIndex),
|
|
Level: level,
|
|
Type: actorType,
|
|
Flag0: 1,
|
|
Flag1: 0xff,
|
|
Blocking: 1,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return runtimeDungeonExtendedActorPlan{}, err
|
|
}
|
|
if _, exists := owner.Actors[actor.ObjectKey]; exists {
|
|
return runtimeDungeonExtendedActorPlan{}, fmt.Errorf("%w: duplicate object_key=%d", errCurrentHellPartyActor, actor.ObjectKey)
|
|
}
|
|
owner.Actors[actor.ObjectKey] = currentDungeonHellPartyActor{
|
|
Identity: planned.Identity,
|
|
Code: planned.Code,
|
|
EntityType: planned.EntityType,
|
|
Level: level,
|
|
}
|
|
if _, exists := owner.MonsterInfoRows[planned.Code]; !exists {
|
|
owner.MonsterInfoRows[planned.Code] = uint32(level)
|
|
}
|
|
plan.Actors = append(plan.Actors, actor)
|
|
plan.NextObjectKey++
|
|
packetIndex++
|
|
}
|
|
}
|
|
for _, actor := range plan.Actors {
|
|
if actor.Kind == runtimeDungeonActorSpecialObject && uint32(actor.Packet.Code) == uint32(owner.Room.Pillar.ObjectID) {
|
|
owner.PillarObjectKey = actor.ObjectKey
|
|
break
|
|
}
|
|
}
|
|
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 buildCurrentHellPartyMonsterInfoBody(rows map[uint32]uint32) []byte {
|
|
codes := make([]uint32, 0, len(rows))
|
|
for code := range rows {
|
|
if code != 0 {
|
|
codes = append(codes, code)
|
|
}
|
|
}
|
|
sort.Slice(codes, func(i, j int) bool { return codes[i] < codes[j] })
|
|
var writer packetWriter
|
|
writer.writeUint32(uint32(len(codes)))
|
|
for _, code := range codes {
|
|
level := rows[code]
|
|
if level == 0 {
|
|
level = 1
|
|
}
|
|
writer.writeUint32(code)
|
|
writer.writeUint32(level)
|
|
}
|
|
return writer.bytes()
|
|
}
|
|
|
|
func (s *Service) sendCurrentHellPartyMonsterInfo(session *gameSession, runtime *runtimeDungeonState, scene worldmap.DungeonRoomScene, source string) error {
|
|
if runtime == nil || runtime.HellParty == nil || runtime.HellParty.MonsterInfoSent ||
|
|
scene.Coordinate != runtime.HellParty.Room.Coordinate || scene.Map.Map.ID != runtime.HellParty.Room.HellMap.ID {
|
|
return nil
|
|
}
|
|
body := buildCurrentHellPartyMonsterInfoBody(runtime.HellParty.MonsterInfoRows)
|
|
if len(body) <= 4 {
|
|
return nil
|
|
}
|
|
if err := s.sendGameUpperRawClass(session, currentHellPartyMonsterInfoWireOpcode, body, 0); err != nil {
|
|
return err
|
|
}
|
|
runtime.HellParty.MonsterInfoSent = true
|
|
s.logGameEvent(session, "game-dungeon-hell-party-monster-info-sent",
|
|
"source", source,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"msg_id", currentHellPartyMonsterInfoWireOpcode,
|
|
"classification", 0,
|
|
"body_len", len(body),
|
|
"row_count", len(runtime.HellParty.MonsterInfoRows))
|
|
return nil
|
|
}
|
|
|
|
// sendCurrentHellPartyMonsterInfoAfterFinishLoading publishes the typed Hell
|
|
// Party monster table only after the encounter room's op37/op30 boundary.
|
|
// Current-EXE sub_1D39E60 proves op679 is only a count followed by code/level
|
|
// rows; a zero-row body has no reset semantics and must not be invented for
|
|
// ordinary, story, altar, or non-encounter rooms.
|
|
func (s *Service) sendCurrentHellPartyMonsterInfoAfterFinishLoading(session *gameSession, source string) error {
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
defer session.dungeon.mu.Unlock()
|
|
runtime := session.dungeon.runtime
|
|
if runtime == nil || runtime.Session == nil {
|
|
return nil
|
|
}
|
|
scene, ok := runtime.Session.Scene()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if runtime.HellParty != nil {
|
|
if scene.Coordinate == runtime.HellParty.Room.Coordinate &&
|
|
scene.Map.Map.ID == runtime.HellParty.Room.HellMap.ID {
|
|
return s.sendCurrentHellPartyMonsterInfo(session, runtime, scene, source)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func currentDungeonHellPartyMoveBlocked(runtime *runtimeDungeonState, source worldmap.DungeonRoomScene) bool {
|
|
return runtime != nil && runtime.HellParty != nil && runtime.HellParty.Runtime != nil &&
|
|
source.Coordinate == runtime.HellParty.Room.Coordinate &&
|
|
source.Map.Map.ID == runtime.HellParty.Room.HellMap.ID &&
|
|
runtime.HellParty.Runtime.Phase() == dnfhellparty.PhaseStarted
|
|
}
|