431 lines
14 KiB
Go
431 lines
14 KiB
Go
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)
|
|
}
|