合并本地领先源码:旧UI、GM疲劳燃烧、白金徽章孔、宠物修复、支援兵普攻开关
This commit is contained in:
1 parent
f2debaf9bf
commit
70b3c5a6dc
57 files changed
+4256
-30
No files matched your search
@@ -0,0 +1,234 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user