合并本地领先源码:旧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,430 @@
package dnfbridge
import (
"context"
"os"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/party"
dnffriend "longheng.io/server/internal/modules/dnf/unitedfriend"
)
// Volunteers are persistent social characters: the native friend list and the
// native party frame both resolve them through a synthetic presence session.
// The session has no socket; it exists only while the server process is up so
// the operator's friend window keeps seeing the volunteers as online, including
// after a dungeon ends.
const (
currentCompanionNativePartyEnv = "DNF_COMPANION_NATIVE_PARTY"
// Presence of this file disables the native op9/op153 frame without a
// restart. The friend presence stays online; only the party window is
// suppressed, which is the fast rollback if a client build rejects the
// synthetic roster.
currentCompanionNativePartyDisableFile = "companion_native_party.disabled"
currentCompanionVirtualPrefix = "companion-presence-"
)
func currentCompanionNativePartyEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionNativePartyEnv))) {
case "0", "off", "false", "no":
return false
}
if _, err := os.Stat(currentCompanionNativePartyDisableFile); err == nil {
return false
}
return true
}
func (s *Service) companionPresenceSession(characterID uint16) (*gameSession, bool) {
if s == nil || characterID == 0 {
return nil, false
}
// A real client always wins over a synthetic presence row.
if real, online := s.onlineGameSession(characterID); online && real != nil {
return real, false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
session := s.companionSessions[characterID]
return session, session != nil
}
// companionPresenceFriendChannel returns the friend-row channel for a synthetic
// volunteer. It copies the field under companionPresenceMu because ensure may
// update the channel when the owner moves.
func (s *Service) companionPresenceFriendChannel(characterID uint16) (uint16, bool) {
if s == nil || characterID == 0 {
return 0, false
}
if real, online := s.onlineGameSession(characterID); online && real != nil {
return 0, false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
session := s.companionSessions[characterID]
if session == nil {
return 0, false
}
return uint16(session.channel.ID), true
}
func (s *Service) companionSessionSnapshot(characterID uint16) (boundGameSessionCharacter, bool) {
session, ok := s.companionPresenceSession(characterID)
if !ok || session == nil || session.companionVirtual == false {
return boundGameSessionCharacter{}, false
}
return boundGameSessionCharacter{
session: session,
character: characterID,
generation: session.characterGeneration,
virtual: true,
}, true
}
func (s *Service) companionSessionCurrent(identity boundGameSessionCharacter) bool {
if s == nil || identity.session == nil || identity.character == 0 {
return false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
return s.companionSessions[identity.character] == identity.session &&
identity.session.selectedCharacterID == identity.character &&
identity.session.characterGeneration == identity.generation
}
func (s *Service) nextCompanionGeneration() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
s.nextGameSessionGeneration++
if s.nextGameSessionGeneration == 0 {
s.nextGameSessionGeneration++
}
return s.nextGameSessionGeneration
}
// ensureCompanionPresenceForSession makes the currently configured volunteers
// visible in the native friend window for this owner. It is called at the login
// scene tail (before op273) and again at dungeon entry.
func (s *Service) ensureCompanionPresenceForSession(ctx context.Context, session *gameSession) {
if s == nil || session == nil || session.selectedCharacterID == 0 ||
!currentDungeonCompanionTeamModeEnabled() {
return
}
if ctx == nil {
ctx = context.Background()
}
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil || repos.Settings == nil {
return
}
ownerID := session.selectedCharacterID
ownerKey := strconv.FormatUint(uint64(ownerID), 10)
cfg, _, _, err := s.loadCurrentDungeonCompanionConfig(
ctx, ownerKey, strings.TrimSpace(s.accountIDForSession(session)), repos)
if err != nil {
s.logGameEvent(session, "game-companion-presence-config-failed",
"owner_character_id", ownerID, "error", err)
return
}
if !cfg.Enabled {
s.dropCompanionPresenceForOwner(ownerID)
return
}
sources := cfg.Sources()
accountID := strings.TrimSpace(s.accountIDForSession(session))
if accountID == "" {
owner, found, loadErr := repos.Character.Load(ctx, ownerKey)
if loadErr != nil || !found {
return
}
accountID = strings.TrimSpace(owner.AccountID)
}
matched, _, applied := s.autoMatchCurrentDungeonCompanionSources(
ctx, repos, accountID, ownerID, sources)
if applied {
sources = matched
}
if len(sources) == 0 {
return
}
ownerName := ""
if _, name, _, found := s.selectedCharacterForEnter(ctx, session); found {
ownerName = name
}
s.ensureCompanionFriendRelations(ctx, session, ownerName, sources)
if currentCompanionHeadlessEnabled() {
ids := s.ensureHeadlessCompanionSessions(ctx, session, sources, accountID)
s.ensureHeadlessCompanionParty(session, ids)
} else {
s.dropHeadlessCompanionSessionsForOwner(ownerID)
s.ensureCompanionVirtualSessions(ctx, session, sources, accountID)
}
s.logGameEvent(session, "game-companion-presence-online",
"owner_character_id", ownerID,
"source_character_ids", strings.Join(sources, ","),
"count", len(sources))
}
func (s *Service) ensureCompanionVirtualSessions(
ctx context.Context,
ownerSession *gameSession,
sources []string,
accountID string,
) {
if s == nil || ownerSession == nil || len(sources) == 0 {
return
}
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil {
return
}
for _, raw := range sources {
id, ok := currentDungeonCompanionProjectionSourceIDValue(raw)
if !ok || id == ownerSession.selectedCharacterID {
continue
}
if _, realOnline := s.onlineGameSession(id); realOnline {
continue
}
character, found, err := repos.Character.Load(ctx, strconv.FormatUint(uint64(id), 10))
if err != nil || !found || character.Stats["delete_flag"] != 0 ||
strings.TrimSpace(character.Name) == "" {
continue
}
// Allocate the generation before taking companionPresenceMu: the
// generation owner lives under Service.mu, and bindGameSessionCharacter
// holds Service.mu while dropping a virtual session.
generation := s.nextCompanionGeneration()
s.companionPresenceMu.Lock()
if s.companionSessions == nil {
s.companionSessions = make(map[uint16]*gameSession)
}
session := s.companionSessions[id]
if session == nil {
session = &gameSession{
connID: currentCompanionVirtualPrefix + strconv.FormatUint(uint64(id), 10),
accountID: accountID,
selectedCharacterID: id,
characterGeneration: generation,
channel: ownerSession.channel,
companionVirtual: true,
companionOwnerID: ownerSession.selectedCharacterID,
}
s.companionSessions[id] = session
} else {
session.channel = ownerSession.channel
session.companionOwnerID = ownerSession.selectedCharacterID
session.accountID = accountID
}
s.companionPresenceMu.Unlock()
}
}
func (s *Service) ensureCompanionFriendRelations(
ctx context.Context,
session *gameSession,
ownerName string,
sources []string,
) {
if s == nil || session == nil || strings.TrimSpace(ownerName) == "" {
return
}
repos, ok := s.repositoryGroup()
if !ok || repos.UnitedFriend == nil || repos.Character == nil {
return
}
owner, err := dnffriend.NewOwner(repos, nil)
if err != nil {
return
}
ownerID := session.selectedCharacterID
s.friendMu.Lock()
defer s.friendMu.Unlock()
for _, raw := range sources {
id, ok := currentDungeonCompanionProjectionSourceIDValue(raw)
if !ok || id == ownerID {
continue
}
character, found, loadErr := repos.Character.Load(ctx, strconv.FormatUint(uint64(id), 10))
if loadErr != nil || !found || strings.TrimSpace(character.Name) == "" {
continue
}
if _, addErr := owner.Add(ctx, ownerName, ownerID, character.Name, id); addErr != nil {
if addErr != dnffriend.ErrFull {
s.logGameEvent(session, "game-companion-friend-add-failed",
"owner_character_id", ownerID,
"source_character_id", id,
"error", addErr)
}
continue
}
s.logGameEvent(session, "game-companion-friend-ensured",
"owner_character_id", ownerID,
"source_character_id", id,
"name", character.Name)
}
}
func (s *Service) dropCompanionPresenceCharacter(characterID uint16) {
if s == nil || characterID == 0 {
return
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
delete(s.companionSessions, characterID)
}
func (s *Service) dropCompanionPresenceForOwner(ownerID uint16) {
if s == nil || ownerID == 0 {
return
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
for id, session := range s.companionSessions {
if session != nil && session.companionOwnerID == ownerID {
delete(s.companionSessions, id)
}
}
}
func companionPartyID(ownerID uint16) uint16 {
id := uint16(0xE000) | (ownerID & 0x0FFF)
if id == 0 || id == 0xFFFF {
id = 0xE001
}
return id
}
func (s *Service) buildCompanionPartyState(session *gameSession, run *currentDungeonCompanionRun) alignedcmd.PartyState {
if s == nil || session == nil || run == nil {
return alignedcmd.PartyState{}
}
state := alignedcmd.PartyState{
PartyID: int(companionPartyID(session.selectedCharacterID)),
IsLeader: true,
UserID: session.selectedCharacterID,
UserState: 100,
MaxMembers: 4,
}
state.Members = append(state.Members, alignedcmd.PartyMemberState{
UserID: session.selectedCharacterID, Slot: 0, UserState: 100, HPPercent: 100, MPPercent: 100,
})
slot := byte(1)
for _, entry := range run.Sources {
if !entry.Ready || entry.SourceCharacterID == 0 {
continue
}
if slot >= state.MaxMembers {
break
}
state.Members = append(state.Members, alignedcmd.PartyMemberState{
UserID: entry.SourceCharacterID, Slot: slot, UserState: 100, HPPercent: 100, MPPercent: 100,
})
slot++
}
return state
}
// publishCurrentDungeonCompanionNativeParty builds the native op9 party frame
// (kind-0 complete roster) followed by op153 HP/MP rows. It intentionally does
// not send op11: the volunteers have no P2P socket, and a made-up endpoint
// would leave the client stuck on "connecting". The scene actors themselves
// still come from the support-soldier projection.
func (s *Service) publishCurrentDungeonCompanionNativeParty(session *gameSession, runtime *runtimeDungeonState) {
if s == nil || session == nil || runtime == nil || runtime.Companion == nil ||
currentCompanionHeadlessEnabled() ||
!currentCompanionNativePartyEnabled() {
return
}
run := runtime.Companion
if !run.Ready || len(run.Sources) == 0 {
return
}
currentParty := runtimePartyStateSnapshot(session)
if currentParty.PartyID > 0 && len(runtimePartyMembers(currentParty)) > 1 {
s.logGameEvent(session, "game-companion-native-party-skipped",
"owner_character_id", session.selectedCharacterID,
"reason", "owner_already_in_native_party")
return
}
if runtime.partyDungeonOwner != nil || isCurrentImplementedRaidChannel(session) {
s.logGameEvent(session, "game-companion-native-party-skipped",
"owner_character_id", session.selectedCharacterID,
"reason", "party_or_raid_owner_has_priority")
return
}
sources := make([]string, 0, len(run.Sources))
for _, entry := range run.Sources {
if entry.Ready && entry.SourceCharacterID != 0 {
sources = append(sources, strconv.FormatUint(uint64(entry.SourceCharacterID), 10))
}
}
s.ensureCompanionVirtualSessions(
context.Background(), session, sources, strings.TrimSpace(s.accountIDForSession(session)))
state := s.buildCompanionPartyState(session, run)
if len(state.Members) <= 1 {
return
}
sent, err := s.sendCurrentPartyActorFrameProjection(
session, session, state, "companion_native_party")
if err != nil {
s.logGameEvent(session, "game-companion-native-party-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"error", err)
return
}
if !sent {
s.logGameEvent(session, "game-companion-native-party-deferred",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"reason", "op9_projection_not_delivered")
return
}
if err := s.sendRuntimePartyRealtimeInfoLocal(session, state); err != nil {
s.logGameEvent(session, "game-companion-native-party-rows-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"error", err)
return
}
run.NativePartyID = uint16(state.PartyID)
run.NativePartySent = true
s.logGameEvent(session, "game-companion-native-party-sent",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"member_count", len(state.Members),
"source_character_ids", uint16ListLog(currentDungeonCompanionTeamSourceIDs(run)))
}
// clearCurrentDungeonCompanionNativeParty empties only the party window; the
// virtual sessions and friend rows stay online, which is the requested
// post-dungeon state.
func (s *Service) clearCurrentDungeonCompanionNativeParty(session *gameSession, run *currentDungeonCompanionRun) {
if s == nil || session == nil || run == nil || !run.NativePartySent || run.NativePartyID == 0 {
return
}
body := buildCurrentSceneOp9ActorRemovalBodyInContext(
run.NativePartyID, currentPartySceneOwnerContext(session))
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRecoverStamina), body, 0); err != nil {
s.logGameEvent(session, "game-companion-native-party-clear-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"error", err)
}
if err := s.sendGameUpperRawClassCodec(
session, 0x0099, party.BuildEmptyRealtimeInfo(), 0, true); err != nil {
s.logGameEvent(session, "game-companion-native-party-clear-rows-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"error", err)
}
run.NativePartySent = false
s.logGameEvent(session, "game-companion-native-party-cleared",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"friends_online", true)
}