合并本地领先源码:旧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
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatchTeamKeepsManualSelectionAndFillsByLevelAndJob(t *testing.T) {
|
||||
candidates := []TeamCandidate{
|
||||
{CharacterID: "1", Level: 90, Job: "0", Slot: 0},
|
||||
{CharacterID: "2", Level: 90, Job: "12", Slot: 1},
|
||||
{CharacterID: "3", Level: 90, Job: "3", Slot: 2},
|
||||
{CharacterID: "4", Level: 90, Job: "0", Slot: 3},
|
||||
{CharacterID: "5", Level: 21, Job: "2", Slot: 4},
|
||||
}
|
||||
match := MatchTeam("1", []string{"4"}, candidates, 3)
|
||||
if want := []string{"4", "2", "3"}; !reflect.DeepEqual(match.Selected, want) {
|
||||
t.Fatalf("selected=%v want=%v", match.Selected, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchTeamSkipsOwnerOnlineDeletedLowLevelAndInvalid(t *testing.T) {
|
||||
candidates := []TeamCandidate{
|
||||
{CharacterID: "1", Level: 90, Job: "0", Slot: 0}, // owner
|
||||
{CharacterID: "2", Level: 90, Job: "0", Slot: 1, Online: true}, // online
|
||||
{CharacterID: "3", Level: 90, Job: "0", Slot: 2, Deleted: true},
|
||||
{CharacterID: "4", Level: 0, Job: "0", Slot: 3},
|
||||
{CharacterID: "5", Level: 90, Job: "x", Slot: 4},
|
||||
{CharacterID: "0", Level: 90, Job: "0", Slot: 5},
|
||||
{CharacterID: "6", Level: 90, Job: "1", Slot: 6},
|
||||
}
|
||||
match := MatchTeam("1", []string{"2", "3", "4", "5", "0", "99"}, candidates, 3)
|
||||
if want := []string{"6"}; !reflect.DeepEqual(match.Selected, want) {
|
||||
t.Fatalf("selected=%v want=%v", match.Selected, want)
|
||||
}
|
||||
assertSkip(t, match, "2") // online
|
||||
assertSkip(t, match, "3") // deleted
|
||||
assertSkip(t, match, "4") // low level
|
||||
assertSkip(t, match, "5") // unsupported job
|
||||
assertSkip(t, match, "99") // missing
|
||||
}
|
||||
|
||||
func TestMatchTeamCapsAtThreeAndIsDeterministic(t *testing.T) {
|
||||
candidates := []TeamCandidate{
|
||||
{CharacterID: "9", Level: 90, Job: "0", Slot: 9},
|
||||
{CharacterID: "7", Level: 90, Job: "2", Slot: 7},
|
||||
{CharacterID: "8", Level: 90, Job: "4", Slot: 8},
|
||||
{CharacterID: "6", Level: 90, Job: "1", Slot: 6},
|
||||
}
|
||||
first := MatchTeam("1", nil, candidates, 99)
|
||||
if len(first.Selected) != TeamMaxCompanions {
|
||||
t.Fatalf("selected=%v want %d entries", first.Selected, TeamMaxCompanions)
|
||||
}
|
||||
// Slot order wins inside the same-levelled, different-job set.
|
||||
if want := []string{"6", "7", "8"}; !reflect.DeepEqual(first.Selected, want) {
|
||||
t.Fatalf("selected=%v want=%v", first.Selected, want)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
again := MatchTeam("1", nil, candidates, 99)
|
||||
if !reflect.DeepEqual(again.Selected, first.Selected) {
|
||||
t.Fatalf("iteration %d selected=%v want=%v", i, again.Selected, first.Selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchTeamEmptyRosterReturnsNoSelection(t *testing.T) {
|
||||
match := MatchTeam("1", []string{"2"}, nil, 3)
|
||||
if len(match.Selected) != 0 {
|
||||
t.Fatalf("selected=%v want empty", match.Selected)
|
||||
}
|
||||
assertSkip(t, match, "2")
|
||||
}
|
||||
|
||||
func TestDefaultTeamAIProfileIsAggressiveAndJobScoped(t *testing.T) {
|
||||
profile := DefaultTeamAIProfile(12)
|
||||
if profile.Behavior != "aggressive" {
|
||||
t.Fatalf("behavior=%q want aggressive", profile.Behavior)
|
||||
}
|
||||
if profile.ClassID != 12 {
|
||||
t.Fatalf("class id=%d want 12", profile.ClassID)
|
||||
}
|
||||
if err := profile.Validate(); err != nil {
|
||||
t.Fatalf("default team profile is invalid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSkip(t *testing.T, match TeamMatch, id string) {
|
||||
t.Helper()
|
||||
for _, skipped := range match.Skipped {
|
||||
if skipped.CharacterID == id {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("candidate %s was not recorded as skipped: %+v", id, match.Skipped)
|
||||
}
|
||||
Reference in new issue
Block a user