合并本地领先源码:旧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,268 @@
package dnfbridge
import (
"context"
"encoding/binary"
"os"
"strconv"
"strings"
"time"
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// The teammate squad is the default support-soldier entry shape. It can be
// disabled per deployment with DNF_COMPANION_TEAM_MODE=0; the GM settings stay
// authoritative for which characters are preferred, but the server fills the
// remaining slots from the same account at dungeon entry.
const currentDungeonCompanionTeamModeEnv = "DNF_COMPANION_TEAM_MODE"
const currentDungeonCompanionTeamRole = "teammate"
func currentDungeonCompanionTeamModeEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentDungeonCompanionTeamModeEnv))) {
case "0", "off", "false", "no":
return false
default:
return true
}
}
// autoMatchCurrentDungeonCompanionSources expands the operator's saved
// selection into a frozen teammate roster for this dungeon.
//
// This is a read-only matchmaking step: no source character is modified and no
// session is created for it. The actual snapshot/AIC/projection pipeline still
// runs per selected source exactly as before, so a bad or deleted character
// fails on its own row instead of aborting the whole team.
func (s *Service) autoMatchCurrentDungeonCompanionSources(
ctx context.Context,
repos dnfrepo.Group,
accountID string,
ownerCharacterID uint16,
configured []string,
) ([]string, []dnfcompanion.TeamSkip, bool) {
if s == nil || repos.Character == nil || strings.TrimSpace(accountID) == "" ||
!currentDungeonCompanionTeamModeEnabled() {
return configured, nil, false
}
records, err := repos.Character.ListByAccount(ctx, accountID, dnfrepo.MaxCharacterSlotLimit)
if err != nil {
s.logPacketEvent("dnf-companion-team-match-deferred",
"owner_character_id", ownerCharacterID,
"reason", "character_list_unavailable",
"error", err)
return configured, nil, false
}
ownerLevel := 0
if owner, ownerFound, ownerErr := repos.Character.Load(ctx, strconv.FormatUint(uint64(ownerCharacterID), 10)); ownerErr == nil && ownerFound {
ownerLevel = owner.Level
}
levelFloor := ownerLevel - 10
if levelFloor < 1 {
levelFloor = 1
}
candidates := make([]dnfcompanion.TeamCandidate, 0, len(records))
for _, record := range records {
id := numericCharacterID(record)
if id <= 0 || id >= int(^uint16(0)) {
continue
}
if ownerLevel > 0 && record.Level > 0 && record.Level < levelFloor {
// A level-21 character in a level-90 party's dungeon entry preflight
// makes the whole party fail the level gate. Do not auto-fill with a
// character the dungeon will reject.
s.logPacketEvent("dnf-companion-team-match-level-skipped",
"owner_character_id", ownerCharacterID,
"candidate_character_id", id,
"candidate_level", record.Level,
"owner_level", ownerLevel,
"level_floor", levelFloor)
continue
}
session, online := s.onlineGameSession(uint16(id))
if online && session != nil && session.companionHeadless {
// Our own headless presence is not a competing real player. Without
// this, every later auto-match (preflight/dungeon entry) excludes
// the volunteers it just logged in.
online = false
}
candidates = append(candidates, dnfcompanion.TeamCandidate{
CharacterID: strconv.Itoa(id),
Level: record.Level,
Job: strings.TrimSpace(record.Job),
Slot: record.Slot,
Online: online,
Deleted: record.Stats["delete_flag"] != 0,
})
}
match := dnfcompanion.MatchTeam(
strconv.FormatUint(uint64(ownerCharacterID), 10),
configured,
candidates,
dnfcompanion.TeamMaxCompanions,
)
skipped := make([]string, 0, len(match.Skipped))
for _, entry := range match.Skipped {
if len(skipped) >= 16 {
break
}
skipped = append(skipped, entry.CharacterID+":"+entry.Reason)
}
s.logPacketEvent("dnf-companion-team-match",
"owner_character_id", ownerCharacterID,
"configured", strings.Join(configured, ","),
"selected", strings.Join(match.Selected, ","),
"selected_count", len(match.Selected),
"skipped", strings.Join(skipped, ","),
"limit", dnfcompanion.TeamMaxCompanions)
return match.Selected, match.Skipped, true
}
func currentDungeonCompanionTeamSourceIDs(run *currentDungeonCompanionRun) []uint16 {
if run == nil {
return nil
}
out := make([]uint16, 0, len(run.Sources))
for _, entry := range run.Sources {
if entry.Ready {
out = append(out, entry.SourceCharacterID)
}
}
if len(out) == 0 && run.Ready && run.SourceCharacterID != 0 {
out = append(out, run.SourceCharacterID)
}
return out
}
func currentDungeonCompanionTeamRevision(run *currentDungeonCompanionRun) uint64 {
if run == nil {
return 1
}
if len(run.Body) >= currentDungeonCompanionProjectionHeaderSize {
if revision := binary.LittleEndian.Uint64(run.Body[8:16]); revision != 0 {
return revision
}
}
for _, entry := range run.Sources {
if len(entry.Body) >= currentDungeonCompanionProjectionHeaderSize {
if revision := binary.LittleEndian.Uint64(entry.Body[8:16]); revision != 0 {
return revision
}
}
}
return 1
}
func (s *Service) markCurrentDungeonCompanionTeamOnline(
session *gameSession,
run *currentDungeonCompanionRun,
source string,
) {
if s == nil || run == nil || !run.Ready {
return
}
run.Online = true
run.OnlineAt = s.gameplayNow()
run.OfflineAt = time.Time{}
run.OfflineReason = ""
run.LogoutSent = false
s.logGameEvent(session, "game-dungeon-companion-team-online",
"source", source,
"team_mode", run.TeamMode,
"team_leader_id", run.TeamLeaderID,
"team_size", run.TeamSize,
"ready_count", currentDungeonCompanionReadyCount(run),
"source_character_ids", uint16ListLog(currentDungeonCompanionTeamSourceIDs(run)),
"online_at", run.OnlineAt)
}
// sendCurrentDungeonCompanionTeamOffline publishes the explicit present=0
// projection. The client clears every queued/live support actor on that body,
// so one packet is enough for the whole squad and it is safe to call from every
// dungeon boundary: the run's LogoutSent flag makes it idempotent.
func (s *Service) sendCurrentDungeonCompanionTeamOffline(
session *gameSession,
runtime *runtimeDungeonState,
reason string,
) {
if s == nil || session == nil || runtime == nil {
return
}
run := runtime.Companion
if run == nil || !run.Ready || run.LogoutSent {
return
}
run.LogoutSent = true
run.Online = false
run.OfflineAt = s.gameplayNow()
run.OfflineReason = strings.TrimSpace(reason)
// The dungeon party frame goes away; the synthetic friend sessions do not.
s.clearCurrentDungeonCompanionNativeParty(session, run)
ids := currentDungeonCompanionTeamSourceIDs(run)
body := buildCurrentDungeonCompanionOfflineBody(currentDungeonCompanionTeamRevision(run))
s.logGameEvent(session, "game-dungeon-companion-team-offline",
"reason", run.OfflineReason,
"team_mode", run.TeamMode,
"team_leader_id", run.TeamLeaderID,
"team_size", run.TeamSize,
"offline_count", len(ids),
"source_character_ids", uint16ListLog(ids),
"offline_at", run.OfflineAt,
"private_opcode", currentDungeonCompanionProjectionOpcode,
"body_len", len(body))
if err := s.sendGameUpperRawClass(
session,
currentDungeonCompanionProjectionOpcode,
body,
0,
); err != nil {
s.logGameEvent(session, "game-dungeon-companion-team-offline-send-failed",
"reason", run.OfflineReason,
"error", err)
}
}
func (s *Service) sendCurrentDungeonCompanionTeamOfflineCurrent(
session *gameSession,
reason string,
) {
if s == nil || session == nil {
return
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
session.dungeon.mu.Unlock()
s.sendCurrentDungeonCompanionTeamOffline(session, runtime, reason)
}
func uint16ListLog(values []uint16) string {
if len(values) == 0 {
return ""
}
var builder strings.Builder
for index, value := range values {
if index != 0 {
builder.WriteByte(',')
}
builder.WriteString(strconv.Itoa(int(value)))
}
return builder.String()
}
// runtimeDungeonCompanionCurrent is the compare step used by exit paths that
// opportunistically publish the logout before taking session.dungeon.mu. The
// final authority remains retireCurrentDungeonRuntimeLocked; this only avoids
// sending a stale run's packet when a newer dungeon already replaced it.
func (s *Service) runtimeDungeonCompanionCurrent(
session *gameSession,
runtime *runtimeDungeonState,
) bool {
if s == nil || session == nil || runtime == nil {
return false
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
return session.dungeon.runtime == runtime
}