Files

565 lines
25 KiB
Go
Raw Permalink 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.
// 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
// virtual marks a companion presence session. Such a session is not in
// Service.gameSessions (it has no socket), so the liveness check below must
// consult Service.companionSessions instead.
virtual bool
}
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")
}
if oldID := session.selectedCharacterID; oldID != 0 && oldID != charID {
session.dungeon.mu.Lock()
pendingCompanionRuntime := session.dungeon.runtime
session.dungeon.mu.Unlock()
s.sendCurrentDungeonCompanionTeamOffline(
session, pendingCompanionRuntime, "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
// A real client binding this character always retires the synthetic
// volunteer presence row, so the friend list cannot claim two online copies.
s.dropCompanionPresenceCharacter(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)
s.sendCurrentDungeonCompanionTeamOfflineCurrent(session, "disconnected")
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
}
if identity.virtual {
return s.companionSessionCurrent(identity)
}
s.mu.Lock()
defer s.mu.Unlock()
return s.gameSessions[identity.character] == identity.session &&
identity.session.selectedCharacterID == identity.character &&
identity.session.characterGeneration == identity.generation
}