package dnfbridge import ( "context" "os" "strconv" "strings" "longheng.io/server/internal/modules/dnf/alignedcmd" "longheng.io/server/internal/modules/dnf/dnfenum" "longheng.io/server/internal/modules/dnf/party" dnffriend "longheng.io/server/internal/modules/dnf/unitedfriend" ) // Volunteers are persistent social characters: the native friend list and the // native party frame both resolve them through a synthetic presence session. // The session has no socket; it exists only while the server process is up so // the operator's friend window keeps seeing the volunteers as online, including // after a dungeon ends. const ( currentCompanionNativePartyEnv = "DNF_COMPANION_NATIVE_PARTY" // Presence of this file disables the native op9/op153 frame without a // restart. The friend presence stays online; only the party window is // suppressed, which is the fast rollback if a client build rejects the // synthetic roster. currentCompanionNativePartyDisableFile = "companion_native_party.disabled" currentCompanionVirtualPrefix = "companion-presence-" ) func currentCompanionNativePartyEnabled() bool { switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionNativePartyEnv))) { case "0", "off", "false", "no": return false } if _, err := os.Stat(currentCompanionNativePartyDisableFile); err == nil { return false } return true } func (s *Service) companionPresenceSession(characterID uint16) (*gameSession, bool) { if s == nil || characterID == 0 { return nil, false } // A real client always wins over a synthetic presence row. if real, online := s.onlineGameSession(characterID); online && real != nil { return real, false } s.companionPresenceMu.Lock() defer s.companionPresenceMu.Unlock() session := s.companionSessions[characterID] return session, session != nil } // companionPresenceFriendChannel returns the friend-row channel for a synthetic // volunteer. It copies the field under companionPresenceMu because ensure may // update the channel when the owner moves. func (s *Service) companionPresenceFriendChannel(characterID uint16) (uint16, bool) { if s == nil || characterID == 0 { return 0, false } if real, online := s.onlineGameSession(characterID); online && real != nil { return 0, false } s.companionPresenceMu.Lock() defer s.companionPresenceMu.Unlock() session := s.companionSessions[characterID] if session == nil { return 0, false } return uint16(session.channel.ID), true } func (s *Service) companionSessionSnapshot(characterID uint16) (boundGameSessionCharacter, bool) { session, ok := s.companionPresenceSession(characterID) if !ok || session == nil || session.companionVirtual == false { return boundGameSessionCharacter{}, false } return boundGameSessionCharacter{ session: session, character: characterID, generation: session.characterGeneration, virtual: true, }, true } func (s *Service) companionSessionCurrent(identity boundGameSessionCharacter) bool { if s == nil || identity.session == nil || identity.character == 0 { return false } s.companionPresenceMu.Lock() defer s.companionPresenceMu.Unlock() return s.companionSessions[identity.character] == identity.session && identity.session.selectedCharacterID == identity.character && identity.session.characterGeneration == identity.generation } func (s *Service) nextCompanionGeneration() uint64 { s.mu.Lock() defer s.mu.Unlock() s.nextGameSessionGeneration++ if s.nextGameSessionGeneration == 0 { s.nextGameSessionGeneration++ } return s.nextGameSessionGeneration } // ensureCompanionPresenceForSession makes the currently configured volunteers // visible in the native friend window for this owner. It is called at the login // scene tail (before op273) and again at dungeon entry. func (s *Service) ensureCompanionPresenceForSession(ctx context.Context, session *gameSession) { if s == nil || session == nil || session.selectedCharacterID == 0 || !currentDungeonCompanionTeamModeEnabled() { return } if ctx == nil { ctx = context.Background() } repos, ok := s.repositoryGroup() if !ok || repos.Character == nil || repos.Settings == nil { return } ownerID := session.selectedCharacterID ownerKey := strconv.FormatUint(uint64(ownerID), 10) cfg, _, _, err := s.loadCurrentDungeonCompanionConfig( ctx, ownerKey, strings.TrimSpace(s.accountIDForSession(session)), repos) if err != nil { s.logGameEvent(session, "game-companion-presence-config-failed", "owner_character_id", ownerID, "error", err) return } if !cfg.Enabled { s.dropCompanionPresenceForOwner(ownerID) return } sources := cfg.Sources() accountID := strings.TrimSpace(s.accountIDForSession(session)) if accountID == "" { owner, found, loadErr := repos.Character.Load(ctx, ownerKey) if loadErr != nil || !found { return } accountID = strings.TrimSpace(owner.AccountID) } matched, _, applied := s.autoMatchCurrentDungeonCompanionSources( ctx, repos, accountID, ownerID, sources) if applied { sources = matched } if len(sources) == 0 { return } ownerName := "" if _, name, _, found := s.selectedCharacterForEnter(ctx, session); found { ownerName = name } s.ensureCompanionFriendRelations(ctx, session, ownerName, sources) if currentCompanionHeadlessEnabled() { ids := s.ensureHeadlessCompanionSessions(ctx, session, sources, accountID) s.ensureHeadlessCompanionParty(session, ids) } else { s.dropHeadlessCompanionSessionsForOwner(ownerID) s.ensureCompanionVirtualSessions(ctx, session, sources, accountID) } s.logGameEvent(session, "game-companion-presence-online", "owner_character_id", ownerID, "source_character_ids", strings.Join(sources, ","), "count", len(sources)) } func (s *Service) ensureCompanionVirtualSessions( ctx context.Context, ownerSession *gameSession, sources []string, accountID string, ) { if s == nil || ownerSession == nil || len(sources) == 0 { return } repos, ok := s.repositoryGroup() if !ok || repos.Character == nil { return } for _, raw := range sources { id, ok := currentDungeonCompanionProjectionSourceIDValue(raw) if !ok || id == ownerSession.selectedCharacterID { continue } if _, realOnline := s.onlineGameSession(id); realOnline { continue } character, found, err := repos.Character.Load(ctx, strconv.FormatUint(uint64(id), 10)) if err != nil || !found || character.Stats["delete_flag"] != 0 || strings.TrimSpace(character.Name) == "" { continue } // Allocate the generation before taking companionPresenceMu: the // generation owner lives under Service.mu, and bindGameSessionCharacter // holds Service.mu while dropping a virtual session. generation := s.nextCompanionGeneration() s.companionPresenceMu.Lock() if s.companionSessions == nil { s.companionSessions = make(map[uint16]*gameSession) } session := s.companionSessions[id] if session == nil { session = &gameSession{ connID: currentCompanionVirtualPrefix + strconv.FormatUint(uint64(id), 10), accountID: accountID, selectedCharacterID: id, characterGeneration: generation, channel: ownerSession.channel, companionVirtual: true, companionOwnerID: ownerSession.selectedCharacterID, } s.companionSessions[id] = session } else { session.channel = ownerSession.channel session.companionOwnerID = ownerSession.selectedCharacterID session.accountID = accountID } s.companionPresenceMu.Unlock() } } func (s *Service) ensureCompanionFriendRelations( ctx context.Context, session *gameSession, ownerName string, sources []string, ) { if s == nil || session == nil || strings.TrimSpace(ownerName) == "" { return } repos, ok := s.repositoryGroup() if !ok || repos.UnitedFriend == nil || repos.Character == nil { return } owner, err := dnffriend.NewOwner(repos, nil) if err != nil { return } ownerID := session.selectedCharacterID s.friendMu.Lock() defer s.friendMu.Unlock() for _, raw := range sources { id, ok := currentDungeonCompanionProjectionSourceIDValue(raw) if !ok || id == ownerID { continue } character, found, loadErr := repos.Character.Load(ctx, strconv.FormatUint(uint64(id), 10)) if loadErr != nil || !found || strings.TrimSpace(character.Name) == "" { continue } if _, addErr := owner.Add(ctx, ownerName, ownerID, character.Name, id); addErr != nil { if addErr != dnffriend.ErrFull { s.logGameEvent(session, "game-companion-friend-add-failed", "owner_character_id", ownerID, "source_character_id", id, "error", addErr) } continue } s.logGameEvent(session, "game-companion-friend-ensured", "owner_character_id", ownerID, "source_character_id", id, "name", character.Name) } } func (s *Service) dropCompanionPresenceCharacter(characterID uint16) { if s == nil || characterID == 0 { return } s.companionPresenceMu.Lock() defer s.companionPresenceMu.Unlock() delete(s.companionSessions, characterID) } func (s *Service) dropCompanionPresenceForOwner(ownerID uint16) { if s == nil || ownerID == 0 { return } s.companionPresenceMu.Lock() defer s.companionPresenceMu.Unlock() for id, session := range s.companionSessions { if session != nil && session.companionOwnerID == ownerID { delete(s.companionSessions, id) } } } func companionPartyID(ownerID uint16) uint16 { id := uint16(0xE000) | (ownerID & 0x0FFF) if id == 0 || id == 0xFFFF { id = 0xE001 } return id } func (s *Service) buildCompanionPartyState(session *gameSession, run *currentDungeonCompanionRun) alignedcmd.PartyState { if s == nil || session == nil || run == nil { return alignedcmd.PartyState{} } state := alignedcmd.PartyState{ PartyID: int(companionPartyID(session.selectedCharacterID)), IsLeader: true, UserID: session.selectedCharacterID, UserState: 100, MaxMembers: 4, } state.Members = append(state.Members, alignedcmd.PartyMemberState{ UserID: session.selectedCharacterID, Slot: 0, UserState: 100, HPPercent: 100, MPPercent: 100, }) slot := byte(1) for _, entry := range run.Sources { if !entry.Ready || entry.SourceCharacterID == 0 { continue } if slot >= state.MaxMembers { break } state.Members = append(state.Members, alignedcmd.PartyMemberState{ UserID: entry.SourceCharacterID, Slot: slot, UserState: 100, HPPercent: 100, MPPercent: 100, }) slot++ } return state } // publishCurrentDungeonCompanionNativeParty builds the native op9 party frame // (kind-0 complete roster) followed by op153 HP/MP rows. It intentionally does // not send op11: the volunteers have no P2P socket, and a made-up endpoint // would leave the client stuck on "connecting". The scene actors themselves // still come from the support-soldier projection. func (s *Service) publishCurrentDungeonCompanionNativeParty(session *gameSession, runtime *runtimeDungeonState) { if s == nil || session == nil || runtime == nil || runtime.Companion == nil || currentCompanionHeadlessEnabled() || !currentCompanionNativePartyEnabled() { return } run := runtime.Companion if !run.Ready || len(run.Sources) == 0 { return } currentParty := runtimePartyStateSnapshot(session) if currentParty.PartyID > 0 && len(runtimePartyMembers(currentParty)) > 1 { s.logGameEvent(session, "game-companion-native-party-skipped", "owner_character_id", session.selectedCharacterID, "reason", "owner_already_in_native_party") return } if runtime.partyDungeonOwner != nil || isCurrentImplementedRaidChannel(session) { s.logGameEvent(session, "game-companion-native-party-skipped", "owner_character_id", session.selectedCharacterID, "reason", "party_or_raid_owner_has_priority") return } sources := make([]string, 0, len(run.Sources)) for _, entry := range run.Sources { if entry.Ready && entry.SourceCharacterID != 0 { sources = append(sources, strconv.FormatUint(uint64(entry.SourceCharacterID), 10)) } } s.ensureCompanionVirtualSessions( context.Background(), session, sources, strings.TrimSpace(s.accountIDForSession(session))) state := s.buildCompanionPartyState(session, run) if len(state.Members) <= 1 { return } sent, err := s.sendCurrentPartyActorFrameProjection( session, session, state, "companion_native_party") if err != nil { s.logGameEvent(session, "game-companion-native-party-failed", "owner_character_id", session.selectedCharacterID, "party_id", state.PartyID, "error", err) return } if !sent { s.logGameEvent(session, "game-companion-native-party-deferred", "owner_character_id", session.selectedCharacterID, "party_id", state.PartyID, "reason", "op9_projection_not_delivered") return } if err := s.sendRuntimePartyRealtimeInfoLocal(session, state); err != nil { s.logGameEvent(session, "game-companion-native-party-rows-failed", "owner_character_id", session.selectedCharacterID, "party_id", state.PartyID, "error", err) return } run.NativePartyID = uint16(state.PartyID) run.NativePartySent = true s.logGameEvent(session, "game-companion-native-party-sent", "owner_character_id", session.selectedCharacterID, "party_id", state.PartyID, "member_count", len(state.Members), "source_character_ids", uint16ListLog(currentDungeonCompanionTeamSourceIDs(run))) } // clearCurrentDungeonCompanionNativeParty empties only the party window; the // virtual sessions and friend rows stay online, which is the requested // post-dungeon state. func (s *Service) clearCurrentDungeonCompanionNativeParty(session *gameSession, run *currentDungeonCompanionRun) { if s == nil || session == nil || run == nil || !run.NativePartySent || run.NativePartyID == 0 { return } body := buildCurrentSceneOp9ActorRemovalBodyInContext( run.NativePartyID, currentPartySceneOwnerContext(session)) if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRecoverStamina), body, 0); err != nil { s.logGameEvent(session, "game-companion-native-party-clear-failed", "owner_character_id", session.selectedCharacterID, "party_id", run.NativePartyID, "error", err) } if err := s.sendGameUpperRawClassCodec( session, 0x0099, party.BuildEmptyRealtimeInfo(), 0, true); err != nil { s.logGameEvent(session, "game-companion-native-party-clear-rows-failed", "owner_character_id", session.selectedCharacterID, "party_id", run.NativePartyID, "error", err) } run.NativePartySent = false s.logGameEvent(session, "game-companion-native-party-cleared", "owner_character_id", session.selectedCharacterID, "party_id", run.NativePartyID, "friends_online", true) }