package companion import ( "sort" "strconv" "strings" ) // TeamMaxCompanions is the number of support soldiers that may enter with the // owner. The current client's ordinary party has four slots: the owner plus // three companions. Keeping the limit here (rather than in the bridge) makes // every matcher, GM preflight and status projection agree. const TeamMaxCompanions = 3 // DefaultTeamAIProfile is the fallback policy for an auto-matched teammate // that has no per-character GM profile. Team members must actually join the // fight, so the default is the aggressive AIC variant; operators can still // override any character through ai_profiles (assist/aggressive). func DefaultTeamAIProfile(classID uint16) AIProfile { profile := DefaultAIProfile(classID) profile.Behavior = "aggressive" return profile } // TeamCandidate is the detached roster row used by the automatic matcher. It // deliberately contains no session, inventory or mutable character pointers: // the bridge reads one consistent repository list and passes only identity, // level, job and online state here. type TeamCandidate struct { CharacterID string Level int Job string Slot int Online bool Deleted bool } // TeamSkip explains why a candidate did not enter the frozen run. It is // evidence for the GM status and packet log, not a client-facing error. type TeamSkip struct { CharacterID string Reason string } // TeamMatch is the deterministic result of one dungeon-entry match. type TeamMatch struct { Selected []string Skipped []TeamSkip } const ( TeamSkipOwner = "source_is_owner" TeamSkipOnline = "source_online" TeamSkipUnavailable = "source_unavailable" TeamSkipLevel = "level_unsupported" TeamSkipDeleted = "source_deleted" TeamSkipInvalid = "source_invalid" TeamSkipJob = "job_unsupported" TeamSkipPartyFull = "party_full" ) // MatchTeam forms the teammate roster for one dungeon. // // Rules, in order: // 1. The GM's explicit selection is authoritative and keeps its order. // 2. Remaining slots are filled automatically from the same account's offline, // non-deleted characters. // 3. Automatic picks prefer higher level first, then one character per job // before duplicating a job, then the lower slot/id for stable output. // // The result is frozen by the caller. This function is pure so the selection // cannot depend on map iteration order or on repository timing. func MatchTeam(ownerID string, configured []string, candidates []TeamCandidate, limit int) TeamMatch { ownerID = strings.TrimSpace(ownerID) if limit <= 0 || limit > TeamMaxCompanions { limit = TeamMaxCompanions } byID := make(map[string]TeamCandidate, len(candidates)) for _, candidate := range candidates { id := strings.TrimSpace(candidate.CharacterID) if !validCharacterID(id) { continue } if _, exists := byID[id]; exists { continue } candidate.CharacterID = id candidate.Job = strings.TrimSpace(candidate.Job) byID[id] = candidate } result := TeamMatch{ Selected: make([]string, 0, limit), Skipped: make([]TeamSkip, 0, 4), } selected := make(map[string]struct{}, limit) skip := func(id, reason string) { if len(result.Skipped) >= 16 { return } result.Skipped = append(result.Skipped, TeamSkip{CharacterID: id, Reason: reason}) } selectID := func(id string) bool { if _, exists := selected[id]; exists { return true } if len(result.Selected) >= limit { return false } selected[id] = struct{}{} result.Selected = append(result.Selected, id) return true } // Phase 1: preserve the operator's manual choice. for _, raw := range configured { id := strings.TrimSpace(raw) if !validCharacterID(id) { skip(id, TeamSkipInvalid) continue } if _, exists := selected[id]; exists { continue } if id == ownerID { skip(id, TeamSkipOwner) continue } candidate, found := byID[id] if !found { skip(id, TeamSkipUnavailable) continue } if reason := teamCandidateReason(candidate); reason != "" { skip(id, reason) continue } if !selectID(id) { skip(id, TeamSkipPartyFull) } } // Phase 2: auto-fill. Sorting is made total (level, slot, numeric id) so a // database returning rows in a different order cannot change the roster. available := make([]TeamCandidate, 0, len(byID)) for id, candidate := range byID { if id == ownerID { continue } if _, exists := selected[id]; exists { continue } if teamCandidateReason(candidate) != "" { continue } available = append(available, candidate) } sort.Slice(available, func(i, j int) bool { if available[i].Level != available[j].Level { return available[i].Level > available[j].Level } if available[i].Slot != available[j].Slot { return available[i].Slot < available[j].Slot } return teamNumericID(available[i].CharacterID) < teamNumericID(available[j].CharacterID) }) usedJobs := make(map[string]struct{}, limit) for id := range selected { if candidate, ok := byID[id]; ok { usedJobs[teamJobKey(candidate.Job)] = struct{}{} } } // First pass: one character per job keeps the team varied. for _, candidate := range available { if len(result.Selected) >= limit { break } job := teamJobKey(candidate.Job) if job == "" { continue } if _, exists := usedJobs[job]; exists { continue } if selectID(candidate.CharacterID) { usedJobs[job] = struct{}{} } } // Second pass: fill the remaining slots by level/slot/id. for _, candidate := range available { if len(result.Selected) >= limit { break } if _, exists := selected[candidate.CharacterID]; exists { continue } selectID(candidate.CharacterID) } return result } func teamCandidateReason(candidate TeamCandidate) string { if candidate.Deleted { return TeamSkipDeleted } if candidate.Level < 1 || candidate.Level > 90 { return TeamSkipLevel } if candidate.Online { return TeamSkipOnline } if _, err := strconv.ParseUint(strings.TrimSpace(candidate.Job), 10, 8); err != nil { return TeamSkipJob } return "" } func teamJobKey(job string) string { parsed, err := strconv.ParseUint(strings.TrimSpace(job), 10, 8) if err != nil { return "" } return strconv.FormatUint(parsed, 10) } func teamNumericID(id string) int { parsed, err := strconv.Atoi(strings.TrimSpace(id)) if err != nil { return int(^uint(0) >> 1) } return parsed }