package dnfbridge import ( "context" "encoding/binary" "os" "strconv" "strings" "time" dnfcompanion "longheng.io/server/internal/modules/dnf/companion" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) // The teammate squad is the default support-soldier entry shape. It can be // disabled per deployment with DNF_COMPANION_TEAM_MODE=0; the GM settings stay // authoritative for which characters are preferred, but the server fills the // remaining slots from the same account at dungeon entry. const currentDungeonCompanionTeamModeEnv = "DNF_COMPANION_TEAM_MODE" const currentDungeonCompanionTeamRole = "teammate" func currentDungeonCompanionTeamModeEnabled() bool { switch strings.ToLower(strings.TrimSpace(os.Getenv(currentDungeonCompanionTeamModeEnv))) { case "0", "off", "false", "no": return false default: return true } } // autoMatchCurrentDungeonCompanionSources expands the operator's saved // selection into a frozen teammate roster for this dungeon. // // This is a read-only matchmaking step: no source character is modified and no // session is created for it. The actual snapshot/AIC/projection pipeline still // runs per selected source exactly as before, so a bad or deleted character // fails on its own row instead of aborting the whole team. func (s *Service) autoMatchCurrentDungeonCompanionSources( ctx context.Context, repos dnfrepo.Group, accountID string, ownerCharacterID uint16, configured []string, ) ([]string, []dnfcompanion.TeamSkip, bool) { if s == nil || repos.Character == nil || strings.TrimSpace(accountID) == "" || !currentDungeonCompanionTeamModeEnabled() { return configured, nil, false } records, err := repos.Character.ListByAccount(ctx, accountID, dnfrepo.MaxCharacterSlotLimit) if err != nil { s.logPacketEvent("dnf-companion-team-match-deferred", "owner_character_id", ownerCharacterID, "reason", "character_list_unavailable", "error", err) return configured, nil, false } ownerLevel := 0 if owner, ownerFound, ownerErr := repos.Character.Load(ctx, strconv.FormatUint(uint64(ownerCharacterID), 10)); ownerErr == nil && ownerFound { ownerLevel = owner.Level } levelFloor := ownerLevel - 10 if levelFloor < 1 { levelFloor = 1 } candidates := make([]dnfcompanion.TeamCandidate, 0, len(records)) for _, record := range records { id := numericCharacterID(record) if id <= 0 || id >= int(^uint16(0)) { continue } if ownerLevel > 0 && record.Level > 0 && record.Level < levelFloor { // A level-21 character in a level-90 party's dungeon entry preflight // makes the whole party fail the level gate. Do not auto-fill with a // character the dungeon will reject. s.logPacketEvent("dnf-companion-team-match-level-skipped", "owner_character_id", ownerCharacterID, "candidate_character_id", id, "candidate_level", record.Level, "owner_level", ownerLevel, "level_floor", levelFloor) continue } session, online := s.onlineGameSession(uint16(id)) if online && session != nil && session.companionHeadless { // Our own headless presence is not a competing real player. Without // this, every later auto-match (preflight/dungeon entry) excludes // the volunteers it just logged in. online = false } candidates = append(candidates, dnfcompanion.TeamCandidate{ CharacterID: strconv.Itoa(id), Level: record.Level, Job: strings.TrimSpace(record.Job), Slot: record.Slot, Online: online, Deleted: record.Stats["delete_flag"] != 0, }) } match := dnfcompanion.MatchTeam( strconv.FormatUint(uint64(ownerCharacterID), 10), configured, candidates, dnfcompanion.TeamMaxCompanions, ) skipped := make([]string, 0, len(match.Skipped)) for _, entry := range match.Skipped { if len(skipped) >= 16 { break } skipped = append(skipped, entry.CharacterID+":"+entry.Reason) } s.logPacketEvent("dnf-companion-team-match", "owner_character_id", ownerCharacterID, "configured", strings.Join(configured, ","), "selected", strings.Join(match.Selected, ","), "selected_count", len(match.Selected), "skipped", strings.Join(skipped, ","), "limit", dnfcompanion.TeamMaxCompanions) return match.Selected, match.Skipped, true } func currentDungeonCompanionTeamSourceIDs(run *currentDungeonCompanionRun) []uint16 { if run == nil { return nil } out := make([]uint16, 0, len(run.Sources)) for _, entry := range run.Sources { if entry.Ready { out = append(out, entry.SourceCharacterID) } } if len(out) == 0 && run.Ready && run.SourceCharacterID != 0 { out = append(out, run.SourceCharacterID) } return out } func currentDungeonCompanionTeamRevision(run *currentDungeonCompanionRun) uint64 { if run == nil { return 1 } if len(run.Body) >= currentDungeonCompanionProjectionHeaderSize { if revision := binary.LittleEndian.Uint64(run.Body[8:16]); revision != 0 { return revision } } for _, entry := range run.Sources { if len(entry.Body) >= currentDungeonCompanionProjectionHeaderSize { if revision := binary.LittleEndian.Uint64(entry.Body[8:16]); revision != 0 { return revision } } } return 1 } func (s *Service) markCurrentDungeonCompanionTeamOnline( session *gameSession, run *currentDungeonCompanionRun, source string, ) { if s == nil || run == nil || !run.Ready { return } run.Online = true run.OnlineAt = s.gameplayNow() run.OfflineAt = time.Time{} run.OfflineReason = "" run.LogoutSent = false s.logGameEvent(session, "game-dungeon-companion-team-online", "source", source, "team_mode", run.TeamMode, "team_leader_id", run.TeamLeaderID, "team_size", run.TeamSize, "ready_count", currentDungeonCompanionReadyCount(run), "source_character_ids", uint16ListLog(currentDungeonCompanionTeamSourceIDs(run)), "online_at", run.OnlineAt) } // sendCurrentDungeonCompanionTeamOffline publishes the explicit present=0 // projection. The client clears every queued/live support actor on that body, // so one packet is enough for the whole squad and it is safe to call from every // dungeon boundary: the run's LogoutSent flag makes it idempotent. func (s *Service) sendCurrentDungeonCompanionTeamOffline( session *gameSession, runtime *runtimeDungeonState, reason string, ) { if s == nil || session == nil || runtime == nil { return } run := runtime.Companion if run == nil || !run.Ready || run.LogoutSent { return } run.LogoutSent = true run.Online = false run.OfflineAt = s.gameplayNow() run.OfflineReason = strings.TrimSpace(reason) // The dungeon party frame goes away; the synthetic friend sessions do not. s.clearCurrentDungeonCompanionNativeParty(session, run) ids := currentDungeonCompanionTeamSourceIDs(run) body := buildCurrentDungeonCompanionOfflineBody(currentDungeonCompanionTeamRevision(run)) s.logGameEvent(session, "game-dungeon-companion-team-offline", "reason", run.OfflineReason, "team_mode", run.TeamMode, "team_leader_id", run.TeamLeaderID, "team_size", run.TeamSize, "offline_count", len(ids), "source_character_ids", uint16ListLog(ids), "offline_at", run.OfflineAt, "private_opcode", currentDungeonCompanionProjectionOpcode, "body_len", len(body)) if err := s.sendGameUpperRawClass( session, currentDungeonCompanionProjectionOpcode, body, 0, ); err != nil { s.logGameEvent(session, "game-dungeon-companion-team-offline-send-failed", "reason", run.OfflineReason, "error", err) } } func (s *Service) sendCurrentDungeonCompanionTeamOfflineCurrent( session *gameSession, reason string, ) { if s == nil || session == nil { return } session.dungeon.mu.Lock() runtime := session.dungeon.runtime session.dungeon.mu.Unlock() s.sendCurrentDungeonCompanionTeamOffline(session, runtime, reason) } func uint16ListLog(values []uint16) string { if len(values) == 0 { return "" } var builder strings.Builder for index, value := range values { if index != 0 { builder.WriteByte(',') } builder.WriteString(strconv.Itoa(int(value))) } return builder.String() } // runtimeDungeonCompanionCurrent is the compare step used by exit paths that // opportunistically publish the logout before taking session.dungeon.mu. The // final authority remains retireCurrentDungeonRuntimeLocked; this only avoids // sending a stale run's packet when a newer dungeon already replaced it. func (s *Service) runtimeDungeonCompanionCurrent( session *gameSession, runtime *runtimeDungeonState, ) bool { if s == nil || session == nil || runtime == nil { return false } session.dungeon.mu.Lock() defer session.dungeon.mu.Unlock() return session.dungeon.runtime == runtime }