合并本地领先源码:旧UI、GM疲劳燃烧、白金徽章孔、宠物修复、支援兵普攻开关

This commit is contained in:
hanfuit committed 2026-11-04 19:20:49 +08:00
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)
}