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

1340 lines
59 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
cryptorand "crypto/rand"
"encoding/binary"
"errors"
"fmt"
"math/big"
"strconv"
"strings"
"sync/atomic"
"time"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
var dungeonRuntimePayloadReleaseCount atomic.Uint64
var (
errDungeonWorldMapUnavailable = errors.New("dnf dungeon worldmap runtime is unavailable")
errDungeonMonsterCatalogUnavailable = errors.New("dnf dungeon monster catalog is unavailable")
errDungeonCharacterRequired = errors.New("dnf dungeon selected character is required")
errDungeonCharacterNotFound = errors.New("dnf dungeon selected character was not found")
errDungeonAccountMismatch = errors.New("dnf dungeon selected character account mismatch")
errDungeonQuestRepositoryUnavailable = errors.New("dnf dungeon quest repository is unavailable")
errDungeonInfernoLocked = errors.New("dnf inferno dungeon is not unlocked by its PVF quest")
errDungeonDifficultyInvalid = errors.New("dnf dungeon difficulty is invalid")
errDungeonLevelTooLow = dnfdungeon.ErrEntryLevelTooLow
errDungeonFatigueUnknown = dnfdungeon.ErrEntryFatigueUnknown
errDungeonFatigueExhausted = dnfdungeon.ErrEntryFatigueExhausted
errDungeonPartyLimit = dnfdungeon.ErrEntryPartyLimit
errDungeonAlreadyActive = errors.New("dnf dungeon session is already active")
errDungeonRuntimeRetired = errors.New("dnf dungeon runtime generation is already retired")
errDungeonChoiceRequired = errors.New("dnf dungeon explicit PVF choice is required")
errDungeonMonsterOwnerMismatch = errors.New("dnf dungeon monster death related actor object key mismatch")
)
type currentDungeonRuntimeLifecycle byte
const (
currentDungeonRuntimePrepared currentDungeonRuntimeLifecycle = iota
currentDungeonRuntimeActive
currentDungeonRuntimeRetired
)
type runtimeDungeonState struct {
Request dungeoncmd.SelectDungeonRequest
// ProtocolMode is the validated op16 mode mirrored into op28.
ProtocolMode currentDungeonProtocolMode
Dungeon worldmap.Dungeon
// clientDungeonID is the selector/resource identity projected to the current
// EXE. In-dungeon op28/op29 packets must continue to use Dungeon.ID.
// Single Anton owns dedicated PVF dungeons 5314-5322 on the server while
// the current client keeps its native Raid objective table under 210-224.
clientDungeonID int64
MazeIndex int
Character dnfrepo.CharacterRecord
Session *worldmap.DungeonSession
Room *runtimeDungeonRoom
DropOwner *runtimeDungeonDropOwner
HellParty *currentDungeonHellPartyOwner
// Companion 是本次副本运行的支援兵运行态(从 Server-90 移植)。
// 配置在进入副本时冻结,所以同一副本内全程用这一份。
Companion *currentDungeonCompanionRun
// companionP0OwnerCaptureBudget / companionP0OwnerObjectKeys 跟踪
// 「已捕获的 APC owner 数」与「已捕获的 owner 集合」,用于 limit 与去重。
companionP0OwnerCaptureBudget uint16
companionP0OwnerObjectKeys map[uint16]struct{}
NextObjectKey uint32
BossCoordinate worldmap.RoomCoordinate
BossSet bool
// entryCharacterLevel 是**进图那一刻**的角色等级快照。"推荐地下城通关"
// 判定必须用它而不是 runtime.Character.Level:本局的小怪与翻牌经验会把等级
// 推过推荐区间上限,用实时等级会让"推荐副本"在打完最后一张图时判成不推荐,
// 于是月度签到/周签到/婚姻胶囊这些挂在"推荐副本通关"上的活动全部漏记。
// 0 表示没有快照,调用方回落到实时等级。
entryCharacterLevel int
LayeredMapIndex int
LayeredMapActive bool
LayerChains map[worldmap.RoomCoordinate]*runtimeDungeonLayerChain
StoryStages []worldmap.DungeonStoryStage
StoryStageIndex int
Seed uint32
RandomizedObjectSelections [][]currentDungeonRandomizedObjectSelection
RandomizedObjectGroups [][]currentDungeonStartMapRidableEntry
RandomizedObjectsPlanned bool
RidableObjects *currentDungeonRidableObjectOwner
partyDungeonOwner *runtimePartyDungeonOwner
partyMemberIndex byte
partyMemberIndexed bool
Rooms map[runtimeDungeonRoomKey]*runtimeDungeonRoomVisit
startedAt time.Time
// bossDieCheckObserved* records that the client already spent its single
// op117 in this room, whatever the server did with it. The tutorial final
// room defers to op117 when a boss-ranked monster exists; without this
// marker a client that sends op117 before the room's last blocker dies
// leaves the run waiting for a packet that will never come again.
bossDieCheckObserved bool
bossDieCheckObservedRoom worldmap.RoomCoordinate
bossDieCheckObservedMapID int64
bossDieCheckPending bool
bossDieCheckPendingRequest dungeoncmd.BossDieCheckRequest
bossDieCheckAccepted bool
bossDieCheckRelatedActorObjectKey uint16
bossDieCheckTargetObjectKey uint16
bossDieCheckResponseSent bool
bossEntranceConditionComplete bool
bossEntranceGateNotificationSent bool
// actionObjects is the run's dungeon-symbol runtime: the passive-object
// scripts, their trigger state and the symbols they share. Built lazily on
// the first room that places a driving object.
// (上游 b402882 的物件脚本子系统;本树其余字段未动。)
actionObjects *runtimeDungeonActionObjectState
ordinaryFinalRoomClearAccepted bool
tutorialFinalRoomClearPending bool
tutorialFinalRoomClearAccepted bool
// Clear-card gold is driven by how much of the dungeon was actually
// killed, so the run tallies its own deaths as they are accounted. The
// weighting (champion x2, boss x4) belongs to the gold formula, not here.
clearKillNormal int64
clearKillChampion int64
clearKillBoss int64
// colorlessSpent 本次副本运行里"技能消耗的无色小晶块"数量(op18 DELETE_ITEM 累计)。
// 只统计技能消耗:晶体契约每 30 秒自动扣的那 1 个不走 op18,不会计入。
// 用途:判断"技能放了很多却一个无色都没掉"这类外挂特征(见 dungeon_colorless_spend.go)。
colorlessSpent int64
settlementPhase currentDungeonSettlementPhase
settlementEntrySent bool
settlementEntryAt time.Time
settlementPlayResultReceived bool
settlementPlayResultBody []byte
settlementPlayResultDynamicRows uint8
settlementPlayResultOptionalField bool
settlementStatisticReceived bool
settlementStatisticBody []byte
settlementResultPlan *currentDungeonSettlementPacketPlan
settlementResultNoticeSent bool
settlementCharacterStateSent bool
settlementClearRewardSent bool
// settlementTowerRewardSent marks the tower clear window (op348/op1263) as
// published. A tower has no ordinary card reward, so its exit readiness is
// gated on this flag instead of settlementClearRewardSent.
settlementTowerRewardSent bool
settlementTowerInventorySent bool
settlementTowerCountsSent bool
towerRewardUpdates []currentItemListEntry
settlementLuckyStarResolved bool
settlementLuckyStarRefreshPending bool
// settlementWeddingCapsuleSent 保证 1016 号婚姻成长胶囊进度行每次结算只发一次
// (与 TowerReward 系列同样的"一次性窗口"语义)。
settlementWeddingCapsuleSent bool
settlementLuckyStarBefore uint32
settlementLuckyStarApplied uint32
settlementLuckyStarAfter uint32
settlementLuckyStarLimit uint32
settlementLuckyStarNoticeSent bool
settlementLuckyStarStateSent bool
settlementCardScrollStateSent bool
settlementCardRightStateSent bool
settlementCardLayoutSent bool
settlementCardSelectionKnown bool
settlementCardSelected byte
settlementCardSelectionSent bool
settlementCardRewardCommitted bool
settlementCardSideSelectionKnown [dungeonCardSideCount]bool
settlementCardSideMember [dungeonCardSideCount]byte
settlementCardSideSelectionSent [dungeonCardSideCount]bool
settlementCardSideRewardCommitted [dungeonCardSideCount]bool
settlementCardSidePosition [dungeonCardSideCount]byte
settlementCardSidePositionKnown [dungeonCardSideCount]bool
partyCardSelectionSeen [dungeonCardSideCount][dungeonCardSlotsPerSide]bool
partyCardSelectionPending [][]byte
settlementCardAutoFlipGeneration uint64
settlementCardAutoFlipTimerName string
settlementCardExitAckSent bool
settlementCardRewardState *dungeonCardState
// settlementPaidCardCost is the gold-card commission the current EXE draws
// under 黄金卡牌 N. The 86JP reference fills the same op35 field from its
// own [gold card cost table] lookup (ClearRewardGenerator.GetPaidCardCost ->
// DungeonNotificationBuilder "PAID-CARD COMMISSION"), so a zero here is what
// makes every gold card render as 0 金币.
settlementPaidCardCost uint32
settlementMonsterExperienceTotal uint32
settlementMonsterGrowthContractBonus uint32
settlementMonsterEquipmentBonus uint32
settlementMonsterAttendanceMarbleBonus uint32
// settlementMonsterWelcomeBonus 累计本局怪物的"回归冒险家欢迎经验",
// settlementClearWelcomeBonus 是通关经验的同一项。后者用指针是为了区分
// "还没算过"(nil,需要现算并在重试时钉住)与"算过且为 0"(合法值)。
settlementMonsterWelcomeBonus uint32
settlementClearWelcomeBonus *uint32
// settlementClearExperiencePresentation 冻结"这一次通关的经验分类"(Base 四格 +
// Bonus 全表)。出包计划可能重试,而重试之间**资格会变**(例如回归/幸运角色的
// 有效期到期、队伍解散),重算会给出与已提交回执不同的分类,面板数字就和真实
// 入账对不上了。所以第一次算完就钉在这里,后续重试直接复用。
settlementClearExperiencePresentation *currentDungeonClearExperiencePresentation
// settlementMonsterLuckyCharacterBonus 累计本局"幸运角色"的怪物经验加成。
settlementMonsterLuckyCharacterBonus uint32
// 签到弹珠(onlineevent marble)的增益位掩码。进图时一次性从角色状态读取并
// 缓存在这里,避免每只怪都去查角色表;bit0..3 对应 buff 29..32。
attendanceMarbleBuffs byte
attendanceMarbleBuffsLoaded bool
attendanceMarbleReminderSent bool
settlementMonsterFatigueBatteryBonus uint32
settlementChampionExperience uint32
settlementSuperChampionExperience uint32
settlementBossExperience uint32
// The current result reader resolves the dynamic op35 rows by the
// monster object key and uses the value as that kill's awarded EXP.
// Keep this presentation receipt separate from the classified totals.
settlementMonsterExperienceByObjectKey map[uint32]uint32
// Main-quest result-window values are presentation-only. Freeze them before
// the settlement commit so a packet-plan retry cannot recalculate against
// the already-advanced character level.
settlementStoryQuestBaseResolved bool
settlementStoryQuestBase uint32
settlementStoryQuestTotal uint32
tutorialCompletionPersisted bool
clearMapCompletionPhaseAPersisted bool
clearMapCompletionKey string
clearMapCompletionAt time.Time
clearMapCompletionQuestIDs []int64
clearMapCompletionCompletedQuestIDs []int64
clearMapCompletionPendingQuestIDs []int64
clearMapCompletionActiveSnapshotSent bool
clearMapCompletionNotificationClosed bool
tutorialCompletedReentry bool
tutorialCompletedReentryExitSent bool
tutorialFinalFlagAckSent bool
tutorialInitialUserStateSent bool
townReturnPending bool
townReturnOp24Sent bool
townReturnPartyDetached bool
// townReturnKeepParty marks a WHOLE-party exit (a raid objective closing a
// gate, or a squad leader leading the squad home from the 战况表). The
// interrupted-run route detaches a member who leaves alone; a whole-party
// exit must keep the squad together.
townReturnKeepParty bool
// townReturnKeepsSquadLabel marks a return that must NOT clear the member's
// Fiend War squad label even though it is not a whole-party exit.
//
// It exists for the returns the SERVER drives while the squad is meant to stay
// together: the failure tail that puts everybody back in the waiting room, and
// the auto-return a cleared area performs. Neither is 「队内成员单独自己回城」
// (the operator's wording for the gesture that clears a 编制), and the failure
// tail must keep every squad label by ruling
// 「攻坚失败后 … 攻坚队不应该被解散」. Only Fiend War sets it, and
// currentDungeonTownReturnClearsRaidSquad double-gates it on the channel, so
// Anton and Luke are untouched. See raid_squad_sync.go for the rule.
townReturnKeepsSquadLabel bool
// townReturnWeaknessExemption remembers which rule spared (or did not
// spare) the forced exit so the premium refresh a spent 魔王契约 needs can
// be published after the town player state exists, not before it.
townReturnWeaknessExemption currentWeaknessExemption
// An interrupted return arms the current dungeon actor's native item-use
// wait byte before C2S op42/op132 is emitted. Clear that exact actor before
// any town mode0/mode1 packet can replace the client's active object.
townReturnDungeonGateResetSent bool
consumableUseGateResetSent bool
townReturnRequestMsgID uint16
townReturnSource string
townReturnOrigin currentTownPositionSnapshot
townReturnTransition currentDungeonTownTransition
lifecycleToken uint64
lifecycleState currentDungeonRuntimeLifecycle
lifecycleEndReason string
raidDungeon runtimeRaidDungeonBinding
// raidNativeSettlementFinished is committed only after the current EXE has
// received the final single-Raid state=6 notification. The native Raid
// movie/two-card flow deliberately suppresses ordinary dungeon op31/op35,
// so its F12/op72 exit must not depend on settlementClearRewardSent.
raidNativeSettlementFinished bool
deathReturnWaiting bool
deathReturnGeneration uint64
deathReturnTimerName string
deathReturnDueAt time.Time
deathReturnTransition currentDungeonTownTransition
deathReviveCommitted bool
deathReviveGeneration uint64
deathReviveTargetObjectKey uint16
deathReviveConsume currentDungeonReviveCoinConsumeResult
// fiendWarReviveCount 是超时空一次副本尝试里已用掉的复活币数。它按
// (副本, 座位) 共享 —— 一个小队一起打一个区域,额度是整队共用的 ——
// 所以小队成员各自的副本实例读的是同一个值 (见 dungeon_death_return.go)。
fiendWarReviveCount int64
forgottenLandTimerActive bool
forgottenLandTimerGeneration uint64
forgottenLandTimerName string
forgottenLandDeadline time.Time
forgottenLandTownTransition currentDungeonTownTransition
crystalContractTimerActive bool
crystalContractTimerGeneration uint64
crystalContractTimerName string
crystalContractTimerDueAt time.Time
altar *currentDungeonAltarRuntime
actorPositionX uint16
actorPositionY uint16
actorPositionValid bool
settlementDungeonPermissionSent bool
tournamentClearRewardSent bool
tournamentRewardExperience uint32
tournamentRewardGold uint32
tournamentRewardAutoSelectGeneration uint64
tournamentRewardAutoSelectTimerName string
suspiciousVillageElevator currentDungeonElevatorState
tournament *worldmap.TournamentRuntime
payloadReleased bool
// settlementFatigueExhaustedSlots is the party's "who has run out of fatigue"
// snapshot, indexed by party slot.
//
// It is taken by the op70 entry point BEFORE it takes session.dungeon.mu,
// because collecting it needs every member's own session lock and this codebase
// only ever holds one session lock at a time. currentDungeonCardLayoutSlots
// turns a true slot into the 0 that the client reads as 「玩家或某队员的疲劳值为0,
// 无法再挑战」(dstr 475) and as the disabled 再次挑战 button. Only runs that
// actually charge fatigue fill it in; see currentDungeonRetryGatesOnFatigue.
settlementFatigueExhaustedSlots [dungeonCardSlotsPerSide]bool
// --- 公会贡献度 (guild contribution) state, from the upstream guild branch. ---
//
// guildContributionEntry is the guild-membership snapshot taken at dungeon
// ENTRY. The contribution rules pay out per clear, but they must be judged
// against the guild as it was when the run started: a member who joins or
// leaves mid-run must not change who the clear credits, and the settlement
// re-checks this snapshot against the live registry (Registry.
// ContributionEligible) before paying anything.
//
// guildContributionUpdates carries the item rows the delivered 贡献币 changed,
// so the settlement can replay them as one op14 batch instead of re-reading
// the inventory.
//
// Deliberately NOT modelled here: upstream's guildDungeonID / guildDungeonCycle
// / guildDungeonRewardUpdates / guildDungeonStateSent / guildDungeonFatiguePaid,
// and the guild war's key/instance/state trio. This tree identifies a 公会地下城
// by looking its dungeon id back up in the catalog rather than by remembering a
// flag (see wedding_ceremony.go currentWeddingGuildDungeon, which records that
// decision and is what the wedding feature already depends on), and our 公会地下城
// is the weekly gauge design in modules/dnf/guild/dungeon.go, not upstream's
// monthly DungeonCycle. guildWarID is kept only as the "this run is a 公会战 run"
// gate that the contribution rules read; the rest of the war state lands with
// the 公会战 step.
guildContributionEntry dnfguild.ContributionEntry
guildContributionUpdates []currentItemListEntry
guildContributionSent bool
guildWarID uint32
// guildWarKey is the 公会战 battle key this run belongs to, captured at entry and
// re-checked by the war timer so a stale timer cannot clear a newer battle.
guildWarKey string
}
// releaseDungeonRuntimePayload drops data that is only needed while a run is
// active. The session and lifecycle fields remain available for existing
// acknowledgement and retry paths after the runtime owner is detached.
func releaseDungeonRuntimePayload(runtime *runtimeDungeonState) {
if runtime == nil || runtime.payloadReleased {
return
}
runtime.payloadReleased = true
runtime.Rooms = nil
runtime.LayerChains = nil
runtime.StoryStages = nil
runtime.RandomizedObjectSelections = nil
runtime.RandomizedObjectGroups = nil
runtime.RidableObjects = nil
runtime.DropOwner = nil
runtime.Room = nil
runtime.HellParty = nil
runtime.partyDungeonOwner = nil
runtime.altar = nil
runtime.tournament = nil
runtime.clearKillNormal = 0
runtime.clearKillChampion = 0
runtime.clearKillBoss = 0
runtime.settlementPlayResultBody = nil
runtime.settlementStatisticBody = nil
runtime.settlementResultPlan = nil
runtime.partyCardSelectionPending = nil
runtime.settlementCardRewardState = nil
runtime.settlementMonsterExperienceByObjectKey = nil
runtime.clearMapCompletionQuestIDs = nil
runtime.clearMapCompletionCompletedQuestIDs = nil
runtime.clearMapCompletionPendingQuestIDs = nil
dungeonRuntimePayloadReleaseCount.Add(1)
}
// retireCurrentDungeonRuntimeLocked releases every session-owned part of one
// dungeon attempt after the caller has committed its lifecycle result. The
// caller must hold session.dungeon.mu and must publish any Raid/party terminal
// event before calling this helper.
func (s *Service) retireCurrentDungeonRuntimeLocked(
session *gameSession,
runtime *runtimeDungeonState,
reason string,
) {
if session == nil {
return
}
if runtime != nil {
// Match S4A21's compare-and-detach lifecycle: make the generation
// unreachable from the live session before cancelling its owned work.
// A callback already waiting on dungeon.mu will therefore observe a
// different owner even if cancelling its queue item races with dequeue.
if session.dungeon.runtime == runtime {
session.dungeon.runtime = nil
session.dungeon.runToken = 0
}
if runtime.lifecycleState != currentDungeonRuntimeRetired {
runtime.lifecycleState = currentDungeonRuntimeRetired
runtime.lifecycleEndReason = strings.TrimSpace(reason)
// A retiring member must not hold the rest of the party at the
// op35 reward barrier: the departed slot is dropped from the
// expected set so the freeze can still complete for everyone else.
s.detachRuntimePartyDungeonCardRewardsLocked(session, runtime)
// 这次副本到此结束(返回城镇/死亡回城/被下一次选本顶掉……),
// 记一条"离开副本"。这个分支对每个 runtime 只会进入一次,
// 所以不需要额外的去重标志。
s.recordDungeonExit(session, runtime, reason)
}
s.cancelCurrentDungeonDeathReturnLocked(session, runtime, reason)
s.cancelCurrentForgottenLandTimerLocked(session, runtime, reason)
s.cancelCurrentCrystalContractTimerLocked(session, runtime, reason)
s.cancelCurrentDungeonAltarTimersLocked(runtime)
s.cancelCurrentDungeonCardAutoFlipLocked(session, runtime, reason)
s.cancelCurrentTournamentRewardAutoSelectLocked(session, runtime, reason)
releaseDungeonRuntimePayload(runtime)
} else if session.dungeon.runtime == nil {
// Heal a detached token left by an interrupted/legacy boundary even
// when no worldmap runtime remains to identify it.
session.dungeon.runToken = 0
}
// These two owners are session-local dungeon helpers rather than part of the
// worldmap runtime. They must not survive a force return, a completed
// return, or a later fresh dungeon generation.
session.dungeon.deathTower = nil
session.dungeon.debugLightningPending = false
}
func dungeonRuntimeOwnsCharacter(runtime *runtimeDungeonState, characterID uint16) bool {
return runtime != nil &&
dnfdungeon.RuntimeOwnsCharacter(runtime.Character.CharacterID, characterID)
}
func (s *Service) preloadDungeonWorldMap(ctx context.Context) error {
if s == nil {
return errDungeonWorldMapUnavailable
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return fmt.Errorf("preload dungeon worldmap pvf: %w", err)
}
table, err := worldmap.LoadSource(ctx, archive, worldmap.Options{CompactRuntime: true})
if err != nil {
return fmt.Errorf("preload dungeon worldmap table: %w", err)
}
resolver, err := worldmap.NewResolver(table)
if err != nil {
return fmt.Errorf("preload dungeon worldmap resolver: %w", err)
}
monsterTable, err := newPVFDungeonMonsterCatalog(archive)
if err != nil {
return fmt.Errorf("preload dungeon monster table: %w", err)
}
if _, err := s.currentDungeonAdmissionReceiptNonce(); err != nil {
return fmt.Errorf("preload dungeon admission receipt owner: %w", err)
}
admissionCatalog, err := dnfdungeonadmission.LoadCatalog(archive)
if err != nil {
return fmt.Errorf("preload dungeon admission counter catalog: %w", err)
}
// Death packets are sent only after drop planning completes. Resolve all
// lazy drop assets before listeners open so the first kill cannot block on
// PVF parsing and item-pool construction.
if _, err := currentDungeonMonsterDropRules(monsterTable); err != nil {
return fmt.Errorf("preload dungeon drop rules: %w", err)
}
if _, err := currentDungeonGoldReferences(monsterTable); err != nil {
return fmt.Errorf("preload dungeon gold references: %w", err)
}
if _, err := currentDungeonDimensionDrops(monsterTable); err != nil {
return fmt.Errorf("preload dungeon dimension drops: %w", err)
}
if _, err := currentDungeonSpecialDrops(monsterTable); err != nil {
return fmt.Errorf("preload dungeon special drops: %w", err)
}
dropCatalog, err := monsterTable.DropCatalog()
if err != nil {
return fmt.Errorf("preload dungeon drop catalog: %w", err)
}
if err := dropCatalog.ensureGenericPools(); err != nil {
return fmt.Errorf("preload dungeon generic drop pools: %w", err)
}
aiCharacterTable, err := newPVFDungeonAICharacterCatalog(archive)
if err != nil {
return fmt.Errorf("preload dungeon AI character table: %w", err)
}
tutorialScripts, err := newPVFDungeonTutorialScriptCatalog(archive)
if err != nil {
return fmt.Errorf("preload dungeon tutorial script table: %w", err)
}
tutorialScripts.indexBasicActionDestroyTargets(archive, table)
s.worldMapMu.Lock()
s.worldMapTable = table
s.worldMapResolver = resolver
s.dungeonAdmissionCatalog = admissionCatalog
s.dungeonMonsterTable = monsterTable
s.dungeonAICharacterTable = aiCharacterTable
s.dungeonTutorialScripts = tutorialScripts
s.worldMapMu.Unlock()
snapshot := table.Snapshot()
tutorialSnapshot := tutorialScripts.Snapshot()
s.logPacketEvent("dnf-dungeon-worldmap-loaded",
"maps", snapshot.Maps,
"areas", snapshot.Areas,
"dungeons", snapshot.Dungeons,
"admission_counter_policies", len(admissionCatalog.Policies()),
"admission_counter_evidence", len(admissionCatalog.Evidence()),
"mazes", snapshot.Mazes,
"dungeon_refs", snapshot.DungeonRefs,
"monsters", monsterTable.Count(),
"AI_characters", aiCharacterTable.Count(),
"tutorial_cinematic_entries", tutorialSnapshot.CinematicEntries,
"tutorial_cinematics_with_destroy_targets", tutorialSnapshot.CinematicsWithTargets,
"tutorial_maps_with_destroy_targets", tutorialSnapshot.MapsWithTargets,
"tutorial_monster_destroy_targets", tutorialSnapshot.MonsterTargets,
"tutorial_cinematic_read_failures", tutorialSnapshot.ReadFailures,
"tutorial_cinematic_parse_failures", tutorialSnapshot.ParseFailures,
"tutorial_basic_action_entries", tutorialSnapshot.BasicActionEntries,
"tutorial_basic_actions_with_destroy_targets", tutorialSnapshot.BasicActionsWithTargets,
"tutorial_basic_action_maps", tutorialSnapshot.BasicActionMaps,
"tutorial_basic_action_monster_targets", tutorialSnapshot.BasicActionTargets,
"tutorial_basic_action_read_failures", tutorialSnapshot.BasicActionReadFailures,
"tutorial_basic_action_parse_failures", tutorialSnapshot.BasicActionParseFailures)
return nil
}
func (s *Service) dungeonMonsterCatalog() (*pvfDungeonMonsterCatalog, error) {
if s == nil {
return nil, errDungeonWorldMapUnavailable
}
s.worldMapMu.RLock()
defer s.worldMapMu.RUnlock()
if s.dungeonMonsterTable == nil {
return nil, errDungeonMonsterCatalogUnavailable
}
return s.dungeonMonsterTable, nil
}
func (s *Service) dungeonAICharacterCatalog() (*pvfDungeonAICharacterCatalog, error) {
if s == nil {
return nil, errDungeonWorldMapUnavailable
}
s.worldMapMu.RLock()
defer s.worldMapMu.RUnlock()
if s.dungeonAICharacterTable == nil {
return nil, errDungeonAICharacterUnavailable
}
return s.dungeonAICharacterTable, nil
}
func (s *Service) dungeonTutorialScriptCatalog() (*pvfDungeonTutorialScriptCatalog, bool) {
if s == nil {
return nil, false
}
s.worldMapMu.RLock()
defer s.worldMapMu.RUnlock()
if s.dungeonTutorialScripts == nil {
return nil, false
}
return s.dungeonTutorialScripts, true
}
func (s *Service) dungeonWorldMap() (*worldmap.Table, *worldmap.Resolver, error) {
if s == nil {
return nil, nil, errDungeonWorldMapUnavailable
}
s.worldMapMu.RLock()
defer s.worldMapMu.RUnlock()
if s.worldMapTable == nil || s.worldMapResolver == nil {
return nil, nil, errDungeonWorldMapUnavailable
}
return s.worldMapTable, s.worldMapResolver, nil
}
func (s *Service) prepareDungeonRuntime(
ctx context.Context,
session *gameSession,
request dungeoncmd.SelectDungeonRequest,
) (*runtimeDungeonState, worldmap.DungeonRoomScene, error) {
prepared, preparedScene, prepareErr := s.prepareDungeonRuntimePlanned(ctx, session, request, nil)
if prepareErr != nil {
// The client answers a failed entry with nothing at all, so without this line a
// "clicked the dungeon and nothing happened" report has no server-side trace:
// the request decodes, the entry policy runs, a maze is picked, and then the
// error is returned to a caller that stays silent. Measured 2026-09-19 13:09:
// repeated select requests for dungeon 3 after 选择其他地下城 left exactly that
// shape in the log.
s.logGameEvent(session, "game-dungeon-select-entry-failed",
"dungeon_id", request.DungeonID,
"difficulty", request.Difficulty,
"entry_option", request.EntryOption,
"error", prepareErr,
"source", "prepareDungeonRuntime")
}
return prepared, preparedScene, prepareErr
}
func (s *Service) prepareDungeonRuntimePlanned(
ctx context.Context,
session *gameSession,
request dungeoncmd.SelectDungeonRequest,
partyPlan *runtimePartyDungeonEntryPlan,
) (*runtimeDungeonState, worldmap.DungeonRoomScene, error) {
if session == nil || session.selectedCharacterID == 0 {
return nil, worldmap.DungeonRoomScene{}, errDungeonCharacterRequired
}
if request.DungeonID == 0 {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("%w: dungeon=0", worldmap.ErrDungeonNotIndexed)
}
if request.Difficulty > 4 {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("%w: got=%d max=4", errDungeonDifficultyInvalid, request.Difficulty)
}
requestedDungeonID := int64(request.DungeonID)
canonicalDungeonID, antonRaidTranslated := s.resolveAntonRaidDungeonSelection(session, requestedDungeonID)
if canonicalDungeonID <= 0 || canonicalDungeonID > int64(^uint32(0)) {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("%w: dungeon=%d", worldmap.ErrDungeonNotIndexed, canonicalDungeonID)
}
request.DungeonID = uint32(canonicalDungeonID)
antonRaidRequest := runtimeRaidModeForSession(session) != 0 && isAntonRaidEntryDungeonID(canonicalDungeonID)
table, resolver, err := s.dungeonWorldMap()
if err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
character, err := s.dungeonCharacter(ctx, session.selectedCharacterID, session)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
// Both native tower selector icons may send their first-floor base dungeon.
// Resolve that alias from the persisted floor before anything else reads the
// dungeon, so the admission limit and the PVF route agree on the same layer.
entryMode, entryModeSupported := currentDungeonProtocolModeForRoom(request.RuntimeState, request.RuntimeToken, canonicalDungeonID)
if selected, found := table.FindDungeon(canonicalDungeonID); found && entryModeSupported && entryMode == currentDungeonProtocolModeNormal {
canonicalDungeonID, err = s.resolveCurrentTowerDungeonSelection(character, selected)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
request.DungeonID = uint32(canonicalDungeonID)
}
resolvedTowerDungeonID, skippedTowerDungeonIDs, err := resolveCurrentTowerOfDespairSoloDungeon(table, resolver, canonicalDungeonID)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("resolve tower of despair solo floor: %w", err)
}
if resolvedTowerDungeonID != canonicalDungeonID {
s.logGameEvent(session, "game-dungeon-tower-of-despair-solo-floors-skipped",
"requested_dungeon_id", canonicalDungeonID,
"resolved_dungeon_id", resolvedTowerDungeonID,
"skipped_dungeon_ids", skippedTowerDungeonIDs,
"source", "pvf_layout_and_hostile_actor_readiness")
canonicalDungeonID = resolvedTowerDungeonID
request.DungeonID = uint32(canonicalDungeonID)
}
dungeon, ok := table.FindDungeon(canonicalDungeonID)
if !ok {
s.logGameEvent(session, "game-dungeon-select-pvf-miss",
"requested_dungeon_id", requestedDungeonID,
"resolved_anton_dungeon_id", canonicalDungeonID,
"channel_id", session.channel.ID,
"channel_type", session.channel.Type,
"anton_raid", antonRaidRequest,
"reason", "current_pvf_dungeon_not_indexed")
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("%w: dungeon=%d", worldmap.ErrDungeonNotIndexed, canonicalDungeonID)
}
// Anton raid entrances share the ordinary SelectDungeon packet, but their
// authorization comes from the live raid roster rather than PVF level and
// fatigue metadata. Keep this check before the generic policy so a type-23
// channel cannot open a raid map merely because its worldmap entry exists.
//
// partyPlan != nil 恰好等于"这一次是**队友**、由队长的 op16 带着走"
// (prepareRuntimePartyDungeonEntry 是唯一传 plan 的调用方)。超时空那条门禁
// 需要知道这个区别:队友按设计留在原格,拿"他自己/他自己的小队在哪个区域"去比
// 目标区域永远对不上,而这正是规格 D「与小队编制无关」要放行的情况。
// 安东/卢克不受影响 —— 那条分支逐字未动。
if err := s.validateAntonRaidDungeonEntryForEntry(session, dungeon.ID, partyPlan != nil); err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
character, err = s.dungeonCharacter(ctx, session.selectedCharacterID, session)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
protocolMode, modeSupported := currentDungeonProtocolModeForRoom(
request.RuntimeState, request.RuntimeToken, dungeon.ID,
)
if !modeSupported {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("%w: runtime_state=%d runtime_token=%d", errDungeonEntryModeUnsupported, request.RuntimeState, request.RuntimeToken)
}
if protocolMode == currentDungeonProtocolModePractice {
if err := validatePracticeDungeonRoute(dungeon); err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
}
if !antonRaidRequest {
if err := validateDungeonEntry(character, dungeon, session, protocolMode == currentDungeonProtocolModePractice); err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
}
// A tower admission spends one of two independent daily pools (one
// character entry, then the account pool) and is capped by the PVF limit
// for the account's adventure-group level.
if protocolMode != currentDungeonProtocolModePractice {
if err := s.validateCurrentTowerAdmission(ctx, session, character, dungeon); err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
}
s.logGameEvent(session, "game-dungeon-select-entry-policy",
"requested_dungeon_id", requestedDungeonID,
"resolved_anton_dungeon_id", dungeon.ID,
"anton_raid_selector_translated", antonRaidTranslated,
"channel_id", session.channel.ID,
"channel_type", session.channel.Type,
"anton_raid", antonRaidRequest,
"ordinary_policy_skipped", antonRaidRequest,
"raid_started", antonRaidRequest)
questRecord := dnfrepo.QuestRecord{}
questRecordFound := false
if protocolMode != currentDungeonProtocolModePractice {
questRecord, questRecordFound, err = s.dungeonQuestRecord(ctx, session.selectedCharacterID)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
if reconciled, reconcileErr := s.reconcileActiveQuestClearLinkedSubQuestsForDungeon(ctx, session, "select_dungeon_before_maze_selection"); reconcileErr != nil {
s.logGameEvent(session, "game-dungeon-quest-clear-linked-subquest-reconcile-failed",
"dungeon_id", request.DungeonID,
"difficulty", request.Difficulty,
"source", "select_dungeon_before_maze_selection",
"error", reconcileErr)
} else if reconciled {
questRecord, questRecordFound, err = s.dungeonQuestRecord(ctx, session.selectedCharacterID)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
}
}
if err := authorizeCurrentInfernoDungeon(dungeon, questRecord); err != nil {
s.logGameEvent(session, "game-dungeon-inferno-entry-blocked",
"dungeon_id", dungeon.ID,
"required_quest_ids", dungeon.Metadata.InfernoQuestIDs,
"quest_record_found", questRecordFound,
"reason", "pvf_inferno_challenge_quest_not_completed")
return nil, worldmap.DungeonRoomScene{}, err
}
session.dungeon.mu.Lock()
if session.dungeon.runtime != nil && session.dungeon.runtime.Session != nil {
snapshot := session.dungeon.runtime.Session.Snapshot().Run
if snapshot.Status == worldmap.DungeonRunActive &&
!s.runtimeRaidHatcheryTransferAllowedLocked(session, session.dungeon.runtime, dungeon.ID) {
session.dungeon.mu.Unlock()
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("%w: dungeon=%d maze=%d", errDungeonAlreadyActive, snapshot.DungeonID, snapshot.MazeIndex)
}
}
session.dungeon.mu.Unlock()
mazeSelection := dungeonMazeSelection{}
if partyPlan != nil {
if partyPlan.dungeonID != dungeon.ID || partyPlan.mazeIndex < 0 || partyPlan.mazeIndex >= len(dungeon.Mazes) {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("party leader dungeon selection is invalid: dungeon=%d maze=%d", partyPlan.dungeonID, partyPlan.mazeIndex)
}
mazeSelection.Index = partyPlan.mazeIndex
mazeSelection.Reason = "ordinary_party_leader_frozen_selection"
} else if isCurrentFiendWarRaidChannel(session) {
// 超时空的迷宫不是随机的:区域里站着哪个兵种就进对应的那张图
// (魅魔/融合/破坏/合金 四种敌军 + 两种兽人),所以选择由战场状态决定。
mazeSelection, err = s.selectRuntimeFiendWarMaze(session.selectedCharacterID, dungeon)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
} else if protocolMode == currentDungeonProtocolModePractice {
mazeSelection, err = selectPracticeDungeonMaze(dungeon.Mazes, s.chooseDungeonIndex)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("choose practice dungeon maze: %w", err)
}
} else if lukeMaze, luke, lukeErr := s.selectRuntimeRaidLukeSanctumMaze(session, dungeon.ID, len(dungeon.Mazes)); lukeErr != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("choose luke sanctum maze: %w", lukeErr)
} else if luke {
// 卢克一阶段的圣所按它抽到的 [CARD SET MAP INFO] 组走,一次通关走组内一个迷宫
// (raid_luke_sanctum_maze.go)。不是卢克圣所时 luke==false,落到下面原来的分支。
mazeSelection = lukeMaze
} else {
var err error
mazeSelection, err = selectDungeonMaze(dungeon.Mazes, request.Difficulty, questRecord, s.chooseDungeonIndex)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("choose dungeon maze: %w", err)
}
}
mazeIndex := mazeSelection.Index
explicitHellParty := currentDungeonExplicitHellPartyRequest(request, dungeon, mazeIndex)
s.logGameEvent(session, "game-dungeon-maze-selected",
"dungeon_id", request.DungeonID,
"difficulty", request.Difficulty,
"maze_index", mazeIndex,
"selection_reason", mazeSelection.Reason,
"quest_id", mazeSelection.QuestID,
"quest_record_found", questRecordFound,
"body_source", "runtime_pvf_quest_connection_and_persisted_quest_state")
if explicitHellParty {
if err := s.authorizeCurrentDungeonHellParty(ctx, session, table, dungeon.ID); err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
}
chooser := func(choice worldmap.DungeonMapChoice) (int64, error) {
if partyPlan != nil {
mapID, ok := partyPlan.maps[choice.Coordinate]
if !ok {
return 0, fmt.Errorf("party leader has no frozen map for room %s", choice.Coordinate)
}
for _, candidate := range choice.Candidates {
if candidate.ID == mapID {
return mapID, nil
}
}
return 0, fmt.Errorf("party leader map %d is not a candidate for room %s", mapID, choice.Coordinate)
}
index, chooseErr := s.chooseDungeonIndex(len(choice.Candidates))
if chooseErr != nil {
return 0, chooseErr
}
return choice.Candidates[index].ID, nil
}
topology, err := worldmap.BuildDungeonLayout(resolver, dungeon.ID, mazeIndex, chooser)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("build dungeon layout: %w", err)
}
storyStages, storyErr := resolver.StoryStages(dungeon.ID, mazeIndex)
if storyErr != nil && !errors.Is(storyErr, worldmap.ErrStoryStageMissing) {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("resolve dungeon story stages: %w", storyErr)
}
var bossCoordinate worldmap.RoomCoordinate
if len(storyStages) != 0 {
bossCoordinate = storyStages[len(storyStages)-1].Coordinate
} else {
bossIndex := 0
if partyPlan != nil {
bossIndex = -1
for index, coordinate := range topology.Bosses {
if coordinate == partyPlan.boss {
bossIndex = index
break
}
}
if bossIndex < 0 {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("party leader boss room %s is not in follower topology", partyPlan.boss)
}
} else {
var err error
bossIndex, err = s.chooseDungeonIndex(len(topology.Bosses))
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("choose dungeon boss coordinate: %w", err)
}
}
bossCoordinate = topology.Bosses[bossIndex]
}
seed := uint32(0)
if partyPlan != nil {
seed = partyPlan.seed
} else {
var seedErr error
seed, seedErr = s.chooseDungeonSeed()
if seedErr != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("create dungeon room seed: %w", seedErr)
}
}
var hellPartyOwner *currentDungeonHellPartyOwner
if explicitHellParty {
hellPartyOwner, err = s.prepareCurrentDungeonHellParty(table, topology, dungeon, mazeIndex, seed)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("prepare hell party runtime: %w", err)
}
}
s.logGameEvent(session, "game-dungeon-monster-rank-policy",
"dungeon_id", dungeon.ID,
"difficulty", request.Difficulty,
"maze_index", mazeIndex,
"source", "pvf_map_authored_ranks_only")
run, err := worldmap.NewDungeonRun(topology)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("create dungeon run: %w", err)
}
dungeonSession, err := worldmap.NewDungeonSession(run)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("create dungeon session: %w", err)
}
scene, ok := dungeonSession.Scene()
if !ok {
return nil, worldmap.DungeonRoomScene{}, errors.New("created dungeon session has no current scene")
}
monsterCatalog, err := s.dungeonMonsterCatalog()
if err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
room, nextObjectKey, err := newRuntimeDungeonRoom(
scene,
monsterCatalog,
firstDungeonMonsterObjectKey,
)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("create dungeon monster owner: %w", err)
}
// 支援兵:冻结本副本的配置与源角色投影。
//
// 配置在"进入副本"这一刻冻结,之后 GM 的改动只对**下一个**副本生效 ——
// 这样一次副本运行内不会出现"投影换了一半"的中间态。
companionRun := s.prepareCurrentDungeonCompanionRun(ctx, session)
companionReadyCount := currentDungeonCompanionReadyCount(companionRun)
// 本副本是否把官方 APC(code 5405,阵营 "[character]")注入 START_MAP。
//
// ⚠️ 这里**故意不用** run.Ready。契约是:
// - 持久化的 GM 配置(ConfigSource == "settings"/"account_settings")
// 由**客户端执行器**创建支援兵,服务端【不得】再注入 START_MAP actor,
// 否则客户端与服务端各建一个,会出现重复 actor。
// - 只有显式的 phase-1 环境回落(environment_fallback)才保留 START_MAP
// 这条实验通道。
// 判定收在 currentDungeonCompanionServerStartMapActorEnabled 里,对应
// Server-90 的 TestCompanionPersistedRunDoesNotInjectServerStartMapActor。
companionStartMapActor := currentDungeonCompanionServerStartMapActorEnabled(companionRun)
var aiCharacterCatalog *pvfDungeonAICharacterCatalog
if len(scene.AICharacters) != 0 || companionStartMapActor {
aiCharacterCatalog, err = s.dungeonAICharacterCatalog()
if err != nil {
return nil, worldmap.DungeonRoomScene{}, err
}
}
if companionRun != nil {
s.logGameEvent(session, "game-dungeon-companion-run-frozen",
"enabled", companionRun.Enabled,
"ready", companionRun.Ready,
"reason", companionRun.Reason,
"config_revision", companionRun.ConfigRevision,
"config_source", companionRun.ConfigSource,
"source_character_id", companionRun.SourceCharacterID,
"ready_count", companionReadyCount,
"start_map_actor", companionStartMapActor,
"dungeon_id", dungeon.ID)
}
extendedPlan, err := planRuntimeDungeonExtendedActors(
scene,
monsterCatalog,
aiCharacterCatalog,
dungeon.Metadata.BasisLevel,
nextObjectKey,
companionStartMapActor,
)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("create dungeon extended actor owner: %w", err)
}
randomizedSelections, randomizedGroups, randomizedNextObjectKey, randomizedSkipped, err := planCurrentDungeonRandomizedObjects(
dungeon.Mazes[mazeIndex].RandomizedObjects,
seed,
extendedPlan.NextObjectKey,
dungeon.Metadata.BasisLevel,
monsterCatalog,
)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("create dungeon randomized object owner: %w", err)
}
if len(randomizedSkipped) != 0 {
// The map still loads; the unsupported actors simply are not announced.
// EnergyCut (Anton phase two 218) depends on this: every candidate of its
// first group is a passive object with team "monster", which used to fail
// the whole gate closed.
details := make([]string, 0, len(randomizedSkipped))
for _, skip := range randomizedSkipped {
details = append(details, fmt.Sprintf("g%d/c%d/def%d/family%d/team%s",
skip.GroupIndex, skip.CandidateIndex, skip.DefinitionID, skip.Family, skip.Team))
}
s.logPacketEvent("game-dungeon-randomized-object-skipped",
"dungeon_id", dungeon.ID,
"maze_index", mazeIndex,
"skipped_count", len(randomizedSkipped),
"skipped", strings.Join(details, ","),
"reason", "unsupported_randomized_object_kind_not_announced")
}
extendedPlan.NextObjectKey = randomizedNextObjectKey
extendedPlan, err = s.appendCurrentDungeonHellPartyActors(
extendedPlan,
hellPartyOwner,
scene,
dungeon.Metadata.BasisLevel,
monsterCatalog,
aiCharacterCatalog,
)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("project hell party actors: %w", err)
}
if _, err := s.configurePVFTutorialBasicActionRoom(dungeon, scene, room); err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("configure tutorial basic-action room: %w", err)
}
tournament, nextObjectKey, err := s.prepareCurrentTournamentRuntime(
table, dungeon, scene, extendedPlan.NextObjectKey,
)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("prepare tournament runtime: %w", err)
}
if err := appendCurrentTournamentActors(&extendedPlan, tournament); err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("append tournament actors: %w", err)
}
if tournament != nil {
nextObjectKey = extendedPlan.NextObjectKey
}
ridableObjects, ridableEntries, ridableNextObjectKey, err := planCurrentDungeonStaticRidableObjects(
dungeonSession.Rooms(), nextObjectKey,
)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("prepare dungeon static ridable objects: %w", err)
}
if len(ridableEntries) != 0 {
if len(randomizedGroups) >= int(^uint8(0)) {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf(
"%w: randomized_groups=%d static_ridable_groups=1",
errDungeonStartMapGroupCount, len(randomizedGroups),
)
}
randomizedGroups = append(randomizedGroups, ridableEntries)
nextObjectKey = ridableNextObjectKey
}
if err := room.AttachExtendedActors(extendedPlan); err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("attach dungeon extended actor owner: %w", err)
}
clientDungeonID := dungeon.ID
if projectedDungeonID, projected := s.projectSingleAntonRaidClientDungeonID(session, dungeon.ID); projected {
clientDungeonID = projectedDungeonID
}
altar, err := newCurrentDungeonAltarRuntime(dungeon, scene)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("prepare blood altar runtime: %w", err)
}
if altar != nil {
if err := altar.configureRunSeed(seed); err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("prepare blood altar special round: %w", err)
}
}
runtime := &runtimeDungeonState{
Request: request, ProtocolMode: protocolMode, Dungeon: dungeon, MazeIndex: mazeIndex,
clientDungeonID: clientDungeonID,
Character: character, Session: dungeonSession, Room: room, HellParty: hellPartyOwner, NextObjectKey: nextObjectKey,
// 支援兵:运行态持有一份**独立副本**,避免与配置层共享切片/map。
Companion: cloneCurrentDungeonCompanionRun(companionRun),
companionP0OwnerCaptureBudget: uint16(companionReadyCount),
BossCoordinate: bossCoordinate, BossSet: true,
entryCharacterLevel: character.Level,
LayeredMapIndex: -1, StoryStageIndex: -1, Seed: seed,
RandomizedObjectSelections: randomizedSelections,
RandomizedObjectGroups: randomizedGroups,
RandomizedObjectsPlanned: true,
RidableObjects: ridableObjects,
LayerChains: make(map[worldmap.RoomCoordinate]*runtimeDungeonLayerChain),
StoryStages: append([]worldmap.DungeonStoryStage(nil), storyStages...),
Rooms: make(map[runtimeDungeonRoomKey]*runtimeDungeonRoomVisit),
startedAt: s.gameplayNow(),
tournament: tournament,
altar: altar,
}
// 公会贡献度 is judged against the guild as it was when the run STARTED, so the
// membership snapshot is taken here and the settlement re-checks it against the
// live registry (Registry.ContributionEligible) before paying anything. Taking
// it at settlement time instead would credit a member who joined mid-run and
// drop one who left.
runtime.guildContributionEntry, err = s.captureCurrentGuildContributionEntry(ctx, character.CharacterID)
if err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("capture guild contribution membership: %w", err)
}
if _, err := s.applyPVFTutorialBasicActionRoomToSession(
session,
runtime,
scene,
"initial_room_before_start_map",
); err != nil {
return nil, worldmap.DungeonRoomScene{}, fmt.Errorf("apply tutorial basic-action initial room: %w", err)
}
scene, _ = dungeonSession.Scene()
if isPVFTutorialDungeon(runtime) && hasPersistedDungeonTutorialCompletion(character) {
runtime.tutorialCompletionPersisted = true
runtime.tutorialCompletedReentry = true
}
runtime.Rooms[runtimeDungeonRoomKeyFromScene(scene)] = &runtimeDungeonRoomVisit{
Scene: scene,
Room: room,
Seed: seed,
DropRNG: seed,
}
return runtime, scene, nil
}
func authorizeCurrentInfernoDungeon(dungeon worldmap.Dungeon, record dnfrepo.QuestRecord) error {
if !dungeon.Metadata.InfernoDungeon {
return nil
}
for _, questID := range dungeon.Metadata.InfernoQuestIDs {
if dnfquest.IsCompleted(record, questID) {
return nil
}
}
return errDungeonInfernoLocked
}
func (s *Service) commitDungeonRuntime(session *gameSession, runtime *runtimeDungeonState) error {
if session == nil || runtime == nil || runtime.Session == nil {
return errDungeonWorldMapUnavailable
}
if err := validateCurrentDungeonRuntimeTownReturnOrigin(session, runtime); err != nil {
return err
}
session.dungeon.mu.Lock()
if runtime.payloadReleased || runtime.lifecycleState == currentDungeonRuntimeRetired {
session.dungeon.mu.Unlock()
return fmt.Errorf("%w: dungeon=%d maze=%d reason=%s",
errDungeonRuntimeRetired,
runtime.Dungeon.ID,
runtime.MazeIndex,
runtime.lifecycleEndReason,
)
}
previousRuntime := session.dungeon.runtime
if previousRuntime == runtime {
session.dungeon.mu.Unlock()
return fmt.Errorf("%w: dungeon=%d maze=%d generation=%d",
errDungeonAlreadyActive,
runtime.Dungeon.ID,
runtime.MazeIndex,
runtime.lifecycleToken,
)
}
if previousRuntime != nil && previousRuntime.Session != nil {
snapshot := previousRuntime.Session.Snapshot().Run
hatcheryTransfer := s.runtimeRaidHatcheryTransferAllowedLocked(session, previousRuntime, runtime.Dungeon.ID)
if snapshot.Status == worldmap.DungeonRunActive && !hatcheryTransfer {
session.dungeon.mu.Unlock()
return fmt.Errorf("%w: dungeon=%d maze=%d", errDungeonAlreadyActive, snapshot.DungeonID, snapshot.MazeIndex)
}
if !hatcheryTransfer {
// A warp into an open hatchery is not giving up: the energy-interception
// attempt stays valid and only its runtime is retired below.
if err := s.notifyRuntimeRaidDungeonAbandonedLocked(session, previousRuntime); err != nil {
session.dungeon.mu.Unlock()
return err
}
} else if err := s.publishRuntimeRaidDungeonTransferAwayLocked(session, previousRuntime); err != nil {
// The attempt stays valid, but the client's row for the gate just left
// must not: keeping the attempt and keeping the row were the same
// decision here, and only the first one was meant. Without this the
// 战况表 drew the squad inside 能量阻截战 for the rest of the phase.
//
// Deliberately NOT fatal, unlike the abandoned notification above: that
// one advances the raid's state machine, this one only redraws a row. A
// member whose socket dropped mid-broadcast must not lose the hatchery
// it just walked into over a cosmetic update -- the worst case here is
// the stale row this call exists to remove.
s.logPacketEvent("game-raid-hatchery-transfer-participation-failed",
"char_id", session.selectedCharacterID,
"raid_key", previousRuntime.raidDungeon.RaidKey,
"left_map_id", previousRuntime.raidDungeon.MapID,
"entered_map_id", runtime.Dungeon.ID,
"error", err)
}
}
if previousRuntime != nil {
s.retireCurrentDungeonRuntimeLocked(session, previousRuntime, "new_dungeon_run_replaces_previous_runtime")
}
// Lifecycle attempts are retained by the process-wide raid state after the
// owning socket disconnects. A per-session counter restarts at one on every
// login and can therefore collide with an earlier closed attempt for the same
// character. Allocate a process-wide nonce so reconnects can retry the open
// objective without being rejected as an already-completed attempt.
if runtime.lifecycleToken == 0 {
runtime.lifecycleToken = s.allocateDungeonLifecycleToken()
}
session.dungeon.runToken = runtime.lifecycleToken
runtime.lifecycleState = currentDungeonRuntimeActive
runtime.lifecycleEndReason = ""
session.dungeon.runtime = runtime
// 新一轮副本开始:复位 op32 补发标志,保证强制出图/回城后重进仍会触发一次。
session.currentDungeonReviveReapplySent = false
session.dungeon.deathTower = nil
session.dungeon.debugLightningPending = false
session.dungeon.mu.Unlock()
if err := s.freezeRuntimeRaidDungeonSelection(session, runtime); err != nil {
session.dungeon.mu.Lock()
if session.dungeon.runtime == runtime {
s.retireCurrentDungeonRuntimeLocked(session, runtime, "new_dungeon_raid_selection_freeze_failed")
}
session.dungeon.mu.Unlock()
return err
}
// 到这里这次副本已经完整提交给会话了,记一条"进入副本"。
// 放在 freeze 之后而不是解锁之后:freeze 失败会把运行时立刻退休,那种情况
// 不该留下一条"进入"记录。
s.recordDungeonEnter(session, runtime)
return nil
}
func (s *Service) allocateDungeonLifecycleToken() uint64 {
if s == nil {
return 0
}
for {
token := s.nextDungeonLifecycleToken.Add(1)
if token != 0 {
return token
}
}
}
func (s *Service) dungeonCharacter(ctx context.Context, characterID uint16, sessions ...*gameSession) (dnfrepo.CharacterRecord, error) {
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil {
return dnfrepo.CharacterRecord{}, dungeoncmd.ErrOwnerUnavailable
}
if ctx == nil {
ctx = context.Background()
}
character, found, err := repositories.Character.Load(ctx, strconv.Itoa(int(characterID)))
if err != nil {
return dnfrepo.CharacterRecord{}, err
}
if !found {
return dnfrepo.CharacterRecord{}, errDungeonCharacterNotFound
}
character = dnfrepo.CloneCharacter(character)
accountID := strings.TrimSpace(s.accountIDForSession(sessions...))
if stored := strings.TrimSpace(character.AccountID); stored != "" && accountID != "" && stored != accountID {
return dnfrepo.CharacterRecord{}, fmt.Errorf("%w: selected=%s stored=%s", errDungeonAccountMismatch, accountID, stored)
}
return character, nil
}
func (s *Service) dungeonQuestRecord(ctx context.Context, characterID uint16) (dnfrepo.QuestRecord, bool, error) {
repositories, ok := s.repositoryGroup()
if !ok || repositories.Quest == nil {
return dnfrepo.QuestRecord{}, false, errDungeonQuestRepositoryUnavailable
}
if ctx == nil {
ctx = context.Background()
}
characterKey := strconv.Itoa(int(characterID))
record, found, err := repositories.Quest.Load(ctx, characterKey)
if err != nil {
return dnfrepo.QuestRecord{}, false, err
}
if !found {
return dnfrepo.QuestRecord{CharacterID: characterKey}, false, nil
}
return dnfrepo.CloneQuest(record), true, nil
}
func validateDungeonEntry(character dnfrepo.CharacterRecord, dungeon worldmap.Dungeon, session *gameSession, practice bool) error {
partyCount := 1
if session != nil {
session.party.mu.Lock()
if len(session.party.state.Members) > partyCount {
partyCount = len(session.party.state.Members)
}
session.party.mu.Unlock()
}
partyLimit := dungeon.Metadata.LimitPartyCount.Value
partyLimitSet := dungeon.Metadata.LimitPartyCount.Set
// Blood/Ultimate Altar progression is intentionally session-owned. Until a
// shared party altar runtime exists, admitting more than one member would
// create divergent round/monster sequence owners on the fanout path. The
// real 11006/11007 PVF lacks a party limit, so enforce this implementation
// boundary explicitly and reuse the normal entry-party-limit rejection.
if dungeon.ID == 11006 || dungeon.ID == 11007 || dungeon.BloodAltarKind != 0 {
partyLimit = 1
partyLimitSet = true
}
fatigue, fatigueKnown := dnfdungeon.CharacterStat(character.Stats, "fatigue", "fp", "疲劳")
return dnfdungeon.ValidateEntry(dnfdungeon.EntryPolicy{
CharacterLevel: character.Level,
MinimumLevel: dungeon.Metadata.MinimumRequiredLevel.Value,
MinimumLevelSet: dungeon.Metadata.MinimumRequiredLevel.Set,
PartyCount: partyCount,
PartyLimit: partyLimit,
PartyLimitSet: partyLimitSet,
NoFatigue: currentDungeonIgnoresFatigue(dungeon) || practice,
EnterWithoutFatigue: dungeon.Metadata.Flags["enter without fatigue"],
Fatigue: fatigue,
FatigueKnown: fatigueKnown,
})
}
func (s *Service) chooseDungeonIndex(limit int) (int, error) {
if limit <= 0 {
return 0, worldmap.ErrMazeNotFound
}
if limit == 1 {
return 0, nil
}
if s != nil && s.dungeonChoice != nil {
index, err := s.dungeonChoice(limit)
if err != nil {
return 0, err
}
if index < 0 || index >= limit {
return 0, fmt.Errorf("dungeon choice index %d outside [0,%d)", index, limit)
}
return index, nil
}
index, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(limit)))
if err != nil {
return 0, fmt.Errorf("choose dungeon random index: %w", err)
}
return int(index.Int64()), nil
}
func (s *Service) chooseDungeonSeed() (uint32, error) {
if s != nil && s.dungeonSeed != nil {
return s.dungeonSeed()
}
var data [4]byte
if _, err := cryptorand.Read(data[:]); err != nil {
return 0, err
}
return binary.LittleEndian.Uint32(data[:]), nil
}