235 lines
6.3 KiB
Go
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
|
|
}
|