Files
90CNServer/server/internal/modules/dnf/companion/team.go
T

235 lines
6.3 KiB
Go

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
}