服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
547 lines
24 KiB
Go
547 lines
24 KiB
Go
// game_sessions.go 维护 DNF game 连接到在线角色的短生命周期索引。
|
||
// 这里的状态只用于旧客户端实时回包投影,不是队伍、角色或玩法的持久 owner。
|
||
package dnfbridge
|
||
|
||
import (
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/worldmap"
|
||
)
|
||
|
||
type boundGameSessionCharacter struct {
|
||
session *gameSession
|
||
character uint16
|
||
generation uint64
|
||
}
|
||
|
||
func (s *Service) bindGameSessionCharacter(session *gameSession, charID uint16) {
|
||
if s == nil || session == nil || charID == 0 {
|
||
return
|
||
}
|
||
characterChanged := session.selectedCharacterID != charID
|
||
if characterChanged {
|
||
// 换角色等于换战场上下文:先把超时空的坐标与遇敌去重记下来/清掉,
|
||
// 再把面板增量基线归零 —— 否则新角色会拿着旧角色的基线算差异,
|
||
// 发出一批不存在的"变化行"。
|
||
s.captureRuntimeFiendWarRejoinPosition(session)
|
||
session.fiendWarEncounterRaidKey, session.fiendWarEncounterArea = 0, 0
|
||
session.fiendWarEncounterAt = time.Time{}
|
||
session.fiendWarBoardMu.Lock()
|
||
session.fiendWarBoard = runtimeRaidState{}
|
||
session.fiendWarBoardMu.Unlock()
|
||
session.darkKnightCombo.reset()
|
||
session.customAbilityConverter.reset()
|
||
}
|
||
if oldID := session.selectedCharacterID; oldID != 0 && oldID != charID {
|
||
s.stopCurrentDailyFatigueReset(session, "selected_character_changed")
|
||
// 换角色时撤掉挂在该会话上的婚姻邀请等待(见 clearWeddingInvitations)。
|
||
// 必须在拿 session.dungeon.mu / s.mu 之前调用:它自己要拿 s.weddingMu,
|
||
// 而全局锁序是 weddingMu 先于 mu。
|
||
s.clearWeddingInvitations(session)
|
||
s.closeCurrentGuildCreateSession(session)
|
||
s.closeCurrentPVPSession(session)
|
||
s.cleanupOnlinePlayer(session)
|
||
s.closeCurrentExpertJobStoreSession(session, true)
|
||
s.closeCurrentPrivateStoreSession(session, true)
|
||
s.detachOnlineItemTrade(session, "selected_character_changed")
|
||
s.detachRuntimePartySession(session, "selected_character_changed")
|
||
// Switching characters leaves whatever party the previous character was
|
||
// in, so its attack-squad label goes with it (operator rule 2026-09-27:
|
||
// every path that detaches the ordinary party drops the 编制 too).
|
||
s.unassignRuntimeRaidSquadOnSessionLeave(oldID, "selected_character_changed")
|
||
s.stopCurrentSpendTimeClock(session, "selected_character_changed")
|
||
s.stopCurrentPetGrowthClock(session, "selected_character_changed")
|
||
}
|
||
session.dungeon.mu.Lock()
|
||
if oldID := session.selectedCharacterID; oldID != 0 && oldID != charID {
|
||
runtime := session.dungeon.runtime
|
||
if runtime != nil && runtime.Session != nil && runtime.Session.Snapshot().Run.Status == worldmap.DungeonRunActive {
|
||
if err := runtime.Session.Abandon(); err != nil {
|
||
s.logGameEvent(session, "game-selected-character-change-dungeon-abandon-failed", "error", err)
|
||
}
|
||
}
|
||
if runtime != nil {
|
||
if err := s.notifyRuntimeRaidDungeonAbandonedLocked(session, runtime); err != nil {
|
||
s.logGameEvent(session, "game-selected-character-change-raid-abandon-publish-failed", "error", err)
|
||
}
|
||
}
|
||
s.retireCurrentDungeonRuntimeLocked(session, runtime, "selected_character_changed")
|
||
}
|
||
resident := (*gameSession)(nil)
|
||
previousChannelID, previousChannelType, previousChannelKnown :=
|
||
s.observeCurrentClientSelectedChannel(session, charID)
|
||
if s.onlinePlayers != nil {
|
||
resident = s.onlinePlayers.SessionForCharacter(charID)
|
||
if resident != nil && resident != session {
|
||
previousChannelID = resident.channel.ID
|
||
previousChannelType = resident.channel.Type
|
||
previousChannelKnown = true
|
||
}
|
||
if resident != nil && resident != session && session.channelReconnect {
|
||
// A channel-reconnect session binds before its op13 inventory
|
||
// bootstrap. Settle and stop the resident clock synchronously here,
|
||
// so any threshold reward is already durable and therefore included
|
||
// in that new bootstrap. Waiting for the old socket's later unbind
|
||
// would allow the reward commit to race after op13 and remain visually
|
||
// absent until another login. The party itself is not carried over:
|
||
// see the replacement-session leave below.
|
||
s.stopCurrentSpendTimeClock(resident, "replacement_game_session_bound_before_inventory_bootstrap")
|
||
}
|
||
}
|
||
if session.channelReconnect && previousChannelKnown &&
|
||
isCurrentTeamChannelType(previousChannelType) &&
|
||
!isCurrentTeamChannelType(session.channel.Type) {
|
||
session.spendTime.mu.Lock()
|
||
session.spendTime.eventInfoReplayAfterTeamReturn = true
|
||
session.spendTime.mu.Unlock()
|
||
s.logGameEvent(session, "game-team-channel-return-op108-replay-armed",
|
||
"char_id", charID,
|
||
"previous_channel_id", previousChannelID,
|
||
"previous_channel_type", previousChannelType,
|
||
"target_channel_id", session.channel.ID,
|
||
"target_channel_type", session.channel.Type)
|
||
}
|
||
// 全局锁序:weddingMu 必须在 mu 之前拿、在 mu 之后放。婚姻事务
|
||
// (wedding_runtime.go 等)只拿 weddingMu,而会话绑定会同时碰两者;
|
||
// 顺序写反就会和婚姻手势互相等待。
|
||
s.weddingMu.Lock()
|
||
s.mu.Lock()
|
||
if s.gameSessions == nil {
|
||
s.gameSessions = make(map[uint16]*gameSession)
|
||
}
|
||
if oldID := session.selectedCharacterID; oldID != 0 && oldID != charID {
|
||
if s.gameSessions[oldID] == session {
|
||
delete(s.gameSessions, oldID)
|
||
}
|
||
}
|
||
previous := s.gameSessions[charID]
|
||
if characterChanged || session.characterGeneration == 0 ||
|
||
(previous != nil && previous != session && session.characterGeneration == previous.characterGeneration) {
|
||
s.allocateGameSessionCharacterGenerationLocked(session, previous)
|
||
} else if session.characterGeneration > s.nextGameSessionGeneration {
|
||
s.nextGameSessionGeneration = session.characterGeneration
|
||
}
|
||
if characterChanged {
|
||
session.blackDiamondMu.Lock()
|
||
session.blackDiamondExpiresAt = time.Time{}
|
||
session.blackDiamondMu.Unlock()
|
||
clearCurrentGameOptionRestoreSnapshot(session)
|
||
clearCurrentCharacterOptionRestoreSnapshot(session)
|
||
session.expertJobInfoCharacterID = 0
|
||
session.raidAvailabilitySent = false
|
||
session.raidDirectoryReadySent = false
|
||
session.raidDirectory = runtimeRaidDirectorySubscription{}
|
||
session.raidBrowseTarget = 0
|
||
session.raidBrowseAt = time.Time{}
|
||
session.raidBrowseMembers = 0
|
||
resetRuntimeRaidSessionProjection(session)
|
||
session.selectedCollectboxIndex = 0
|
||
session.selectedCollectboxCharacterID = 0
|
||
session.collectboxInitializationSent = false
|
||
session.collectboxSceneProjectionSent = false
|
||
session.collectboxSelectionRefreshSent = false
|
||
}
|
||
session.selectedCharacterID = charID
|
||
if resident != nil && resident != session {
|
||
if current := s.gameSessions[charID]; current == resident {
|
||
s.mu.Unlock()
|
||
s.weddingMu.Unlock()
|
||
session.dungeon.mu.Unlock()
|
||
// 超时空:被顶号/换频道也先把坐标记进名册再走退队路径。
|
||
s.captureRuntimeFiendWarRejoinPosition(resident)
|
||
// A replacement socket for a character the resident session still
|
||
// holds is a party LEAVE, exactly like the one the code below
|
||
// performs for a replacement that is no longer the map's holder: a
|
||
// channel move and a takeover both end the connection that held the
|
||
// party, so the member's cache is no longer handed over here
|
||
// (operator rule 2026-09-27: 「还有更换频道 也算退队」,
|
||
// 「被顶掉以后…此时也按退队处理」).
|
||
s.leaveManagedRuntimePartyOnReplacementSession(resident, session, "resident_session_replaced")
|
||
s.restoreCurrentPartyUDPEndpoint(session)
|
||
return
|
||
}
|
||
}
|
||
s.gameSessions[charID] = session
|
||
s.mu.Unlock()
|
||
s.weddingMu.Unlock()
|
||
session.dungeon.mu.Unlock()
|
||
s.restoreCurrentPartyUDPEndpoint(session)
|
||
// 角色上线:开启"在线窗口",虚弱恢复的计时只在这个窗口里前进
|
||
// (离线期间窗口被 stop 清掉,计时因此锁定 —— 运营要求 2026-09-15)。
|
||
s.startCurrentWeaknessOnlineClock(session)
|
||
if previous != nil && previous != session {
|
||
s.clearWeddingInvitations(previous)
|
||
s.closeCurrentGuildCreateSession(previous)
|
||
s.closeCurrentPVPSession(previous)
|
||
s.releaseDisconnectedDungeonAttempt(previous, "replacement_game_session_bound")
|
||
s.leaveManagedRuntimePartyOnReplacementSession(previous, session, "replacement_game_session_bound")
|
||
s.restoreRuntimeRaidPresence(session, "replacement_game_session_bound")
|
||
// 接管同一个角色的,可能是本客户端换频道,也可能是同账号的第二个客户端选了
|
||
// 同一个角色。后者要把旧客户端请出去;靠进程号/登录身份区分,判不出来就放过。
|
||
s.displaceReplacedAccountSession(previous, session)
|
||
}
|
||
// 一个账号只留一个在玩的客户端。必须放在绑定**之后**:换频道时新会话接管的是同一
|
||
// 个角色,上面那段已经把注册持有者换成自己,旧会话查不到,天然不会误踢。
|
||
//
|
||
// 这里刻意用**原始的** session.accountID,不用 accountIDForSession:后者在账号未
|
||
// 绑定时会回退到实例账号,而本机的实例账号就是一个真实账号,拿它当判据会让未鉴权
|
||
// 的客户端踢掉真正的持有者。
|
||
s.enforceCurrentAccountSingleSession(session, session.accountID)
|
||
}
|
||
|
||
// allocateGameSessionCharacterGenerationLocked assigns a nonce that is unique
|
||
// across the bridge process. RuntimePartyManager stores this generation without
|
||
// a session pointer, so a reconnect must never reuse the retired socket's
|
||
// initial value (both fresh sockets otherwise begin at generation one).
|
||
// s.mu must be held.
|
||
func (s *Service) allocateGameSessionCharacterGenerationLocked(session *gameSession, competing *gameSession) uint64 {
|
||
if s == nil || session == nil {
|
||
return 0
|
||
}
|
||
next := s.nextGameSessionGeneration
|
||
if session.characterGeneration > next {
|
||
next = session.characterGeneration
|
||
}
|
||
if competing != nil && competing.characterGeneration > next {
|
||
next = competing.characterGeneration
|
||
}
|
||
next++
|
||
if next == 0 {
|
||
next++
|
||
}
|
||
s.nextGameSessionGeneration = next
|
||
session.characterGeneration = next
|
||
return next
|
||
}
|
||
|
||
func (s *Service) promoteResidentGameSession(session *gameSession, charID uint16) {
|
||
if s == nil || session == nil || charID == 0 || session.selectedCharacterID != charID {
|
||
return
|
||
}
|
||
// 同 bindGameSessionCharacter:weddingMu 先于 mu,后于 mu 释放。
|
||
s.weddingMu.Lock()
|
||
s.mu.Lock()
|
||
if s.gameSessions == nil {
|
||
s.gameSessions = make(map[uint16]*gameSession)
|
||
}
|
||
previous := s.gameSessions[charID]
|
||
s.gameSessions[charID] = session
|
||
s.mu.Unlock()
|
||
s.weddingMu.Unlock()
|
||
s.restoreCurrentPartyUDPEndpoint(session)
|
||
if previous != nil && previous != session {
|
||
s.clearWeddingInvitations(previous)
|
||
s.closeCurrentGuildCreateSession(previous)
|
||
s.closeCurrentPVPSession(previous)
|
||
s.releaseDisconnectedDungeonAttempt(previous, "replacement_game_session_promoted")
|
||
s.leaveManagedRuntimePartyOnReplacementSession(previous, session, "replacement_game_session_promoted")
|
||
s.restoreRuntimeRaidPresence(session, "replacement_game_session_promoted")
|
||
}
|
||
}
|
||
|
||
// leaveManagedRuntimePartyOnReplacementSession treats a replacement socket for
|
||
// the same character as a LEAVE of the party the old socket was holding.
|
||
//
|
||
// Operator rule (2026-09-27): 「被顶掉以后 客户端的连接就断开了 此时也按退队处理」 and
|
||
// 「还有更换频道 也算退队」. A replacement session is exactly one of those two
|
||
// events -- the same client moving to another channel, or a second client taking
|
||
// the character over (顶号) -- and both of them end the connection that held the
|
||
// party. The party therefore does not travel with the character: the member is
|
||
// removed under the OLD session's generation, the survivors are told their
|
||
// roster shrank, and the replacement comes up partyless.
|
||
//
|
||
// This replaces the earlier hand-over (RuntimePartyManager.RebindSession), which
|
||
// moved the membership onto the new generation. Two things must survive the
|
||
// change:
|
||
//
|
||
// - The old socket's own teardown cannot perform this leave. By the time it
|
||
// runs, s.gameSessions[char] is already the replacement, so
|
||
// boundGameSessionCharacterCurrent answers false for the old identity and
|
||
// detachRuntimePartySession returns without touching the roster. Dropping the
|
||
// hand-over without doing the leave here would leave a member nobody can
|
||
// remove -- teammates still see him, his own client has no party, and he has
|
||
// no way to leave (the 2026-09-20 ghost-party class).
|
||
// - Both session caches are cleared, so the replacement cannot inherit the
|
||
// departed party through the legacy client-projection fallback
|
||
// (bootstrapManagedRuntimePartyForSession) this function used to end on.
|
||
func (s *Service) leaveManagedRuntimePartyOnReplacementSession(previous *gameSession, replacement *gameSession, source string) {
|
||
if s == nil || previous == nil || replacement == nil || previous == replacement {
|
||
return
|
||
}
|
||
characterID := previous.selectedCharacterID
|
||
if characterID == 0 || characterID != replacement.selectedCharacterID {
|
||
return
|
||
}
|
||
generation := previous.characterGeneration
|
||
previousState := runtimePartyStateSnapshot(previous)
|
||
replacementState := runtimePartyStateSnapshot(replacement)
|
||
storeRuntimePartyState(previous, alignedcmd.PartyState{})
|
||
// The replacement is a fresh selection of this character; whatever its cache
|
||
// holds predates the leave and must not outlive it.
|
||
storeRuntimePartyState(replacement, alignedcmd.PartyState{})
|
||
// 编制 is derived from the ordinary party, so it follows this leave too
|
||
// (operator rule: 攻坚队被顶号/掉线 本来就要掉编制).
|
||
s.unassignRuntimeRaidSquadOnSessionLeave(characterID, source)
|
||
if replacementState.PartyID > 0 {
|
||
// The replacement was already showing a party -- the resident takeover
|
||
// used to hand the cache over -- and it is not a member of it. Give its
|
||
// client the same empty projection a leave sends, so the window cannot
|
||
// outlive the membership it was drawn from.
|
||
if err := s.sendRuntimePartyMemberRemoved(replacement, uint16(replacementState.PartyID), alignedcmd.PartyState{}); err != nil {
|
||
s.logWarn("dnfbridge deferred replacement session party clear",
|
||
"char_id", characterID,
|
||
"party_id", replacementState.PartyID,
|
||
"source", source,
|
||
"error", err)
|
||
}
|
||
}
|
||
if previousState.PartyID <= 0 || generation == 0 {
|
||
return
|
||
}
|
||
manager := s.runtimePartyManagerForService()
|
||
if manager == nil {
|
||
return
|
||
}
|
||
result := manager.Leave(characterID, generation)
|
||
if !result.OK {
|
||
// Nothing to wind down: the manager no longer holds this member (an
|
||
// explicit op13 leave, a disband, a kick). The cache clears above are all
|
||
// the replacement owed, and the survivors' own party -- relay included --
|
||
// must not be touched from here.
|
||
s.logGameEvent(replacement, "game-party-replacement-session-leave-skipped",
|
||
"char_id", characterID,
|
||
"party_id", previousState.PartyID,
|
||
"prior_generation", generation,
|
||
"source", source,
|
||
"reason", result.Reason)
|
||
return
|
||
}
|
||
if result.Disbanded || result.Party.ID == 0 {
|
||
if err := s.publishRuntimePartyDisbandToSurvivors(
|
||
nil,
|
||
previousState,
|
||
characterID,
|
||
previous,
|
||
source+"_disband",
|
||
); err != nil {
|
||
s.logWarn("dnfbridge deferred party disband during replacement session leave",
|
||
"char_id", characterID,
|
||
"party_id", previousState.PartyID,
|
||
"error", err)
|
||
}
|
||
s.closeRuntimePartyUDPRelay(previousState.PartyID)
|
||
s.logGameEvent(replacement, "game-party-replacement-session-left",
|
||
"char_id", characterID,
|
||
"party_id", previousState.PartyID,
|
||
"remaining_party_id", 0,
|
||
"source", source,
|
||
"reason", "a_party_that_drops_to_one_member_is_not_a_party")
|
||
return
|
||
}
|
||
nextState := s.publishRuntimePartyMembershipSnapshot(result.Party)
|
||
if nextState.PartyID > 0 {
|
||
s.syncRuntimePartyUDPRelay(nextState)
|
||
} else {
|
||
s.closeRuntimePartyUDPRelay(previousState.PartyID)
|
||
}
|
||
// A replacement socket's leave must not fail the survivor's write: see
|
||
// swallowPeerFanoutWrite for the fan-out boundary.
|
||
if err := s.broadcastRuntimePartyMemberRemoved(nil, previousState, nextState, characterID, previous); err != nil {
|
||
s.logWarn("dnfbridge deferred party member removal during replacement session leave",
|
||
"char_id", characterID,
|
||
"party_id", nextState.PartyID,
|
||
"error", err)
|
||
}
|
||
s.logGameEvent(replacement, "game-party-replacement-session-left",
|
||
"char_id", characterID,
|
||
"party_id", previousState.PartyID,
|
||
"remaining_party_id", nextState.PartyID,
|
||
"member_count", len(runtimePartyMembers(nextState)),
|
||
"source", source,
|
||
"reason", "a_replacement_socket_for_the_same_character_is_a_party_leave")
|
||
}
|
||
|
||
func (s *Service) unbindGameSession(session *gameSession) {
|
||
if s == nil || session == nil {
|
||
return
|
||
}
|
||
// 掉线/关游戏/小退都由这条路径收口,先把该会话上挂着的婚姻邀请等待撤掉。
|
||
// 必须在拿 s.mu 之前调用:clearWeddingInvitations 自己要拿 s.weddingMu,
|
||
// 而全局锁序是 weddingMu 先于 mu(见下方 unbind 尾部的加锁顺序)。
|
||
s.clearWeddingInvitations(session)
|
||
s.closeCurrentGuildCreateSession(session)
|
||
s.closeCurrentPVPSession(session)
|
||
s.mu.Lock()
|
||
canonical := session.selectedCharacterID != 0 && s.gameSessions[session.selectedCharacterID] == session
|
||
s.mu.Unlock()
|
||
if canonical {
|
||
// The party detach runs BEFORE the dungeon attempt is released.
|
||
// releaseDisconnectedDungeonAttempt retires the runtime and releases its
|
||
// payload, and releaseDungeonRuntimePayload is what clears
|
||
// runtime.partyDungeonOwner -- the only handle on the shared
|
||
// party-dungeon roster that the teammates still need updated. Asking
|
||
// after that release always answers "no binding", which is why a
|
||
// mid-dungeon disconnect used to leave the roster row, the op26 host
|
||
// slot and the actor behind. See detachRuntimePartySession.
|
||
s.detachRuntimePartySession(session, "game_session_unbound_or_disconnected")
|
||
// Closing the game, returning to the character select screen and a
|
||
// dropped transport all detach the ordinary party; the attack-squad label
|
||
// is derived from that party, so it is cleared here rather than left on
|
||
// the roster for a reconnect to inherit. Operator rule 2026-09-27:
|
||
// 「无论是否主动退队 只要游戏关闭了或者小退到选择角色界面了 一律按退队处理」,
|
||
// 「攻坚队…掉线 本来就要掉编制」. Before the gate run is released on the
|
||
// next line, which is why the clear above does not carry the
|
||
// inside-a-dungeon guard its party-leave sibling has.
|
||
s.unassignRuntimeRaidSquadOnSessionLeave(session.selectedCharacterID, "game_session_unbound_or_disconnected")
|
||
s.releaseDisconnectedDungeonAttempt(session, "game_session_unbound_or_disconnected")
|
||
s.noteRuntimeRaidSessionDisconnected(session, "game_session_unbound_or_disconnected")
|
||
s.detachOnlineItemTrade(session, "game_session_unbound_or_disconnected")
|
||
s.closeCurrentPrivateStoreSession(session, false)
|
||
}
|
||
s.cancelCurrentDungeonDeathReturn(session, "game_session_unbound_or_disconnected")
|
||
session.dungeon.mu.Lock()
|
||
s.cancelCurrentDungeonCardAutoFlipLocked(session, session.dungeon.runtime, "game_session_unbound_or_disconnected")
|
||
session.dungeon.mu.Unlock()
|
||
s.stopCurrentPetGrowthClock(session, "game_session_unbound_or_disconnected")
|
||
s.stopCurrentSpendTimeClock(session, "game_session_unbound_or_disconnected")
|
||
s.stopCurrentDailyFatigueReset(session, "game_session_unbound_or_disconnected")
|
||
// 虚弱恢复的时钟是"在线秒":下线时把本次在线窗口结清并清掉窗口标记,
|
||
// 这样离线期间任何读路径再来结算都不会让计时前进(运营要求 2026-09-15)。
|
||
s.stopCurrentWeaknessOnlineClock(session)
|
||
s.endCurrentPartyTeleportForSession(session)
|
||
// 同 bindGameSessionCharacter:weddingMu 先于 mu 拿、后于 mu 放。
|
||
s.weddingMu.Lock()
|
||
defer s.weddingMu.Unlock()
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if session.selectedCharacterID != 0 && s.gameSessions[session.selectedCharacterID] == session {
|
||
delete(s.gameSessions, session.selectedCharacterID)
|
||
}
|
||
if session.selectedCharacterID != 0 {
|
||
advanceGameSessionCharacterGeneration(session)
|
||
}
|
||
}
|
||
|
||
// releaseDisconnectedDungeonAttempt removes only the current dungeon attempt.
|
||
// The raid itself and its completed objectives remain in the process-wide raid
|
||
// state, so a reconnect can rejoin the same single raid and continue from the
|
||
// next open objective. This also releases the group occupancy that would
|
||
// otherwise leave the character unable to enter after a dropped connection.
|
||
func (s *Service) releaseDisconnectedDungeonAttempt(session *gameSession, reason string) {
|
||
if s == nil || session == nil {
|
||
return
|
||
}
|
||
// 超时空:掉线前先把这个人被确认过的城镇坐标记进名册。按 D2 的决定,超时空
|
||
// 掉线不退队 —— 座位由 raid_disconnect.go 的宽限窗口守着,这里存的坐标只作
|
||
// 诊断与重连参考(重连实际一律回 PVF 等待室起点,见 runtimeFiendWarRejoinLocation)。
|
||
s.captureRuntimeFiendWarRejoinPosition(session)
|
||
session.dungeon.mu.Lock()
|
||
runtime := session.dungeon.runtime
|
||
if runtime == nil {
|
||
// Death Tower and compatibility owners are session-scoped and may exist
|
||
// without a worldmap runtime. A transport teardown is still a complete
|
||
// dungeon-generation boundary, so retire that detached scope as well.
|
||
s.retireCurrentDungeonRuntimeLocked(session, nil, reason)
|
||
session.dungeon.mu.Unlock()
|
||
s.logGameEvent(session, "game-dungeon-disconnect-detached-scope-released",
|
||
"char_id", session.selectedCharacterID,
|
||
"reason", reason)
|
||
return
|
||
}
|
||
|
||
status := worldmap.DungeonRunAbandoned
|
||
if runtime.Session != nil {
|
||
status = runtime.Session.Snapshot().Run.Status
|
||
if status == worldmap.DungeonRunActive {
|
||
if err := runtime.Session.Abandon(); err != nil {
|
||
s.logGameEvent(session, "game-dungeon-disconnect-abandon-failed",
|
||
"char_id", session.selectedCharacterID,
|
||
"dungeon_id", runtime.Dungeon.ID,
|
||
"reason", reason,
|
||
"error", err)
|
||
} else {
|
||
status = worldmap.DungeonRunAbandoned
|
||
}
|
||
}
|
||
}
|
||
var lifecycleErr error
|
||
if status == worldmap.DungeonRunCompleted {
|
||
lifecycleErr = s.notifyRuntimeRaidDungeonCompletedLocked(session, runtime)
|
||
} else {
|
||
lifecycleErr = s.notifyRuntimeRaidDungeonAbandonedLocked(session, runtime)
|
||
}
|
||
if lifecycleErr != nil {
|
||
s.logGameEvent(session, "game-dungeon-disconnect-raid-lifecycle-publish-failed",
|
||
"char_id", session.selectedCharacterID,
|
||
"dungeon_id", runtime.Dungeon.ID,
|
||
"run_status", status,
|
||
"reason", reason,
|
||
"error", lifecycleErr)
|
||
}
|
||
s.retireCurrentDungeonRuntimeLocked(session, runtime, reason)
|
||
session.dungeon.mu.Unlock()
|
||
|
||
s.logGameEvent(session, "game-dungeon-disconnect-attempt-released",
|
||
"char_id", session.selectedCharacterID,
|
||
"dungeon_id", runtime.Dungeon.ID,
|
||
"run_status", status,
|
||
"raid_retained", true,
|
||
"reason", reason)
|
||
}
|
||
|
||
func (s *Service) onlineGameSession(charID uint16) (*gameSession, bool) {
|
||
if s == nil || charID == 0 {
|
||
return nil, false
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
session, ok := s.gameSessions[charID]
|
||
return session, ok && session != nil
|
||
}
|
||
|
||
func (s *Service) boundGameSessionCharacterSnapshot(session *gameSession) (boundGameSessionCharacter, bool) {
|
||
if s == nil || session == nil {
|
||
return boundGameSessionCharacter{}, false
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
characterID := session.selectedCharacterID
|
||
if characterID == 0 || s.gameSessions[characterID] != session {
|
||
return boundGameSessionCharacter{}, false
|
||
}
|
||
return boundGameSessionCharacter{
|
||
session: session,
|
||
character: characterID,
|
||
generation: session.characterGeneration,
|
||
}, true
|
||
}
|
||
|
||
func (s *Service) onlineGameSessionCharacterSnapshot(characterID uint16) (boundGameSessionCharacter, bool) {
|
||
if s == nil || characterID == 0 {
|
||
return boundGameSessionCharacter{}, false
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
session := s.gameSessions[characterID]
|
||
if session == nil || session.selectedCharacterID != characterID {
|
||
return boundGameSessionCharacter{}, false
|
||
}
|
||
return boundGameSessionCharacter{
|
||
session: session,
|
||
character: characterID,
|
||
generation: session.characterGeneration,
|
||
}, true
|
||
}
|
||
|
||
func (s *Service) boundGameSessionCharacterCurrent(identity boundGameSessionCharacter) bool {
|
||
if s == nil || identity.session == nil || identity.character == 0 {
|
||
return false
|
||
}
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.gameSessions[identity.character] == identity.session &&
|
||
identity.session.selectedCharacterID == identity.character &&
|
||
identity.session.characterGeneration == identity.generation
|
||
}
|