DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,546 @@
// 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
}