服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2049 lines
81 KiB
Go
2049 lines
81 KiB
Go
// party_online.go 处理只依赖当前 game 连接的临时多人组队回包。
|
|
// 这里不落 DB,也不替代真正 Party owner;它只把已在线且已选角的 session 组装成客户端可见的队伍快照。
|
|
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"sort"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/party"
|
|
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
|
)
|
|
|
|
// All online-party mutations below are routed through RuntimePartyManager.
|
|
// This bridge keeps the current EXE's packet order and per-client projections
|
|
// while the manager remains the sole owner of party identity and membership.
|
|
|
|
const (
|
|
entryIntoPartyFailureGeneric = 3
|
|
entryIntoPartyFailureInvalid = 19
|
|
entryIntoPartyFailureFull = 20
|
|
walkoutPartyFailureInvalid = 19
|
|
walkoutPartyFailureAuthority = 8
|
|
defaultPartyPeerPort = 10000
|
|
)
|
|
|
|
func (s *Service) handleOnlinePartyCommand(session *gameSession, typ uint16, body []byte) (bool, error) {
|
|
// The old connection can still deliver a buffered party command after a
|
|
// reconnect has made a replacement session authoritative for the same
|
|
// character. Never let that stale packet invite, join, leave, kick, or
|
|
// rebuild the replacement's party. The current session directory is the
|
|
// source of truth; a protocol response to the retired connection would
|
|
// only corrupt its already-disposed client state, so consume it silently.
|
|
if isOnlinePartyCommand(typ) && session != nil && session.selectedCharacterID != 0 {
|
|
identity, current := s.boundGameSessionCharacterSnapshot(session)
|
|
if !current || identity.session != session {
|
|
s.logGameEvent(session, "game-party-command-stale-session-ignored",
|
|
"type", typ,
|
|
"char_id", session.selectedCharacterID,
|
|
"character_generation", session.characterGeneration)
|
|
return true, nil
|
|
}
|
|
}
|
|
switch typ {
|
|
case currentPartyPeerStateOpcode, currentPartyHolePunchOpcode, currentPartyAveragePingOpcode:
|
|
return true, s.handleCurrentPartyTransportTelemetry(session, typ, body)
|
|
}
|
|
switch dnfenum.CmdPacket(typ) {
|
|
case dnfenum.CmdPacketLeaveParty:
|
|
// Always consume a leave request. The client can send it after a peer
|
|
// disconnect while its old singleton cache is already absent from the
|
|
// central manager; returning it to the legacy path leaves the EXE's
|
|
// non-leader gate intact.
|
|
return true, s.handleOnlineLeaveParty(session, typ)
|
|
case dnfenum.CmdPacketWalkoutPartyMember:
|
|
return true, s.handleOnlineWalkoutPartyMember(session, typ, body)
|
|
case dnfenum.CmdPacketChangeHost:
|
|
return true, s.handleOnlineChangePartyHost(session, body)
|
|
case dnfenum.CmdPacketChangePartyMemberPosition:
|
|
return true, s.handleOnlineChangePartyMemberPosition(session, typ, body)
|
|
case dnfenum.CmdPacketPartyTeleport:
|
|
return true, s.handleCurrentPartyTeleportOrNetworkReport(session, body)
|
|
case dnfenum.CmdPacketPartyTeleportConfirm:
|
|
// The member's consent answer to the 377 prompt. Its encoding is not
|
|
// decoded yet, so it is recorded instead of interpreted.
|
|
return true, s.handleCurrentPartyTeleportConfirm(session, body)
|
|
case dnfenum.CmdPacketReserveLeaveParty:
|
|
if s.hasManagedRuntimeParty(session) || hasMultiMemberRuntimeParty(session) {
|
|
return true, s.handleOnlineReserveLeaveParty(session, typ, body)
|
|
}
|
|
return false, nil
|
|
case dnfenum.CmdPacketRequestPeer:
|
|
return true, s.handleOnlineRequestPeer(session, typ, body)
|
|
case dnfenum.CmdPacketResponsePeer:
|
|
return true, s.handleOnlineResponsePeer(session, typ, body)
|
|
case dnfenum.CmdPacketEntryIntoParty:
|
|
return true, s.handleOnlineEntryIntoParty(session, typ, body)
|
|
case dnfenum.CmdPacketEntryIntoPartyFinish:
|
|
return true, s.handleOnlineEntryIntoPartyFinish(session, typ)
|
|
case dnfenum.CmdPacketEnterWarroom:
|
|
return s.handleRuntimePartyDirectoryJoin(session, body)
|
|
case dnfenum.CmdPacketQuickJoinRoom:
|
|
return s.handleRuntimePartyDirectoryRefresh(session, body)
|
|
default:
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
func isOnlinePartyCommand(typ uint16) bool {
|
|
switch typ {
|
|
case currentPartyPeerStateOpcode, currentPartyHolePunchOpcode, currentPartyAveragePingOpcode:
|
|
return true
|
|
}
|
|
switch dnfenum.CmdPacket(typ) {
|
|
case dnfenum.CmdPacketLeaveParty,
|
|
dnfenum.CmdPacketWalkoutPartyMember,
|
|
dnfenum.CmdPacketChangeHost,
|
|
dnfenum.CmdPacketChangePartyMemberPosition,
|
|
dnfenum.CmdPacketPartyTeleport,
|
|
dnfenum.CmdPacketPartyTeleportConfirm,
|
|
dnfenum.CmdPacketReserveLeaveParty,
|
|
dnfenum.CmdPacketRequestPeer,
|
|
dnfenum.CmdPacketResponsePeer,
|
|
dnfenum.CmdPacketEntryIntoParty,
|
|
dnfenum.CmdPacketEnterWarroom,
|
|
dnfenum.CmdPacketQuickJoinRoom:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// currentPeerChannelScope reports whether two online characters are committed to
|
|
// the same channel.
|
|
//
|
|
// Presence registration is deliberately NOT usable for this. A character inside a
|
|
// private scene -- the Seria room every ordinary login lands in, a raid channel's
|
|
// Seria room, the PVP lobby -- is never placed in the presence map, so byChar has
|
|
// no entry for it and a presence-based comparison reports a channel mismatch that
|
|
// does not exist. Measured: an applicant standing in the raid channel's Seria room
|
|
// had every mode-10 application dropped by that comparison (13
|
|
// game-peer-request-target-outside-channel rows in the live log, all
|
|
// raid_admission_requires_same_channel), and the identical click started working
|
|
// the moment the character walked out into the public town.
|
|
//
|
|
// The committed game session carries the channel in every scene, so it is the
|
|
// correct authority here. A registration that carries channel zero must not be
|
|
// treated as a match, or two unrelated sessions would compare equal.
|
|
func (s *Service) currentPeerChannelScope(sourceID, targetID uint16) bool {
|
|
if s == nil || sourceID == 0 || targetID == 0 || sourceID == targetID {
|
|
return false
|
|
}
|
|
sourceSession, sourceOK := s.onlineGameSession(sourceID)
|
|
targetSession, targetOK := s.onlineGameSession(targetID)
|
|
if !sourceOK || !targetOK || sourceSession == nil || targetSession == nil {
|
|
return false
|
|
}
|
|
sourceChannel := townPresenceChannelID(sourceSession)
|
|
targetChannel := townPresenceChannelID(targetSession)
|
|
return sourceChannel != 0 && sourceChannel == targetChannel
|
|
}
|
|
|
|
func (s *Service) handleOnlineRequestPeer(session *gameSession, typ uint16, body []byte) error {
|
|
parsed, err := party.DecodeRequestPeerRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-peer-request-body-invalid",
|
|
"type", typ,
|
|
"body_len", len(body),
|
|
"error", err)
|
|
return nil
|
|
}
|
|
sourceID := selectedCharacterID(session)
|
|
if sourceID == 0 || parsed.TargetID == 0 || parsed.TargetID == sourceID || s.onlinePlayers == nil {
|
|
s.logGameEvent(session, "game-peer-request-target-outside-area",
|
|
"type", typ,
|
|
"source_char_id", sourceID,
|
|
"target_id", parsed.TargetID,
|
|
"mode", parsed.Mode)
|
|
return nil
|
|
}
|
|
// Attack-party admission (mode 10) is directory-driven, not co-presence
|
|
// driven. The client resolved the target character from the raid list, so
|
|
// the target legitimately stands in another area of the same town: the
|
|
// leader waits inside the raid's private instance while the applicant is
|
|
// still on the public deck. Requiring a shared area drops the application
|
|
// before handleRuntimeRaidPeerRequest can record it, which is exactly the
|
|
// observed "参加攻坚队 does nothing" symptom.
|
|
//
|
|
// Reference semantics: the 86JP C# server's PartyHandler.Handle_REQUEST_PEER
|
|
// gates a peer request on IsSameGameChannel and rejects the cross-channel
|
|
// case; it never compares town area. Keep that channel-level scope here and
|
|
// leave the raid-specific validation (direction, raid open for admission,
|
|
// leader online) to handleRuntimeRaidPeerRequest.
|
|
crossAreaRaidAdmission := parsed.Mode == runtimeRaidPeerRequestMode &&
|
|
runtimeRaidModeForSession(session) != 0
|
|
if crossAreaRaidAdmission {
|
|
if !s.currentPeerChannelScope(sourceID, parsed.TargetID) {
|
|
s.logGameEvent(session, "game-peer-request-target-outside-channel",
|
|
"type", typ,
|
|
"source_char_id", sourceID,
|
|
"target_id", parsed.TargetID,
|
|
"mode", parsed.Mode,
|
|
"reason", "raid_admission_requires_same_channel")
|
|
return nil
|
|
}
|
|
} else if !s.onlinePlayers.PeerInSameArea(sourceID, parsed.TargetID) {
|
|
s.logGameEvent(session, "game-peer-request-target-outside-area",
|
|
"type", typ,
|
|
"source_char_id", sourceID,
|
|
"target_id", parsed.TargetID,
|
|
"mode", parsed.Mode)
|
|
return nil
|
|
}
|
|
s.logGameEvent(session, "game-peer-request-accepted",
|
|
"type", typ,
|
|
"source_char_id", sourceID,
|
|
"target_id", parsed.TargetID,
|
|
"mode", parsed.Mode,
|
|
"cross_area_raid_admission", crossAreaRaidAdmission,
|
|
"body_source", "current_exe_sub_325F9D0_request_sub_2261E30_ack_and_sub_1D82CF0_mode15_selection_notice")
|
|
if err := s.sendGameUpperRawClassCodec(
|
|
session,
|
|
typ,
|
|
party.BuildRequestPeerAck(),
|
|
dnfproto.DefaultChannelClassification,
|
|
true,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if parsed.Mode == 15 {
|
|
// The town interaction menu reads the target actor's current party link,
|
|
// not an invite/join flag from the mode-15 selection notice. Refresh that
|
|
// link at click time from the target's authoritative runtime state so a
|
|
// partyless target offers invite while an active target offers join. This
|
|
// also repairs a stale client-side link left by an earlier party lifecycle.
|
|
targetSession, online := s.onlineGameSession(parsed.TargetID)
|
|
if !online {
|
|
s.logGameEvent(session, "game-peer-selection-party-state-deferred",
|
|
"source_char_id", sourceID,
|
|
"target_id", parsed.TargetID,
|
|
"reason", "target_resident_session_unavailable")
|
|
return nil
|
|
}
|
|
targetParty := s.runtimePartyPeerSelectionState(targetSession)
|
|
targetPartyActive := runtimePartySelectionTargetActive(targetParty)
|
|
// A one-member party is invisible to its own owner and joinable by
|
|
// nobody, so it has to be projected to the clicking client exactly like
|
|
// the partyless case: the clearing record plus the 0xffff selection
|
|
// marker. See runtimePartySelectionTargetActive for the live incident.
|
|
projectedState := targetParty
|
|
if !targetPartyActive {
|
|
projectedState = alignedcmd.PartyState{}
|
|
}
|
|
projected, projectionErr := s.sendCurrentPartyActorFrameProjection(
|
|
session,
|
|
targetSession,
|
|
projectedState,
|
|
"peer_selection_dynamic_target_party_state",
|
|
)
|
|
if projectionErr != nil {
|
|
return projectionErr
|
|
}
|
|
if !projected {
|
|
s.logGameEvent(session, "game-peer-selection-party-state-deferred",
|
|
"source_char_id", sourceID,
|
|
"target_id", parsed.TargetID,
|
|
"target_party_id", targetParty.PartyID,
|
|
"reason", "target_actor_projection_unavailable")
|
|
return nil
|
|
}
|
|
selectionBody := party.BuildRequestPeerSelectionNotice(parsed.TargetID, parsed.Value0, targetPartyActive)
|
|
s.logGameEvent(session, "game-peer-selection-notice-send",
|
|
"type", 0x0007,
|
|
"source_char_id", sourceID,
|
|
"target_id", parsed.TargetID,
|
|
"target_party_id", targetParty.PartyID,
|
|
"target_party_active", targetPartyActive,
|
|
"projected_frame_party_id", projectedState.PartyID,
|
|
"party_marker", func() string {
|
|
if targetPartyActive {
|
|
return "0000"
|
|
}
|
|
return "ffff"
|
|
}(),
|
|
"mode", parsed.Mode,
|
|
"body_len", len(selectionBody),
|
|
"body_source", "current_exe_sub_1D82CF0_mode15_party_marker_ffff_absent_else_present_then_sub_325FA70_opens_menu")
|
|
return s.sendGameUpperRawClassCodec(session, 0x0007, selectionBody, 0, true)
|
|
}
|
|
|
|
// Mode 10 inside a team channel is attack-party admission, not a
|
|
// four-member party invite. It must never reach the PartyManager path.
|
|
if handled, err := s.handleRuntimeRaidPeerRequest(session, parsed); handled {
|
|
return err
|
|
}
|
|
|
|
if !isForwardedPeerRequestMode(parsed.Mode) {
|
|
return nil
|
|
}
|
|
sourceIdentity, sourceBound := s.boundGameSessionCharacterSnapshot(session)
|
|
targetIdentity, targetBound := s.onlineGameSessionCharacterSnapshot(parsed.TargetID)
|
|
if !sourceBound || !targetBound || targetIdentity.session == nil {
|
|
return nil
|
|
}
|
|
// Direction. One op10 mode-0/12 shape carries both ordinary party requests,
|
|
// so the packet cannot say whether the sender invited the target (邀请组队)
|
|
// or applied to join the target's party (加入) -- the current client sends
|
|
// the same bytes for both. What separates them is the mode-15 selection
|
|
// notice that opened the menu: a target who already owns an ACTIVE party was
|
|
// answered with party_marker 0000, which is exactly what makes the client
|
|
// offer 加入 instead of 邀请组队 (see runtimePartySelectionTargetActive). Ask
|
|
// the same question here, against the same projection, so the bridge cannot
|
|
// read the click differently from the menu the player just used.
|
|
//
|
|
// Live 2026-09-26 13:23:17 is why this matters: char 2 applied to char 1's
|
|
// party 4, the bridge read it as an invitation, and the acceptance moved
|
|
// char 1 OUT of party 4 -- disbanding it, stranding char 20, and building a
|
|
// fresh party for char 1 + char 2 instead of letting char 2 into party 4.
|
|
joinApplication := isPartyInviteMode(parsed.Mode) &&
|
|
runtimePartySelectionTargetActive(s.runtimePartyPeerSelectionState(targetIdentity.session))
|
|
invitePartyID := uint16(0)
|
|
inviteRecorded := false
|
|
if manager := s.runtimePartyManagerForService(); manager != nil {
|
|
if snapshot, found := manager.SnapshotByUser(sourceIdentity.character, sourceIdentity.generation); found {
|
|
invitePartyID = snapshot.ID
|
|
}
|
|
if joinApplication {
|
|
inviteRecorded = manager.RecordJoinApplication(
|
|
targetIdentity.character,
|
|
targetIdentity.generation,
|
|
sourceIdentity.character,
|
|
sourceIdentity.generation,
|
|
invitePartyID,
|
|
parsed.Mode,
|
|
)
|
|
} else {
|
|
inviteRecorded = manager.RecordInvite(
|
|
targetIdentity.character,
|
|
targetIdentity.generation,
|
|
sourceIdentity.character,
|
|
sourceIdentity.generation,
|
|
invitePartyID,
|
|
parsed.Mode,
|
|
)
|
|
}
|
|
}
|
|
if !inviteRecorded {
|
|
s.logGameEvent(session, "game-peer-request-invite-deferred",
|
|
"type", typ,
|
|
"source_char_id", sourceID,
|
|
"target_id", parsed.TargetID,
|
|
"mode", parsed.Mode,
|
|
"reason", "session_generation_changed_before_invite_registration")
|
|
return nil
|
|
}
|
|
if isCurrentAssaultMode(parsed.Mode) {
|
|
if err := s.sendGameUpperRawClassCodec(targetIdentity.session, 443, party.BuildAssaultInvite(sourceID, parsed.Mode), 0, true); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-assault-invite-sent", "target_id", parsed.TargetID, "mode", parsed.Mode)
|
|
return s.sendGameUpperRawClassCodec(session, 444, party.BuildAssaultInvite(parsed.TargetID, parsed.Mode), 0, true)
|
|
}
|
|
noticeBody := party.BuildRequestPeerNotice(sourceID, parsed, s.currentPeerNoticeStats(session, parsed))
|
|
s.logGameEvent(session, "game-peer-request-notice-forward",
|
|
"type", 0x0007,
|
|
"source_char_id", sourceID,
|
|
"target_id", parsed.TargetID,
|
|
"mode", parsed.Mode,
|
|
"join_application", joinApplication,
|
|
"body_len", len(noticeBody),
|
|
"body_source", "current_exe_sub_1D82CF0_mode_specific_peer_prompt")
|
|
return s.sendGameUpperRawClassCodec(targetIdentity.session, 0x0007, noticeBody, 0, true)
|
|
}
|
|
|
|
func (s *Service) handleOnlineLeaveParty(session *gameSession, typ uint16) error {
|
|
sourceID := selectedCharacterID(session)
|
|
state := runtimePartyStateSnapshot(session)
|
|
identity, bound := s.boundGameSessionCharacterSnapshot(session)
|
|
if sourceID == 0 || !bound {
|
|
return nil
|
|
}
|
|
nextState, result, removed := s.leaveManagedRuntimeParty(identity)
|
|
if !removed {
|
|
// Do not let an unimportable one-member cache survive just because the
|
|
// manager has already removed it. A successful native leave ACK plus an
|
|
// empty roster is idempotent and restores solo dungeon permission.
|
|
s.clearRuntimePartyProjection(identity.character, identity.generation)
|
|
s.logGameEvent(session, "game-party-leave-stale-projection-cleared",
|
|
"type", typ,
|
|
"source_char_id", sourceID,
|
|
"reason", result.Reason)
|
|
if err := s.sendGameUpperRawClassCodec(session, typ, []byte{1}, dnfproto.DefaultChannelClassification, true); err != nil {
|
|
return err
|
|
}
|
|
return s.sendRuntimePartyMemberRemoved(session, uint16(state.PartyID), alignedcmd.PartyState{})
|
|
}
|
|
if err := s.sendGameUpperRawClassCodec(session, typ, []byte{1}, dnfproto.DefaultChannelClassification, true); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendRuntimePartyMemberRemoved(session, uint16(state.PartyID), nextState); err != nil {
|
|
return err
|
|
}
|
|
if nextState.PartyID > 0 {
|
|
s.syncRuntimePartyUDPRelay(nextState)
|
|
} else {
|
|
s.closeRuntimePartyUDPRelay(state.PartyID)
|
|
}
|
|
if err := s.broadcastRuntimePartyMemberRemoved(session, state, nextState, sourceID, session); err != nil {
|
|
return err
|
|
}
|
|
// Leaving the party also leaves the attack squad: the raid roster must stop
|
|
// claiming a squad this member is no longer in. See
|
|
// unassignRuntimeRaidSquadOnPartyLeave.
|
|
s.unassignRuntimeRaidSquadOnPartyLeave(sourceID)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleOnlineWalkoutPartyMember(session *gameSession, typ uint16, body []byte) error {
|
|
parsed, err := party.DecodeWalkoutPartyMemberRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-party-walkout-body-short", "type", typ, "body_len", len(body), "error", err)
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureInvalid)
|
|
}
|
|
identity, bound := s.boundGameSessionCharacterSnapshot(session)
|
|
if !bound {
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureInvalid)
|
|
}
|
|
manager := s.runtimePartyManagerForService()
|
|
snapshot, found := manager.SnapshotByUser(identity.character, identity.generation)
|
|
if !found {
|
|
snapshot, found = s.bootstrapManagedRuntimePartyForSession(session)
|
|
}
|
|
if !found {
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureInvalid)
|
|
}
|
|
if snapshot.Leader != identity.character {
|
|
s.logGameEvent(session, "game-party-walkout-not-leader",
|
|
"type", typ,
|
|
"source_char_id", identity.character,
|
|
"leader_id", snapshot.Leader,
|
|
"slot", parsed.Slot)
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureAuthority)
|
|
}
|
|
var target party.RuntimePartyMember
|
|
for _, member := range snapshot.Members {
|
|
if member.Slot == parsed.Slot {
|
|
target = member
|
|
break
|
|
}
|
|
}
|
|
if target.UserID == 0 || target.UserID == identity.character {
|
|
s.logGameEvent(session, "game-party-walkout-invalid-slot",
|
|
"type", typ,
|
|
"source_char_id", identity.character,
|
|
"slot", parsed.Slot,
|
|
"member_count", len(snapshot.Members))
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureInvalid)
|
|
}
|
|
previousState := snapshot.StateFor(identity.character)
|
|
result := manager.Kick(identity.character, identity.generation, target.UserID, target.SessionGeneration)
|
|
// 踢人结果必须留痕。
|
|
//
|
|
// 以前这里是**完全静默**的:manager.Kick 的失败原因(invalid_kick / not_in_party /
|
|
// stale_session / not_leader / target_not_member / stale_target_session)一个日志都
|
|
// 不打,客户端只收到一个"无效",玩家反馈成"踢不掉",而排查时服务器上查不到任何
|
|
// 证据。最典型的是 stale_target_session:被踢的人往往正是"连接中"那个(他刚重开
|
|
// 过客户端),会话代际一变,踢人就永远失败。
|
|
if !result.OK {
|
|
s.logGameEvent(session, "game-party-kick-failed",
|
|
"type", typ,
|
|
"source_char_id", identity.character,
|
|
"target_char_id", target.UserID,
|
|
"target_slot", int(parsed.Slot),
|
|
"source_generation", identity.generation,
|
|
"target_generation", target.SessionGeneration,
|
|
"party_id", previousState.PartyID,
|
|
"member_count", len(snapshot.Members),
|
|
"reason", result.Reason)
|
|
s.recordRaidEvent(session, "party_kick_result", map[string]any{
|
|
"party_id": previousState.PartyID,
|
|
"target_char_id": target.UserID,
|
|
"target_slot": int(parsed.Slot),
|
|
"member_count": len(snapshot.Members),
|
|
"kick_ok": false,
|
|
"kick_reason": result.Reason,
|
|
"source_generation": identity.generation,
|
|
"target_generation": target.SessionGeneration,
|
|
})
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureInvalid)
|
|
}
|
|
s.recordRaidEvent(session, "party_kick_result", map[string]any{
|
|
"party_id": previousState.PartyID,
|
|
"target_char_id": target.UserID,
|
|
"target_slot": int(parsed.Slot),
|
|
"member_count": len(snapshot.Members),
|
|
"kick_ok": true,
|
|
})
|
|
nextState := s.publishRuntimePartyMembershipSnapshot(result.Party)
|
|
if err := s.sendGameUpperRawClassCodec(session, typ, []byte{1}, dnfproto.DefaultChannelClassification, true); err != nil {
|
|
return err
|
|
}
|
|
if err := s.broadcastRuntimePartyWalkoutNotice(session, previousState, parsed.Slot, 0); err != nil {
|
|
return err
|
|
}
|
|
if nextState.PartyID <= 0 {
|
|
s.closeRuntimePartyUDPRelay(int(result.Party.ID))
|
|
} else {
|
|
s.syncRuntimePartyUDPRelay(nextState)
|
|
}
|
|
// target.UserID is never the acting character here (rejected above), so a
|
|
// failure below can only be the KICKED member's own socket. It must not
|
|
// become the kicker's failure; see swallowPeerFanoutWrite.
|
|
if targetSession, ok := s.onlineGameSession(target.UserID); ok {
|
|
s.clearRuntimePartyProjection(target.UserID, target.SessionGeneration)
|
|
if err := s.swallowPeerFanoutWrite(
|
|
session,
|
|
targetSession,
|
|
"runtime_party_member_removed",
|
|
0,
|
|
s.sendRuntimePartyMemberRemoved(targetSession, uint16(previousState.PartyID), nextState),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.broadcastRuntimePartyMemberRemoved(session, previousState, nextState, target.UserID, nil); err != nil {
|
|
return err
|
|
}
|
|
// A kicked member left the party just like one that left on its own, so the
|
|
// raid roster drops its squad label too.
|
|
s.unassignRuntimeRaidSquadOnPartyLeave(target.UserID)
|
|
return nil
|
|
}
|
|
|
|
// handleOnlineChangePartyHost implements the current client's real delegate-
|
|
// leader command. NoPack sends class1/op121 with one fixed party slot. The
|
|
// party model and member slots remain unchanged; only the trailing leader-slot
|
|
// byte of the complete op9 aggregate changes. Replaying op11 here would restart
|
|
// the already-connected P2P transport.
|
|
func (s *Service) handleOnlineChangePartyHost(session *gameSession, body []byte) error {
|
|
if len(body) < 1 {
|
|
return nil
|
|
}
|
|
identity, bound := s.boundGameSessionCharacterSnapshot(session)
|
|
if !bound {
|
|
return nil
|
|
}
|
|
manager := s.runtimePartyManagerForService()
|
|
snapshot, found := manager.SnapshotByUser(identity.character, identity.generation)
|
|
if !found {
|
|
snapshot, found = s.bootstrapManagedRuntimePartyForSession(session)
|
|
}
|
|
if !found || snapshot.Leader != identity.character {
|
|
return nil
|
|
}
|
|
var target party.RuntimePartyMember
|
|
for _, member := range snapshot.Members {
|
|
if member.Slot == body[0] {
|
|
target = member
|
|
break
|
|
}
|
|
}
|
|
if target.UserID == 0 || target.UserID == identity.character {
|
|
return nil
|
|
}
|
|
result := manager.TransferLeader(identity.character, identity.generation, target.UserID, target.SessionGeneration)
|
|
if !result.OK {
|
|
return nil
|
|
}
|
|
state := s.publishRuntimePartySnapshot(result.Party)
|
|
if err := s.sendManagedRuntimePartyAggregateRefresh(state, "op121_change_host"); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-party-change-host-committed",
|
|
"source_char_id", identity.character,
|
|
"new_leader_id", target.UserID,
|
|
"selected_slot", body[0],
|
|
"party_id", result.Party.ID,
|
|
"member_count", len(result.Party.Members),
|
|
"wire_contract", "same_version_stable_party_and_slots_op9_leader_slot_then_op153_without_op11")
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleOnlineChangePartyMemberPosition(session *gameSession, typ uint16, body []byte) error {
|
|
parsed, err := party.DecodeChangePartyMemberPositionRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-party-change-position-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureInvalid)
|
|
}
|
|
identity, bound := s.boundGameSessionCharacterSnapshot(session)
|
|
if !bound {
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureInvalid)
|
|
}
|
|
manager := s.runtimePartyManagerForService()
|
|
snapshot, found := manager.SnapshotByUser(identity.character, identity.generation)
|
|
if !found {
|
|
snapshot, found = s.bootstrapManagedRuntimePartyForSession(session)
|
|
}
|
|
if !found || len(snapshot.Members) <= 1 {
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureInvalid)
|
|
}
|
|
if snapshot.Leader != identity.character {
|
|
s.logGameEvent(session, "game-party-change-position-not-leader",
|
|
"type", typ,
|
|
"source_char_id", identity.character,
|
|
"leader_id", snapshot.Leader,
|
|
"slot", parsed.Slot,
|
|
"position", parsed.Position)
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureAuthority)
|
|
}
|
|
fromSlot := parsed.Slot
|
|
if _, sourceOK := runtimePartyMemberBySlot(snapshot.StateFor(identity.character), fromSlot); !sourceOK {
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureInvalid)
|
|
}
|
|
toSlot := int(fromSlot)
|
|
switch parsed.Position {
|
|
case 3:
|
|
toSlot--
|
|
case 1:
|
|
toSlot++
|
|
}
|
|
maxMembers := snapshot.Settings.MaxMembers
|
|
if maxMembers == 0 || maxMembers > 4 {
|
|
maxMembers = 4
|
|
}
|
|
if toSlot < 0 || toSlot >= int(maxMembers) {
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureInvalid)
|
|
}
|
|
result := manager.Reposition(identity.character, identity.generation, fromSlot, byte(toSlot))
|
|
if !result.OK {
|
|
s.logGameEvent(session, "game-party-change-position-noop",
|
|
"type", typ,
|
|
"source_char_id", identity.character,
|
|
"slot", parsed.Slot,
|
|
"position", parsed.Position,
|
|
"member_count", len(snapshot.Members))
|
|
return s.sendPartyCommandFailure(session, typ, walkoutPartyFailureInvalid)
|
|
}
|
|
nextState := s.publishRuntimePartySnapshot(result.Party)
|
|
if err := s.broadcastRuntimePartyChangePosition(session, nextState, fromSlot, byte(toSlot)); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// swallowPeerFanoutWrite classifies one write that a party/raid fan-out made on
|
|
// behalf of a member session.
|
|
//
|
|
// A write failure belongs to the session that was being written, not to the
|
|
// session that is running the fan-out. When the target is another session the
|
|
// error is recorded and swallowed; only the origin's own socket failure keeps
|
|
// the original failure boundary (returned to the caller, which is what
|
|
// game_session_events.go turns into a closed connection via session.conn.Close()).
|
|
//
|
|
// Live incident 2026-09-25 06:02:14Z: leader 1111 (game-002957, channel 121)
|
|
// kicked a member. The fan-out still resolved char 37 to game-000333 -- the
|
|
// socket of the PREVIOUS raid generation, already closed at 05:37:50 -- and the
|
|
// op10 write returned "set tcp 10.1.0.16:10023: use of closed network
|
|
// connection". It travelled up through handleOnlineWalkoutPartyMember into
|
|
// game_session_events.go, whose fatal path calls session.conn.Close(), so the
|
|
// KICKER lost his OWN connection with 「网络连接中断」. Char 9 (06:02:33) and
|
|
// char 36 (06:04:26) were lost through the same stale-target write.
|
|
//
|
|
// Only the classification of a write that ALREADY failed changes here; a
|
|
// successful write still emits exactly the frame it emitted before, so no
|
|
// packet stream is altered.
|
|
func (s *Service) swallowPeerFanoutWrite(
|
|
origin *gameSession,
|
|
target *gameSession,
|
|
source string,
|
|
msgID uint16,
|
|
err error,
|
|
) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if origin == nil || target == nil || origin == target {
|
|
// The player's own socket failed. Keep the established boundary: the
|
|
// caller returns this error and the session is torn down for it.
|
|
return err
|
|
}
|
|
s.logGameEvent(origin, "game-party-fanout-stale-target-write-skipped",
|
|
"reason", "party_member_session_write_failed_is_not_the_origin_failure",
|
|
"source", source,
|
|
"target_conn_id", target.connID,
|
|
"target_char_id", target.selectedCharacterID,
|
|
"target_generation", target.characterGeneration,
|
|
"msg_id", msgID,
|
|
"error", err)
|
|
return nil
|
|
}
|
|
|
|
// sendGameUpperRawClassCodecToPartyMember sends one fan-out copy of a party
|
|
// notice to one member session through swallowPeerFanoutWrite.
|
|
func (s *Service) sendGameUpperRawClassCodecToPartyMember(
|
|
origin *gameSession,
|
|
target *gameSession,
|
|
msgID uint16,
|
|
body []byte,
|
|
classification byte,
|
|
allowCodec bool,
|
|
) error {
|
|
if target == nil {
|
|
return nil
|
|
}
|
|
return s.swallowPeerFanoutWrite(
|
|
origin,
|
|
target,
|
|
"party_notice_fanout",
|
|
msgID,
|
|
s.sendGameUpperRawClassCodec(target, msgID, body, classification, allowCodec),
|
|
)
|
|
}
|
|
|
|
func (s *Service) broadcastRuntimePartyWalkoutNotice(origin *gameSession, state alignedcmd.PartyState, slot byte, mode byte) error {
|
|
body := party.BuildWalkoutPartyMemberNotice(slot, mode)
|
|
for _, member := range runtimePartyMembers(state) {
|
|
session, ok := s.onlineGameSession(member.UserID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := s.sendGameUpperRawClassCodecToPartyMember(origin, session, uint16(dnfenum.CmdPacketRequestPeer), body, dnfproto.DefaultChannelClassification, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) broadcastRuntimePartyChangePosition(origin *gameSession, state alignedcmd.PartyState, fromSlot byte, toSlot byte) error {
|
|
body := party.BuildChangePartyMemberPositionAck(fromSlot, toSlot)
|
|
for _, member := range runtimePartyMembers(state) {
|
|
session, ok := s.onlineGameSession(member.UserID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := s.sendGameUpperRawClassCodecToPartyMember(origin, session, uint16(dnfenum.CmdPacketChangePartyMemberPosition), body, dnfproto.DefaultChannelClassification, true); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendRuntimePartyRosterLocal(session, state, "change_party_member_position_after_ack"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleOnlineReserveLeaveParty(session *gameSession, typ uint16, body []byte) error {
|
|
parsed, err := party.DecodeReserveLeavePartyRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-party-reserve-leave-body-short", "type", typ, "body_len", len(body), "error", err)
|
|
return s.sendGameUpperRawClassCodec(session, typ, []byte{0}, dnfproto.DefaultChannelClassification, true)
|
|
}
|
|
identity, bound := s.boundGameSessionCharacterSnapshot(session)
|
|
if !bound {
|
|
return s.sendGameUpperRawClassCodec(session, typ, []byte{0}, dnfproto.DefaultChannelClassification, true)
|
|
}
|
|
manager := s.runtimePartyManagerForService()
|
|
snapshot, found := manager.SnapshotByUser(identity.character, identity.generation)
|
|
if !found {
|
|
snapshot, found = s.bootstrapManagedRuntimePartyForSession(session)
|
|
}
|
|
if !found {
|
|
return s.sendGameUpperRawClassCodec(session, typ, []byte{0}, dnfproto.DefaultChannelClassification, true)
|
|
}
|
|
bodyResp := party.BuildReserveLeavePartyAck(parsed.Flag, identity.character)
|
|
for _, member := range snapshot.Members {
|
|
targetSession, ok := s.onlineGameSession(member.UserID)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := s.sendGameUpperRawClassCodecToPartyMember(session, targetSession, typ, bodyResp, dnfproto.DefaultChannelClassification, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.logGameEvent(session, "game-party-reserve-leave-broadcast",
|
|
"type", typ,
|
|
"source_char_id", identity.character,
|
|
"flag", parsed.Flag,
|
|
"member_count", len(snapshot.Members))
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleOnlineResponsePeer(session *gameSession, typ uint16, body []byte) error {
|
|
answer, err := decodeCurrentPeerResponseAnswer(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-party-response-peer-body-short", "type", typ, "body_len", len(body), "error", err)
|
|
return nil
|
|
}
|
|
parsed := answer.Request
|
|
s.logGameEvent(session, "game-party-response-peer-received",
|
|
"type", typ,
|
|
"source_char_id", selectedCharacterID(session),
|
|
"target_id", parsed.TargetID,
|
|
"mode", parsed.Mode,
|
|
"value", parsed.Value,
|
|
"refused", answer.Refused,
|
|
"refusal_reason", answer.Reason)
|
|
if handled, err := s.handleRuntimeRaidPeerResponse(session, parsed, !answer.Refused); handled {
|
|
return err
|
|
}
|
|
if !isForwardedPeerRequestMode(parsed.Mode) {
|
|
return nil
|
|
}
|
|
if isCurrentAssaultMode(parsed.Mode) && parsed.Value > 1 {
|
|
return nil
|
|
}
|
|
acceptorIdentity, acceptorBound := s.boundGameSessionCharacterSnapshot(session)
|
|
targetIdentity, targetBound := s.onlineGameSessionCharacterSnapshot(parsed.TargetID)
|
|
peerRequest := party.RuntimePartyPeerRequestContext{}
|
|
requestConsumed := false
|
|
if acceptorBound && targetBound {
|
|
if manager := s.runtimePartyManagerForService(); manager != nil {
|
|
peerRequest, requestConsumed = manager.ConsumePeerRequest(
|
|
acceptorIdentity.character,
|
|
acceptorIdentity.generation,
|
|
targetIdentity.character,
|
|
targetIdentity.generation,
|
|
parsed.Mode,
|
|
)
|
|
}
|
|
}
|
|
if !requestConsumed {
|
|
s.logGameEvent(session, "game-peer-response-without-pending-request",
|
|
"type", typ,
|
|
"source_char_id", selectedCharacterID(session),
|
|
"target_id", parsed.TargetID,
|
|
"mode", parsed.Mode)
|
|
return nil
|
|
}
|
|
_ = peerRequest.PartyID // recorded generation is revalidated by create/join below.
|
|
if isCurrentAssaultMode(parsed.Mode) {
|
|
return s.respondCurrentAssault(session, targetIdentity.session, parsed)
|
|
}
|
|
ackPayload := party.BuildResponsePeerAckPayload(parsed.TargetID, parsed.Mode)
|
|
if err := s.sendGameUpperSuccess(session, typ, ackPayload); err != nil {
|
|
return err
|
|
}
|
|
targetSession := targetIdentity.session
|
|
if targetSession == nil || session == nil || session.selectedCharacterID == 0 {
|
|
s.logGameEvent(session, "game-party-response-peer-target-offline",
|
|
"type", typ,
|
|
"source_char_id", selectedCharacterID(session),
|
|
"target_id", parsed.TargetID)
|
|
return nil
|
|
}
|
|
if answer.Refused || peerResponseRejected(parsed) {
|
|
reason := answer.Reason
|
|
if reason == 0 {
|
|
reason = party.ResponsePeerRefusalReason
|
|
}
|
|
s.logGameEvent(session, "game-peer-response-rejected",
|
|
"type", typ,
|
|
"source_char_id", selectedCharacterID(session),
|
|
"target_id", parsed.TargetID,
|
|
"mode", parsed.Mode,
|
|
"value", parsed.Value,
|
|
"reason", reason)
|
|
return nil
|
|
}
|
|
if parsed.Mode == 1 {
|
|
// Class-0/op8 is the native generic peer-action notification used by
|
|
// item trade. Party invitation responses do not send it: the same-version
|
|
// flow completes the invitee with class-1/op11, then publishes the native
|
|
// op11 -> op9 -> op153 party frame directly to both members. Injecting an
|
|
// op8 into the inviter's stream enters the generic peer-action controller
|
|
// immediately before party-model creation and leaves that client bound to
|
|
// a different local roster than the invitee.
|
|
responseNotice := party.BuildResponsePeerNotice(session.selectedCharacterID, parsed.Mode, parsed.Value)
|
|
if err := s.sendGameUpperRawClassCodec(targetSession, 0x0008, responseNotice, 0, true); err != nil {
|
|
return err
|
|
}
|
|
s.beginOnlineItemTrade(session, targetSession)
|
|
s.logGameEvent(session, "game-trade-response-peer-forwarded",
|
|
"type", typ,
|
|
"source_char_id", session.selectedCharacterID,
|
|
"target_id", parsed.TargetID,
|
|
"mode", parsed.Mode,
|
|
"value", parsed.Value,
|
|
"body_source", "current_exe_class1_op11_trade_ack_and_class0_op8_peer_notice")
|
|
return nil
|
|
}
|
|
if !isPartyInviteResponse(parsed) {
|
|
return nil
|
|
}
|
|
// Whose party survives is not decided by who answered: it was decided when
|
|
// the request arrived, and the recorded direction says which way it goes.
|
|
//
|
|
// An INVITATION (op7/marker 邀请组队) moves the answering player -- the op11
|
|
// sender -- into the requester's party. A JOIN APPLICATION (op7/marker 加入)
|
|
// moves the requester into the answering player's party instead, because the
|
|
// answering player here is the owner of the party that was applied to: he
|
|
// accepted an application, he did not accept an invitation. Swapping the two
|
|
// is the 2026-09-26 defect -- the leader was detached from his own party and
|
|
// the two players who applied/got applied to ended up in a new one.
|
|
joiner, anchor := session, targetSession
|
|
if peerRequest.JoinApplication {
|
|
joiner, anchor = targetSession, session
|
|
}
|
|
state, result, ok := s.createOrJoinManagedRuntimeParty(joiner, anchor)
|
|
if !ok {
|
|
s.logGameEvent(session, "game-party-response-peer-party-full",
|
|
"type", typ,
|
|
"source_char_id", session.selectedCharacterID,
|
|
"target_id", parsed.TargetID,
|
|
"mode", parsed.Mode,
|
|
"join_application", peerRequest.JoinApplication,
|
|
"reason", result.Reason)
|
|
return nil
|
|
}
|
|
if peerRequest.JoinApplication {
|
|
// The applicant never answers an op11 of its own, so this event is the
|
|
// only place its acceptance is visible without reading the party frame.
|
|
s.logGameEvent(session, "game-party-join-application-accepted",
|
|
"type", typ,
|
|
"decider_char_id", session.selectedCharacterID,
|
|
"applicant_char_id", parsed.TargetID,
|
|
"party_id", state.PartyID,
|
|
"leader_char_id", state.UserID,
|
|
"member_count", len(runtimePartyMembers(state)))
|
|
}
|
|
return s.sendManagedRuntimePartySnapshots(state)
|
|
}
|
|
|
|
func (s *Service) handleOnlineEntryIntoParty(session *gameSession, typ uint16, body []byte) error {
|
|
parsed, err := party.DecodeEntryIntoPartyRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-party-entry-body-short", "type", typ, "body_len", len(body), "error", err)
|
|
return s.sendPartyCommandFailure(session, typ, entryIntoPartyFailureGeneric)
|
|
}
|
|
if session == nil || session.selectedCharacterID == 0 || parsed.TargetID == 0 || parsed.TargetID > 0xffff {
|
|
s.logGameEvent(session, "game-party-entry-invalid-context",
|
|
"type", typ,
|
|
"source_char_id", selectedCharacterID(session),
|
|
"target_id", parsed.TargetID)
|
|
return s.sendPartyCommandFailure(session, typ, entryIntoPartyFailureInvalid)
|
|
}
|
|
targetID := uint16(parsed.TargetID)
|
|
if targetID == session.selectedCharacterID {
|
|
s.logGameEvent(session, "game-party-entry-self-rejected", "type", typ, "char_id", session.selectedCharacterID)
|
|
return s.sendPartyCommandFailure(session, typ, entryIntoPartyFailureInvalid)
|
|
}
|
|
targetSession, ok := s.onlineGameSession(targetID)
|
|
if !ok {
|
|
s.logGameEvent(session, "game-party-entry-target-offline",
|
|
"type", typ,
|
|
"source_char_id", session.selectedCharacterID,
|
|
"target_id", targetID)
|
|
return s.sendPartyCommandFailure(session, typ, entryIntoPartyFailureGeneric)
|
|
}
|
|
// op706 is emitted by the existing party owner while it attaches the
|
|
// selected target. Its source is therefore the anchor/leader, unlike the
|
|
// op8 invite response where the source is the accepting joiner.
|
|
state, result, ok := s.createOrJoinManagedRuntimeParty(targetSession, session)
|
|
if !ok {
|
|
s.logGameEvent(session, "game-party-entry-party-full",
|
|
"type", typ,
|
|
"source_char_id", session.selectedCharacterID,
|
|
"target_id", targetID,
|
|
"reason", result.Reason)
|
|
return s.sendPartyCommandFailure(session, typ, entryIntoPartyFailureFull)
|
|
}
|
|
if err := s.sendGameUpperRawClassCodec(session, typ, party.BuildEntryIntoPartyAck(parsed.TargetID, uint32(session.selectedCharacterID)), dnfproto.DefaultChannelClassification, true); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendManagedRuntimePartySnapshots(state); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-party-entry-linked-online-sessions",
|
|
"type", typ,
|
|
"source_char_id", session.selectedCharacterID,
|
|
"target_id", targetID,
|
|
"member_count", len(state.Members))
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handleOnlineEntryIntoPartyFinish(session *gameSession, typ uint16) error {
|
|
state := runtimePartyStateSnapshot(session)
|
|
if state.PartyID > 0 && len(state.Members) > 1 {
|
|
userState := state.UserState
|
|
if userState == 0 {
|
|
userState = 1
|
|
}
|
|
return s.sendGameUpperRawClassCodec(session, typ, party.BuildEntryIntoPartyFinishEmptyBody(userState), 0, false)
|
|
}
|
|
s.logGameEvent(session, "game-party-entry-finish-missing-context", "type", typ, "source_char_id", selectedCharacterID(session))
|
|
// Current EXE registers only the class0 state/count reader for op706. It has
|
|
// no class1 failure envelope, so the old {0,error} reply would be parsed as
|
|
// state/count and consume bytes from following packets.
|
|
return nil
|
|
}
|
|
|
|
func isPartyInviteMode(mode byte) bool {
|
|
return mode == 0 || mode == 12
|
|
}
|
|
|
|
func isPartyInviteResponse(req party.ResponsePeerRequest) bool {
|
|
return isPartyInviteMode(req.Mode)
|
|
}
|
|
|
|
func isForwardedPeerRequestMode(mode byte) bool {
|
|
return mode == 0 || mode == 1 || mode == 4 || mode == 12 || mode == 13
|
|
}
|
|
|
|
func peerResponseRejected(req party.ResponsePeerRequest) bool {
|
|
// Current EXE sub_269FBF0 accepts ordinary mode-0/1/12 prompts by sending
|
|
// value zero. Street-assault modes 4/13 use explicit value 1 for accept and
|
|
// value 0 for reject (sub_2114B10/sub_2114BB0).
|
|
return (req.Mode == 4 || req.Mode == 13) && req.Value == 0
|
|
}
|
|
|
|
// runtimePartySelectionTargetActive decides whether the interaction menu opened on
|
|
// one character treats that character as somebody who already has a party.
|
|
//
|
|
// A ONE-MEMBER party is deliberately kept by this server -- the native kind-2
|
|
// transition is what turns it into the client's solo window, and it keeps dungeon
|
|
// authority coherent (publishRuntimePartyMembershipSnapshot, and the sole-member
|
|
// rule in party_forced_exit_leave_test.go). But that client never renders a party
|
|
// for a single member: its owner sees no party window and therefore has no 离队 to
|
|
// press. Reporting such a party as active here told every OTHER player's menu "he
|
|
// already has a party", which removes the 邀请 entry -- so nobody could group with
|
|
// him and he could not get out of the state either.
|
|
//
|
|
// Live 2026-09-20, channel 23, four operators (raid 1507338/1507329): 浅笑 (char 12)
|
|
// was left alone in party 23 when his teammate moved to another party. Clicking him
|
|
// produced
|
|
//
|
|
// game-current-party-frame-op9-send … source=peer_selection_dynamic_target_party_state
|
|
// char_id=12 party_model_key=23 party_active=true slot_count=1
|
|
// game-peer-selection-notice-send … target_party_id=23 target_party_active=true party_marker=0000
|
|
//
|
|
// while the same click on char 9 produced target_party_id=0 party_marker=ffff (invite).
|
|
// The operator's report was exactly that: "我们三个怎么组都没问题 就是组不上他", and the
|
|
// person himself could neither see nor leave his party.
|
|
//
|
|
// Reporting him as partyless is all that is needed: accepting an invite detaches him
|
|
// from the stale party inside the same manager lock (RuntimePartyManager.Join calls
|
|
// leaveLocked for the prior membership), so the leftover dissolves by itself.
|
|
func runtimePartySelectionTargetActive(state alignedcmd.PartyState) bool {
|
|
return state.PartyID > 0 && len(runtimePartyMembers(state)) > 1
|
|
}
|
|
|
|
// runtimePartyPeerSelectionState answers "what party does this character's client
|
|
// think it has" for the two peer-selection steps that have to agree with each
|
|
// other: the mode-15 marker that makes the client draw 邀请组队 or 加入, and the
|
|
// mode-0/12 request that follows the click. Both read this one projection, so a
|
|
// menu that offered 加入 cannot be answered as an invitation.
|
|
//
|
|
// The central manager is authoritative and is asked first. The per-session cache
|
|
// is the fallback for a client whose projection predates the manager (the party
|
|
// lifecycle tests build sessions directly) and for a first-contact packet from a
|
|
// window opened before the manager existed.
|
|
func (s *Service) runtimePartyPeerSelectionState(session *gameSession) alignedcmd.PartyState {
|
|
if session == nil {
|
|
return alignedcmd.PartyState{}
|
|
}
|
|
if identity, bound := s.boundGameSessionCharacterSnapshot(session); bound {
|
|
if manager := s.runtimePartyManagerForService(); manager != nil {
|
|
if snapshot, found := manager.SnapshotByUser(identity.character, identity.generation); found {
|
|
return snapshot.StateFor(identity.character)
|
|
}
|
|
}
|
|
}
|
|
return runtimePartyStateSnapshot(session)
|
|
}
|
|
|
|
func (s *Service) sendPartyCommandFailure(session *gameSession, typ uint16, code byte) error {
|
|
return s.sendGameUpperRawClassCodec(session, typ, []byte{0, code}, dnfproto.DefaultChannelClassification, true)
|
|
}
|
|
|
|
func (s *Service) sendRuntimePartySnapshot(session *gameSession, state alignedcmd.PartyState) error {
|
|
frameSent, err := s.sendRuntimePartySnapshotLocal(session, state)
|
|
if err != nil || !frameSent {
|
|
return err
|
|
}
|
|
return s.broadcastCurrentPartyActorProjection(session, state, "runtime_party_snapshot")
|
|
}
|
|
|
|
func (s *Service) sendRuntimePartySnapshotLocal(session *gameSession, state alignedcmd.PartyState) (bool, error) {
|
|
partyActive := state.PartyID > 0 && len(runtimePartyMembers(state)) > 0
|
|
var beforeFrame func() error
|
|
if partyActive && len(runtimePartyMembers(state)) > 1 {
|
|
if !s.runtimePartyPublishablePeerEndpoints(session, state) {
|
|
s.logGameEvent(session, "game-runtime-party-snapshot-deferred",
|
|
"char_id", selectedCharacterID(session),
|
|
"party_id", state.PartyID,
|
|
"member_count", len(runtimePartyMembers(state)),
|
|
"reason", "same_version_native_peer_endpoint_table_incomplete_before_first_op9")
|
|
return false, nil
|
|
}
|
|
beforeFrame = func() error {
|
|
return s.sendRuntimePartyPeerEndpointInfoLocal(session, state, "single_session_snapshot")
|
|
}
|
|
}
|
|
frameSent, err := s.sendCurrentPartyActorFrameProjectionBefore(
|
|
session,
|
|
session,
|
|
state,
|
|
"runtime_party_snapshot",
|
|
beforeFrame,
|
|
)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !frameSent {
|
|
return false, nil
|
|
}
|
|
if !partyActive {
|
|
if err := s.sendGameUpperRawClassCodec(session, 0x0099, party.BuildEmptyRealtimeInfo(), 0, true); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
if err := s.sendGameUpperRawClassCodec(session, 0x0099, party.BuildSingleMemberRealtimeInfo(state), 0, true); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// sendRuntimePartyPeerEndpointInfoLocal installs the native reliable-UDP peer
|
|
// table without starting the scene-side party actor. Multi-session publishers
|
|
// use this as phase one for every member before any op9 is emitted: current
|
|
// NoPack starts its native peer controller when op9 binds the party actor, so
|
|
// allowing the first member to receive op9 while another member still lacks
|
|
// op11 deterministically drops the first handshake and leaves a one-way link.
|
|
func (s *Service) sendRuntimePartyPeerEndpointInfoLocal(
|
|
session *gameSession,
|
|
state alignedcmd.PartyState,
|
|
source string,
|
|
) error {
|
|
if session == nil {
|
|
return errors.New("runtime party peer endpoint target is unavailable")
|
|
}
|
|
if state.PartyID <= 0 || len(runtimePartyMembers(state)) <= 1 {
|
|
return nil
|
|
}
|
|
if !s.runtimePartyPublishablePeerEndpoints(session, state) {
|
|
return fmt.Errorf("runtime party %d peer endpoint table is incomplete for member %d",
|
|
state.PartyID, selectedCharacterID(session))
|
|
}
|
|
body := s.buildRuntimePartyPeerEndpointInfoForReceiver(session, state)
|
|
s.logPacketEvent("game-runtime-party-peer-endpoints-send",
|
|
"conn_id", session.connID,
|
|
"char_id", session.selectedCharacterID,
|
|
"party_id", state.PartyID,
|
|
"peer_count", body[0],
|
|
"body_len", len(body),
|
|
"msg_id", 0x000b,
|
|
"classification", 0,
|
|
"source", source,
|
|
"ordering", "all_members_op11_barrier_before_any_op9_then_op153")
|
|
return s.sendGameUpperRawClassCodec(session, 0x000b, body, 0, true)
|
|
}
|
|
|
|
// sendRuntimePartyRosterLocal refreshes the current client's scene-owned
|
|
// party tables. The complete op9 aggregate owns fixed slots and leadership;
|
|
// op153 follows it with the P2P connection percentage used by the HUD/minimap.
|
|
// The dynamic UDP endpoints in op11 are a party-formation handshake, not a
|
|
// general scene refresh: replaying op11 during every town/selector transition
|
|
// makes the current client tear down a healthy peer link and enter
|
|
// "connecting" again. Therefore scene reconstruction is strictly
|
|
// op9-then-op153, without an op11 re-handshake.
|
|
func (s *Service) sendRuntimePartyRosterLocal(session *gameSession, state alignedcmd.PartyState, source string) error {
|
|
if _, err := s.sendCurrentPartyActorFrameProjection(session, session, state, source); err != nil {
|
|
return err
|
|
}
|
|
return s.sendRuntimePartyRealtimeInfoLocal(session, state)
|
|
}
|
|
|
|
func (s *Service) buildRuntimePartyPeerEndpointInfo(state alignedcmd.PartyState) []byte {
|
|
members := runtimePartyMembers(state)
|
|
endpoints := make([]party.PeerEndpoint, 0, len(members))
|
|
for _, member := range members {
|
|
if member.UserID == 0 {
|
|
continue
|
|
}
|
|
endpoint := s.runtimePartyPeerEndpoint(member, [4]byte{127, 0, 0, 1}, defaultPartyPeerPort)
|
|
if peer, ok := s.onlineGameSession(member.UserID); ok && peer.conn != nil {
|
|
registration := currentPartyPeerEndpointSnapshot(peer)
|
|
if registration.Port != 0 {
|
|
endpoint.Port = registration.Port
|
|
}
|
|
if registration.InnerIPv4 != [4]byte{} {
|
|
endpoint.InnerIPv4 = registration.InnerIPv4
|
|
}
|
|
if registration.OuterIPv4 != [4]byte{} {
|
|
endpoint.OuterIPv4 = registration.OuterIPv4
|
|
}
|
|
if tcpAddress, ok := peer.conn.RemoteAddr().(*net.TCPAddr); ok {
|
|
if ipv4 := tcpAddress.IP.To4(); len(ipv4) == net.IPv4len {
|
|
copy(endpoint.OuterIPv4[:], ipv4)
|
|
}
|
|
}
|
|
}
|
|
endpoints = append(endpoints, endpoint)
|
|
}
|
|
return party.BuildPeerEndpointInfo(endpoints)
|
|
}
|
|
|
|
func (s *Service) buildRuntimePartyPeerEndpointInfoDirectForReceiver(receiver *gameSession, state alignedcmd.PartyState) []byte {
|
|
members := runtimePartyMembers(state)
|
|
endpoints := make([]party.PeerEndpoint, 0, len(members))
|
|
for _, member := range members {
|
|
if member.UserID == 0 || receiver != nil && member.UserID == receiver.selectedCharacterID {
|
|
continue
|
|
}
|
|
endpoint := s.runtimePartyPeerEndpoint(member, [4]byte{127, 0, 0, 1}, defaultPartyPeerPort)
|
|
if peer, ok := s.onlineGameSession(member.UserID); ok && peer.conn != nil {
|
|
registration := currentPartyPeerEndpointSnapshot(peer)
|
|
if registration.Port != 0 {
|
|
endpoint.Port = registration.Port
|
|
}
|
|
if registration.InnerIPv4 != [4]byte{} {
|
|
endpoint.InnerIPv4 = registration.InnerIPv4
|
|
}
|
|
if registration.OuterIPv4 != [4]byte{} {
|
|
endpoint.OuterIPv4 = registration.OuterIPv4
|
|
}
|
|
if tcpAddress, ok := peer.conn.RemoteAddr().(*net.TCPAddr); ok {
|
|
if ipv4 := tcpAddress.IP.To4(); len(ipv4) == net.IPv4len {
|
|
copy(endpoint.OuterIPv4[:], ipv4)
|
|
}
|
|
}
|
|
}
|
|
// The same-version 90CN directory publishes NAT type 1 for the proven
|
|
// direct path. Zero is not a neutral value here: NoPack treats it as an
|
|
// unresolved transport and leaves the member row in "connecting".
|
|
endpoint.NATType = 1
|
|
endpoints = append(endpoints, endpoint)
|
|
}
|
|
return party.BuildPeerEndpointInfo(endpoints)
|
|
}
|
|
|
|
// runtimePartyNativePeerEndpointsReady enforces the same admission boundary
|
|
// as the 90CN DirectorySessionTracker: every remote row must already have an
|
|
// authenticated, non-zero native probe endpoint before the first op9 membership
|
|
// update starts NoPack's P2P controller. Publishing a made-up port cannot be
|
|
// repaired by a later op11 because the client does not restart that member.
|
|
func (s *Service) runtimePartyNativePeerEndpointsReady(receiver *gameSession, state alignedcmd.PartyState) bool {
|
|
for _, member := range runtimePartyMembers(state) {
|
|
if member.UserID == 0 || receiver != nil && member.UserID == receiver.selectedCharacterID {
|
|
continue
|
|
}
|
|
peer, ok := s.onlineGameSession(member.UserID)
|
|
if !ok || peer == nil || peer.conn == nil {
|
|
return false
|
|
}
|
|
registration := currentPartyPeerEndpointSnapshot(peer)
|
|
if registration.Port == 0 {
|
|
return false
|
|
}
|
|
routingKey, pathMTU := s.partyTransportIdentity(peer)
|
|
if routingKey == 0 || !currentPartyPathMTU(pathMTU) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// runtimePartyPublishablePeerEndpoints reports whether the op11 peer endpoint
|
|
// table this service would actually publish to receiver can be built right now.
|
|
//
|
|
// It is deliberately the union of the two tables
|
|
// buildRuntimePartyPeerEndpointInfoForReceiver can produce, and it must stay in
|
|
// step with that function: a gate that admits only one of them strands the
|
|
// parties that can only be built from the other. The direct table needs every
|
|
// remote member's native probe endpoint; the relay table needs only the
|
|
// directed legs, whose ingress is authenticated by the peer's TCP IPv4 and
|
|
// session generation. Checking only the native table is what left a party with
|
|
// one unprobed member permanently unpublished -- the client shows 连接中 forever
|
|
// because no op11/op9/op153 was ever sent, even though the relay could carry it.
|
|
func (s *Service) runtimePartyPublishablePeerEndpoints(receiver *gameSession, state alignedcmd.PartyState) bool {
|
|
if s.runtimePartyNativePeerEndpointsReady(receiver, state) {
|
|
return true
|
|
}
|
|
return s.runtimePartyUDPRelayPeerEndpointsReady(receiver, state)
|
|
}
|
|
|
|
// runtimePartyUDPRelayPeerEndpointsReady reports whether every remote member of
|
|
// state has a directed relay leg from receiver, which is exactly what
|
|
// buildRuntimePartyPeerEndpointInfoForReceiver's relay branch needs to publish a
|
|
// complete table. A missing probe port does not disqualify a member here: its leg
|
|
// still exists and learns the return endpoint from live traffic.
|
|
func (s *Service) runtimePartyUDPRelayPeerEndpointsReady(receiver *gameSession, state alignedcmd.PartyState) bool {
|
|
if receiver == nil || receiver.selectedCharacterID == 0 {
|
|
return false
|
|
}
|
|
if state.PartyID <= 0 || state.PartyID >= int(^uint16(0)) || len(runtimePartyMembers(state)) <= 1 {
|
|
return false
|
|
}
|
|
relay := s.currentPartyUDPRelay()
|
|
if relay == nil || !relay.Enabled() {
|
|
return false
|
|
}
|
|
if !s.syncRuntimePartyUDPRelay(state) {
|
|
return false
|
|
}
|
|
for _, member := range runtimePartyMembers(state) {
|
|
if member.UserID == 0 || member.UserID == receiver.selectedCharacterID {
|
|
continue
|
|
}
|
|
if _, _, ok := relay.Endpoint(uint16(state.PartyID), receiver.selectedCharacterID, member.UserID); !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// buildRuntimePartyPeerEndpointInfoForReceiver publishes the same-version
|
|
// 90CN native PARTY_IP_INFO table: remote members only, using each remote
|
|
// connection's authenticated native-probe UDP endpoint. NoPack owns the
|
|
// receiver's local identity from login success and must contact the peer
|
|
// endpoint directly; inserting a server relay changes the native hole-punch
|
|
// state machine and can strand both HUD rows in "connecting".
|
|
func (s *Service) buildRuntimePartyPeerEndpointInfoForReceiver(receiver *gameSession, state alignedcmd.PartyState) []byte {
|
|
// The relay is a real deployment setting, not a dormant escape hatch: the
|
|
// code default is off (options.go reads
|
|
// DNFBRIDGE_PARTY_UDP_RELAY_ENABLED as false), but the shipped profile turns
|
|
// it on -- runtime\config\instance.json carries
|
|
// "partyUdpRelayEnabled": true with ports 30000+64, measured in the live
|
|
// deployment on 2026-09-20. So this branch is the one normal play takes, and
|
|
// the native branch below is the fallback. It publishes a relay table only
|
|
// after the whole directed topology was installed; partial relay rows still
|
|
// fall back to the same-version direct table.
|
|
if relay := s.currentPartyUDPRelay(); relay != nil && relay.Enabled() && receiver != nil &&
|
|
state.PartyID > 0 && state.PartyID < int(^uint16(0)) {
|
|
_ = s.syncRuntimePartyUDPRelay(state)
|
|
members := runtimePartyMembers(state)
|
|
endpoints := make([]party.PeerEndpoint, 0, len(members))
|
|
complete := true
|
|
for _, member := range members {
|
|
if member.UserID == 0 || member.UserID == receiver.selectedCharacterID {
|
|
continue
|
|
}
|
|
address, port, ok := relay.Endpoint(
|
|
uint16(state.PartyID),
|
|
receiver.selectedCharacterID,
|
|
member.UserID,
|
|
)
|
|
if !ok {
|
|
complete = false
|
|
break
|
|
}
|
|
endpoint := s.runtimePartyPeerEndpoint(member, address, port)
|
|
endpoint.NATType = 1
|
|
endpoints = append(endpoints, endpoint)
|
|
}
|
|
if complete && len(endpoints) > 0 {
|
|
return party.BuildPeerEndpointInfo(endpoints)
|
|
}
|
|
}
|
|
return s.buildRuntimePartyPeerEndpointInfoDirectForReceiver(receiver, state)
|
|
}
|
|
|
|
func (s *Service) runtimePartyPeerEndpoint(member alignedcmd.PartyMemberState, address [4]byte, port uint16) party.PeerEndpoint {
|
|
endpoint := party.PeerEndpoint{
|
|
UserID: member.UserID,
|
|
InnerIPv4: address,
|
|
OuterIPv4: address,
|
|
Port: port,
|
|
NATType: 1,
|
|
PathMTU: currentPartyPeerDefaultPathMTU,
|
|
UserState: member.UserState,
|
|
}
|
|
if endpoint.UserState == 0 || endpoint.UserState == 1 {
|
|
endpoint.UserState = 100
|
|
}
|
|
if peer, ok := s.onlineGameSession(member.UserID); ok {
|
|
routingKey, pathMTU := s.partyTransportIdentity(peer)
|
|
// The route key is the remote connection's native identity from its
|
|
// login success. Sharing the outer compatibility token across members
|
|
// aliases both actors to one reliable-UDP route and leaves them forever
|
|
// in the connecting state.
|
|
endpoint.RoutingKey = routingKey
|
|
if currentPartyPathMTU(pathMTU) {
|
|
endpoint.PathMTU = pathMTU
|
|
}
|
|
}
|
|
// Tests and compatibility imports can construct an active party before a
|
|
// live session has completed its login handshake. Keep the row structurally
|
|
// valid while production always uses the assigned connection identity.
|
|
if endpoint.RoutingKey == 0 {
|
|
endpoint.RoutingKey = uint32(member.UserID)
|
|
}
|
|
return endpoint
|
|
}
|
|
|
|
func (s *Service) currentPartyUDPRelay() *currentPartyUDPRelay {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
relay := s.partyUDPRelay
|
|
s.mu.Unlock()
|
|
return relay
|
|
}
|
|
|
|
// syncRuntimePartyUDPRelay ensures that the directed relay set exactly
|
|
// matches the current live party. It refuses to use a relay until every party
|
|
// member still owns the selected TCP session and that session has an observed
|
|
// IPv4 endpoint; the caller then safely falls back to the proven LAN form.
|
|
func (s *Service) syncRuntimePartyUDPRelay(state alignedcmd.PartyState) bool {
|
|
return s.syncRuntimePartyUDPRelayLegs(state, true)
|
|
}
|
|
|
|
// syncRuntimePartyUDPRelayLegs installs the party relay's forwarding table.
|
|
//
|
|
// A member whose UDP probe answer has not been observed yet no longer aborts the whole
|
|
// sync. Aborting left every squadmate frozen in place for the party's entire life --
|
|
// live 2026-09-20, raid parties 15 and 16 carried ZERO relay packets from creation to
|
|
// dissolution while the re-formed party 17 relayed immediately, and the operator's
|
|
// report is exactly that: "我们两个人互相看不到对方在动 … 重新组队后 就又可以了".
|
|
// The legs that ARE known start forwarding now, the gap is logged loudly, and a bounded
|
|
// retry re-syncs once the missing registration lands.
|
|
func (s *Service) syncRuntimePartyUDPRelayLegs(state alignedcmd.PartyState, allowRetry bool) bool {
|
|
relay := s.currentPartyUDPRelay()
|
|
if state.PartyID > 0 && state.PartyID < int(^uint16(0)) {
|
|
manager := s.runtimePartyManagerForService()
|
|
snapshot, found := manager.SnapshotByID(uint16(state.PartyID))
|
|
if !found {
|
|
return false
|
|
}
|
|
state = snapshot.StateFor(snapshot.Leader)
|
|
}
|
|
members := runtimePartyMembers(state)
|
|
if relay == nil || !relay.Enabled() || state.PartyID <= 0 || len(members) < 2 {
|
|
return false
|
|
}
|
|
bindings := make([]currentPartyUDPRelayBinding, 0, len(members))
|
|
missing := make([]uint16, 0, len(members))
|
|
for _, member := range members {
|
|
identity, ok := s.onlineGameSessionCharacterSnapshot(member.UserID)
|
|
if !ok || identity.session == nil || identity.generation == 0 || identity.session.conn == nil {
|
|
return false
|
|
}
|
|
tcpAddress, ok := identity.session.conn.RemoteAddr().(*net.TCPAddr)
|
|
if !ok || tcpAddress.IP == nil {
|
|
return false
|
|
}
|
|
ipv4 := tcpAddress.IP.To4()
|
|
if len(ipv4) != net.IPv4len {
|
|
return false
|
|
}
|
|
registration := currentPartyPeerEndpointSnapshot(identity.session)
|
|
if registration.Port == 0 {
|
|
// The client's UDP probe answer has not been observed yet. This member's leg
|
|
// is still installed: a relay leg is keyed by the authenticated TCP identity
|
|
// (character + session generation) and by the TCP-owned IPv4 that
|
|
// readLoop enforces on ingress, never by the probe port. Leaving the port at
|
|
// zero only means the leg has no *seed* return endpoint and learns it from the
|
|
// member's first datagram (newLegLocked already supports exactly that).
|
|
//
|
|
// Skipping the member instead is what made one unprobed client unlink the
|
|
// whole party: relay.Endpoint() then failed for every receiver, so
|
|
// buildRuntimePartyPeerEndpointInfoForReceiver fell back to the direct table,
|
|
// runtimePartyNativePeerEndpointsReady refused that table too, and
|
|
// sendManagedRuntimePartySnapshots returned without publishing anything at
|
|
// all. The party stayed half-formed on the wire with the acceptor holding a
|
|
// class1 ACK and nobody receiving op11/op9/op153 -- the operator's "组队显示
|
|
// 连接中" (live 2026-09-29, server 203.0.113.12: party 48 member 32, twelve
|
|
// deferred snapshots and ten unresolved leg retries).
|
|
missing = append(missing, member.UserID)
|
|
}
|
|
binding := currentPartyUDPRelayBinding{
|
|
characterID: identity.character,
|
|
generation: identity.generation,
|
|
port: registration.Port,
|
|
}
|
|
copy(binding.address[:], ipv4)
|
|
bindings = append(bindings, binding)
|
|
}
|
|
if len(missing) != 0 {
|
|
s.logPacketEvent("game-party-udp-relay-leg-missing",
|
|
"party_id", state.PartyID,
|
|
"missing_members", missing,
|
|
"bound_members", len(bindings),
|
|
"member_count", len(members),
|
|
"reason", "the_client_udp_probe_answer_for_that_member_has_not_been_observed",
|
|
"resolution", "leg_installed_without_seed_endpoint_and_learned_from_live_datagram")
|
|
s.recordRaidEvent(nil, "relay_leg_missing", map[string]any{
|
|
"party_id": state.PartyID,
|
|
"missing_members": missing,
|
|
"member_count": len(members),
|
|
"bound_members": len(bindings),
|
|
"result": "legs_installed_without_seed",
|
|
"reason": "the_client_udp_probe_answer_for_that_member_has_not_been_observed",
|
|
"client_visible": "party_still_forms_over_the_relay_instead_of_showing_connecting",
|
|
})
|
|
if allowRetry {
|
|
s.schedulePartyUDPRelayLegRetry(state)
|
|
}
|
|
}
|
|
return relay.Sync(uint16(state.PartyID), bindings)
|
|
}
|
|
|
|
func (s *Service) closeRuntimePartyUDPRelay(partyID int) {
|
|
if partyID <= 0 || partyID > int(^uint16(0)) {
|
|
return
|
|
}
|
|
if relay := s.currentPartyUDPRelay(); relay != nil {
|
|
relay.CloseParty(uint16(partyID))
|
|
}
|
|
}
|
|
|
|
func (s *Service) sendRuntimePartyRealtimeInfoLocal(session *gameSession, state alignedcmd.PartyState) error {
|
|
if session == nil || state.PartyID <= 0 || len(runtimePartyMembers(state)) == 0 {
|
|
return nil
|
|
}
|
|
return s.sendGameUpperRawClassCodec(session, 0x0099, party.BuildSingleMemberRealtimeInfo(state), 0, true)
|
|
}
|
|
|
|
func (s *Service) broadcastRuntimePartyMemberRemoved(origin *gameSession, previousState, nextState alignedcmd.PartyState, removedID uint16, skip *gameSession) error {
|
|
if nextState.PartyID <= 0 {
|
|
// The party is gone, not merely smaller: the members it left behind must
|
|
// come out of it too. See publishRuntimePartyDisbandToSurvivors.
|
|
return s.publishRuntimePartyDisbandToSurvivors(
|
|
origin,
|
|
previousState,
|
|
removedID,
|
|
skip,
|
|
"broadcast_runtime_party_member_removed",
|
|
)
|
|
}
|
|
members := runtimePartyMembers(nextState)
|
|
for _, member := range members {
|
|
if member.UserID == removedID {
|
|
continue
|
|
}
|
|
session, ok := s.onlineGameSession(member.UserID)
|
|
if !ok || session == skip {
|
|
continue
|
|
}
|
|
storeRuntimePartyState(session, nextState)
|
|
if err := s.swallowPeerFanoutWrite(
|
|
origin,
|
|
session,
|
|
"runtime_party_membership_reduction",
|
|
0,
|
|
s.sendRuntimePartyMembershipReduction(session, nextState),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) sendRuntimePartyMemberRemoved(session *gameSession, previousPartyID uint16, remaining alignedcmd.PartyState) error {
|
|
if session == nil || previousPartyID == 0 {
|
|
return nil
|
|
}
|
|
if remaining.PartyID > 0 && len(runtimePartyMembers(remaining)) > 0 {
|
|
if err := s.sendRuntimePartyMembershipBody(session, remaining, 2); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
body := buildCurrentSceneOp9ActorRemovalBodyInContext(previousPartyID, currentPartySceneOwnerContext(session))
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRecoverStamina), body, 0); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.sendGameUpperRawClassCodec(session, 0x0099, party.BuildEmptyRealtimeInfo(), 0, true); err != nil {
|
|
return err
|
|
}
|
|
return s.broadcastCurrentPartyActorProjection(session, alignedcmd.PartyState{}, "runtime_party_member_removed")
|
|
}
|
|
|
|
func (s *Service) sendRuntimePartyMembershipReduction(session *gameSession, state alignedcmd.PartyState) error {
|
|
if err := s.sendRuntimePartyMembershipBody(session, state, 2); err != nil {
|
|
return err
|
|
}
|
|
return s.sendGameUpperRawClassCodec(session, 0x0099, party.BuildSingleMemberRealtimeInfo(state), 0, true)
|
|
}
|
|
|
|
func (s *Service) sendRuntimePartyMembershipBody(session *gameSession, state alignedcmd.PartyState, kind byte) error {
|
|
if session == nil || state.PartyID <= 0 || state.PartyID >= int(^uint16(0)) {
|
|
return fmt.Errorf("runtime party membership frame has invalid party %d", state.PartyID)
|
|
}
|
|
members := runtimePartyMembers(state)
|
|
leader, found := runtimePartyMemberSlotByUserID(members, state.UserID)
|
|
if !found {
|
|
return fmt.Errorf("runtime party %d membership frame missing leader %d", state.PartyID, state.UserID)
|
|
}
|
|
body := buildCurrentSceneOp9PartyMembershipBodyInContext(
|
|
uint16(state.PartyID), currentPartySceneOwnerContext(session), members, leader, kind,
|
|
)
|
|
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRecoverStamina), body, 0)
|
|
}
|
|
|
|
func runtimePartyMemberSlotByUserID(members []alignedcmd.PartyMemberState, userID uint16) (byte, bool) {
|
|
for _, member := range members {
|
|
if member.UserID == userID {
|
|
return member.Slot, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func (s *Service) sendCurrentPartyFrameProjection(session *gameSession, state alignedcmd.PartyState, source string) (bool, error) {
|
|
return s.sendCurrentPartyActorFrameProjection(session, session, state, source)
|
|
}
|
|
|
|
func (s *Service) sendCurrentPartyActorFrameProjection(receiver *gameSession, actor *gameSession, state alignedcmd.PartyState, source string) (bool, error) {
|
|
return s.sendCurrentPartyActorFrameProjectionBefore(receiver, actor, state, source, nil)
|
|
}
|
|
|
|
func (s *Service) sendCurrentPartyActorFrameProjectionBefore(
|
|
receiver *gameSession,
|
|
actor *gameSession,
|
|
state alignedcmd.PartyState,
|
|
source string,
|
|
beforeFrame func() error,
|
|
) (bool, error) {
|
|
receiverIdentity, ok := s.boundGameSessionCharacterSnapshot(receiver)
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
actorIdentity, ok := s.boundGameSessionCharacterSnapshot(actor)
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
characterID := actorIdentity.character
|
|
if characterID == 0 {
|
|
return false, nil
|
|
}
|
|
members := runtimePartyMembers(state)
|
|
partyActive := state.PartyID > 0
|
|
projections := make([]currentSceneOp9PartyMemberProjection, 0, len(members))
|
|
projectionIdentities := make([]boundGameSessionCharacter, 0, len(members))
|
|
for _, member := range members {
|
|
memberIdentity := actorIdentity
|
|
if member.UserID != characterID {
|
|
var found bool
|
|
memberIdentity, found = s.onlineGameSessionCharacterSnapshot(member.UserID)
|
|
if !found {
|
|
continue
|
|
}
|
|
}
|
|
memberID, memberName, memberCharacter, memberHasCharacter := s.characterForEnter(
|
|
ctx,
|
|
memberIdentity.session,
|
|
memberIdentity.character,
|
|
)
|
|
if memberID != member.UserID {
|
|
continue
|
|
}
|
|
memberLevel := byte(1)
|
|
memberJob := byte(0)
|
|
memberGrow := byte(0)
|
|
if memberHasCharacter {
|
|
if memberCharacter.Level > 0 && memberCharacter.Level < 256 {
|
|
memberLevel = byte(memberCharacter.Level)
|
|
}
|
|
memberJob = byte(numericCharacterStat(memberCharacter.Job))
|
|
memberGrow = byte(numericCharacterStatValue(memberCharacter, "grow_type"))
|
|
}
|
|
projections = append(projections, currentSceneOp9PartyMemberProjection{
|
|
State: member,
|
|
Name: memberName,
|
|
Job: memberJob,
|
|
Level: memberLevel,
|
|
Grow: memberGrow,
|
|
})
|
|
projectionIdentities = append(projectionIdentities, memberIdentity)
|
|
}
|
|
projections = currentSceneOp9PartyMembers(projections)
|
|
leaderSlot := byte(0)
|
|
if partyActive {
|
|
if receiver == actor {
|
|
if _, localFound := currentSceneOp9SelectedPartySlot(projections, characterID); !localFound {
|
|
return false, fmt.Errorf(
|
|
"current party projection missing local member %d in party %d",
|
|
characterID,
|
|
state.PartyID,
|
|
)
|
|
}
|
|
}
|
|
var leaderFound bool
|
|
leaderSlot, leaderFound = currentSceneOp9SelectedPartySlot(projections, state.UserID)
|
|
if !leaderFound {
|
|
return false, fmt.Errorf(
|
|
"current party projection missing leader %d in party %d",
|
|
state.UserID,
|
|
state.PartyID,
|
|
)
|
|
}
|
|
}
|
|
if !s.boundGameSessionCharacterCurrent(receiverIdentity) {
|
|
s.logPacketEvent("game-current-party-frame-op9-deferred",
|
|
"source", source,
|
|
"char_id", characterID,
|
|
"party_id", state.PartyID,
|
|
"reason", "receiving_session_character_changed")
|
|
return false, nil
|
|
}
|
|
if !s.boundGameSessionCharacterCurrent(actorIdentity) {
|
|
s.logPacketEvent("game-current-party-frame-op9-deferred",
|
|
"source", source,
|
|
"char_id", characterID,
|
|
"party_id", state.PartyID,
|
|
"reason", "projected_actor_session_character_changed")
|
|
return false, nil
|
|
}
|
|
for _, identity := range projectionIdentities {
|
|
if s.boundGameSessionCharacterCurrent(identity) {
|
|
continue
|
|
}
|
|
s.logPacketEvent("game-current-party-frame-op9-deferred",
|
|
"source", source,
|
|
"char_id", characterID,
|
|
"party_id", state.PartyID,
|
|
"member_id", identity.character,
|
|
"reason", "projected_member_session_character_changed")
|
|
return false, nil
|
|
}
|
|
objectKey := currentSceneActorObjectKey(characterID)
|
|
if !partyActive {
|
|
// Party op9 is parsed against the connected scene channel. The selected
|
|
// actor's mode0/mode1 owner is intentionally context 0, but reusing that
|
|
// value here makes the current EXE choose the wrong party-record branch.
|
|
partyOwner := currentPartySceneOwnerContext(receiver)
|
|
// The record that empties the client's party window has to be addressed
|
|
// to the party object itself, not to the leaving character's scene actor.
|
|
// buildCurrentSceneOp9PartyInformationBodyInContext above keys the
|
|
// populated record by the shared party model id; current EXE sub_1D64CA0
|
|
// resolves every non-zero-kind record through sub_26A1200(ownerChannel,
|
|
// objectKey), whose lookup key is the eight-byte pair of both arguments,
|
|
// and only the kind-3 branch on that same object fills all eight member
|
|
// uid slots with 0xFFFF. Addressed to the actor key instead, the lookup
|
|
// missed the party object entirely and the handler destroyed a scene
|
|
// record while the party window kept every slot it had - the visible
|
|
// symptom after a forced dungeon exit.
|
|
//
|
|
// The party id is not recoverable here: this branch is reached with an
|
|
// already-empty state, so the identity of the last populated record is
|
|
// replayed from the receiver's own memo.
|
|
removalKey := objectKey
|
|
removalOwnerChannel := partyOwner
|
|
removalTarget := "character_actor_no_bound_party_frame"
|
|
if identity, bound := takeRuntimePartyFrameIdentity(receiver, characterID); bound {
|
|
// The remembered pair names the PARTY OBJECT the actor's last
|
|
// populated frame was addressed to, and every member of that party
|
|
// holds the same object. When the observer is one of them, the
|
|
// remembered key IS the observer's own party window, so replaying the
|
|
// clear here empties a party that is still alive -- and nothing
|
|
// repopulates it, because the roster never changed and no frame
|
|
// follows.
|
|
//
|
|
// Live incident 2026-09-27 15:50Z (3-member party 2, town channel
|
|
// 41): the leader closed the game with op3 EXIT, the bridge detached
|
|
// him correctly and re-elected char 20, he logged back in, and the
|
|
// moment his new actor reached the public town this co-presence
|
|
// projection sent op9 kind-3 keyed (2, 41) to chars 20 and 22 --
|
|
// their own party object. Both clients' party windows emptied while
|
|
// the server still held the party. His departure had already reduced
|
|
// that object through the kind-2 membership row, so nothing stale is
|
|
// left to clear and the correct action is to send nothing at all.
|
|
if s.runtimePartyFrameKeyIsLivePartyOf(receiverIdentity, identity.modelKey) {
|
|
s.logPacketEvent("game-current-party-frame-op9-remove-skipped",
|
|
"source", source,
|
|
"receiver_char_id", receiverIdentity.character,
|
|
"char_id", characterID,
|
|
"party_model_key", identity.modelKey,
|
|
"owner_channel", identity.ownerChannel,
|
|
"actor_object_key", objectKey,
|
|
"reason", "the_remembered_party_object_is_the_receivers_own_live_party",
|
|
"body_source", "current_exe_sub_1D64CA0_kind3_party_object_destroy")
|
|
return false, nil
|
|
}
|
|
removalKey = identity.modelKey
|
|
removalOwnerChannel = identity.ownerChannel
|
|
removalTarget = "last_populated_party_model_frame"
|
|
}
|
|
body := buildCurrentSceneOp9ActorRemovalBodyInContext(removalKey, removalOwnerChannel)
|
|
s.logPacketEvent("game-current-party-frame-op9-remove-send",
|
|
"conn_id", receiver.connID,
|
|
"source", source,
|
|
"receiver_char_id", receiverIdentity.character,
|
|
"char_id", characterID,
|
|
"object_key", removalKey,
|
|
"actor_object_key", objectKey,
|
|
"owner_channel", removalOwnerChannel,
|
|
"removal_target", removalTarget,
|
|
"party_id", state.PartyID,
|
|
"body_len", len(body),
|
|
"msg_id", uint16(dnfenum.CmdPacketRecoverStamina),
|
|
"classification", 0,
|
|
"body_source", "current_exe_sub_1D64CA0_kind3_party_object_destroy")
|
|
if err := s.sendGameUpperRawClass(receiver, uint16(dnfenum.CmdPacketRecoverStamina), body, 0); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
partyOwner := currentPartySceneOwnerContext(receiver)
|
|
partyModelKey := uint16(state.PartyID)
|
|
ownerServer := byte(s.channelServerID())
|
|
if receiver.channel.ServerID > 0 && receiver.channel.ServerID <= 0xff {
|
|
ownerServer = byte(receiver.channel.ServerID)
|
|
}
|
|
body := buildCurrentSceneOp9PartyInformationBodyInContext(
|
|
partyModelKey,
|
|
ownerServer,
|
|
partyOwner,
|
|
state,
|
|
projections,
|
|
leaderSlot,
|
|
)
|
|
// The current party transport establishes the realtime roster and P2P
|
|
// endpoints before op9 binds the scene-side member table. Sending op9 first
|
|
// leaves the client in its "connecting" state and prevents teammate map
|
|
// projection from becoming authoritative.
|
|
if beforeFrame != nil {
|
|
if err := beforeFrame(); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
s.logPacketEvent("game-current-party-frame-op9-send",
|
|
"conn_id", receiver.connID,
|
|
"source", source,
|
|
"receiver_char_id", receiverIdentity.character,
|
|
"char_id", characterID,
|
|
"party_model_key", partyModelKey,
|
|
"owner_channel", partyOwner,
|
|
"local_channel", partyOwner,
|
|
"party_id", state.PartyID,
|
|
"party_active", partyActive,
|
|
"slot_count", len(projections),
|
|
"leader_slot", leaderSlot,
|
|
"body_len", len(body),
|
|
"msg_id", uint16(dnfenum.CmdPacketRecoverStamina),
|
|
"classification", 0,
|
|
"body_source", "same_version_sub_1D64CA0_kind0_complete_party_information_and_fixed_slot_table")
|
|
if err := s.sendGameUpperRawClass(receiver, uint16(dnfenum.CmdPacketRecoverStamina), body, 0); err != nil {
|
|
return false, err
|
|
}
|
|
// This client now owns a party object keyed by exactly this pair. Remember
|
|
// it against the projected actor so the record that empties the same object
|
|
// can repeat the pair after the party id has left every session state.
|
|
storeRuntimePartyFrameIdentity(receiver, characterID, partyModelKey, partyOwner)
|
|
return true, nil
|
|
}
|
|
|
|
// storeRuntimePartyFrameIdentity records the (objectKey, ownerChannel) pair a
|
|
// populated op9 kind-0 party record was addressed to, keyed by the actor the
|
|
// record was projected for. Keying by actor matters because one receiver can
|
|
// hold several party objects at once: every same-area peer's party record is
|
|
// broadcast to it, so a single per-session slot would let one player's leave
|
|
// clear another player's party window.
|
|
func storeRuntimePartyFrameIdentity(session *gameSession, characterID uint16, modelKey uint16, ownerChannel byte) {
|
|
if session == nil || characterID == 0 || modelKey == 0 {
|
|
return
|
|
}
|
|
session.party.mu.Lock()
|
|
defer session.party.mu.Unlock()
|
|
if session.party.frameIdentities == nil {
|
|
session.party.frameIdentities = make(map[uint16]runtimePartyFrameIdentity, 4)
|
|
}
|
|
session.party.frameIdentities[characterID] = runtimePartyFrameIdentity{
|
|
modelKey: modelKey,
|
|
ownerChannel: ownerChannel,
|
|
}
|
|
}
|
|
|
|
// takeRuntimePartyFrameIdentity consumes the remembered party-object identity
|
|
// for one actor. It is a one-shot: once the clearing record has been addressed
|
|
// to that object there is nothing left to clear, and a stale pair must never be
|
|
// reused for a later party.
|
|
func takeRuntimePartyFrameIdentity(session *gameSession, characterID uint16) (runtimePartyFrameIdentity, bool) {
|
|
if session == nil || characterID == 0 {
|
|
return runtimePartyFrameIdentity{}, false
|
|
}
|
|
session.party.mu.Lock()
|
|
defer session.party.mu.Unlock()
|
|
identity, bound := session.party.frameIdentities[characterID]
|
|
if !bound {
|
|
return runtimePartyFrameIdentity{}, false
|
|
}
|
|
delete(session.party.frameIdentities, characterID)
|
|
return identity, true
|
|
}
|
|
|
|
// runtimePartyFrameKeyIsLivePartyOf reports whether the remembered party-object
|
|
// key a clearing record would be addressed to is the party the observer stands
|
|
// in right now.
|
|
//
|
|
// The memo is keyed per (receiver, actor) but the value it stores is a SHARED
|
|
// object: two members of one party remember the same model key, which is exactly
|
|
// why that key must not be replayed at one of them. The central manager is the
|
|
// only authority asked here -- a session cache can be empty while the roster is
|
|
// live, and the roster is what makes the object alive.
|
|
func (s *Service) runtimePartyFrameKeyIsLivePartyOf(identity boundGameSessionCharacter, modelKey uint16) bool {
|
|
if s == nil || modelKey == 0 || identity.character == 0 || identity.generation == 0 {
|
|
return false
|
|
}
|
|
manager := s.runtimePartyManagerForService()
|
|
if manager == nil {
|
|
return false
|
|
}
|
|
snapshot, found := manager.SnapshotByUser(identity.character, identity.generation)
|
|
return found && snapshot.ID == modelKey
|
|
}
|
|
|
|
// broadcastCurrentPartyActorProjection tells every same-area peer which party this
|
|
// actor fights in.
|
|
//
|
|
// The projection is a kind-0 op9, and op153 is the only carrier of the per-member
|
|
// HP/MP rows (party.BuildSingleMemberRealtimeInfo). When the observer stands in that
|
|
// same ordinary party, the record IS the observer's own party frame, so leaving it as
|
|
// the last party packet empties the observer's health bars -- the operator's
|
|
// "空血条" every time a squadmate re-enters the area (entering a gate, returning to
|
|
// town, or a phase-end forced return). Live 2026-09-20T13:16:52Z, conn game-000169
|
|
// (char 1 entering 224): three consecutive op9 records for chars 20/22/21 with
|
|
// party_model_key=4 and no op153 behind any of them. The observer's own rows are sent
|
|
// from the observer's own state, never from the actor's.
|
|
func (s *Service) broadcastCurrentPartyActorProjection(actor *gameSession, state alignedcmd.PartyState, source string) error {
|
|
if s == nil || s.onlinePlayers == nil || actor == nil || actor.selectedCharacterID == 0 {
|
|
return nil
|
|
}
|
|
for _, peer := range s.onlinePlayers.PeersInSameArea(actor.selectedCharacterID) {
|
|
if peer.Session == nil {
|
|
continue
|
|
}
|
|
if _, err := s.sendCurrentPartyActorFrameProjection(peer.Session, actor, state, source+"_to_area_peer"); err != nil {
|
|
return err
|
|
}
|
|
if state.PartyID <= 0 {
|
|
// A clearing record (a member left / the party dissolved) is not a frame
|
|
// that needs health rows; the removal path publishes its own empty op153.
|
|
continue
|
|
}
|
|
observer := runtimePartyStateSnapshot(peer.Session)
|
|
if observer.PartyID != state.PartyID {
|
|
continue
|
|
}
|
|
if err := s.sendRuntimePartyRealtimeInfoLocal(peer.Session, observer); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runtimePartyMember(session *gameSession, state alignedcmd.PartyState) alignedcmd.PartyMemberState {
|
|
member := alignedcmd.PartyMemberState{
|
|
UserID: session.selectedCharacterID,
|
|
UserState: 100,
|
|
HPPercent: 100,
|
|
MPPercent: 100,
|
|
}
|
|
for _, existing := range state.Members {
|
|
if existing.UserID == session.selectedCharacterID {
|
|
member.Slot = existing.Slot
|
|
if existing.UserState != 0 {
|
|
member.UserState = existing.UserState
|
|
}
|
|
if existing.HPPercent != 0 {
|
|
member.HPPercent = existing.HPPercent
|
|
}
|
|
if existing.MPPercent != 0 {
|
|
member.MPPercent = existing.MPPercent
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return member
|
|
}
|
|
|
|
func hasMultiMemberRuntimeParty(session *gameSession) bool {
|
|
return runtimePartySelectionTargetActive(runtimePartyStateSnapshot(session))
|
|
}
|
|
|
|
func runtimePartyStateSnapshot(session *gameSession) alignedcmd.PartyState {
|
|
if session == nil {
|
|
return alignedcmd.PartyState{}
|
|
}
|
|
session.party.mu.Lock()
|
|
defer session.party.mu.Unlock()
|
|
return cloneRuntimePartyState(session.party.state)
|
|
}
|
|
|
|
func storeRuntimePartyState(session *gameSession, state alignedcmd.PartyState) {
|
|
if session == nil {
|
|
return
|
|
}
|
|
session.party.mu.Lock()
|
|
defer session.party.mu.Unlock()
|
|
state.Members = runtimePartyMembers(state)
|
|
session.party.state = cloneRuntimePartyState(state)
|
|
}
|
|
|
|
func cloneRuntimePartyState(state alignedcmd.PartyState) alignedcmd.PartyState {
|
|
state.NameBytes = append([]byte(nil), state.NameBytes...)
|
|
state.Members = append([]alignedcmd.PartyMemberState(nil), state.Members...)
|
|
return state
|
|
}
|
|
|
|
func runtimePartyMemberBySlot(state alignedcmd.PartyState, slot byte) (alignedcmd.PartyMemberState, bool) {
|
|
for _, member := range runtimePartyMembers(state) {
|
|
if member.Slot == slot {
|
|
return member, true
|
|
}
|
|
}
|
|
return alignedcmd.PartyMemberState{}, false
|
|
}
|
|
|
|
func runtimePartyMembers(state alignedcmd.PartyState) []alignedcmd.PartyMemberState {
|
|
maxMembers := state.MaxMembers
|
|
if maxMembers == 0 || maxMembers > 4 {
|
|
maxMembers = 4
|
|
}
|
|
members := make([]alignedcmd.PartyMemberState, 0, int(maxMembers))
|
|
for _, member := range state.Members {
|
|
members = appendRuntimePartyMember(members, member, maxMembers)
|
|
}
|
|
// Member slots are fixed native identities. Leadership is carried by the
|
|
// trailing op9 leader-slot field and must never reorder this table.
|
|
sort.Slice(members, func(i, j int) bool { return members[i].Slot < members[j].Slot })
|
|
return members
|
|
}
|
|
|
|
func appendRuntimePartyMember(members []alignedcmd.PartyMemberState, member alignedcmd.PartyMemberState, maxMembers byte) []alignedcmd.PartyMemberState {
|
|
if member.UserID == 0 || containsRuntimePartyMember(members, member.UserID) || len(members) >= int(maxMembers) {
|
|
return members
|
|
}
|
|
if member.UserState == 0 {
|
|
member.UserState = 100
|
|
}
|
|
if member.HPPercent == 0 {
|
|
member.HPPercent = 100
|
|
}
|
|
if member.MPPercent == 0 {
|
|
member.MPPercent = 100
|
|
}
|
|
occupied := func(slot byte) bool {
|
|
for _, existing := range members {
|
|
if existing.Slot == slot {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if member.Slot >= maxMembers || occupied(member.Slot) {
|
|
for candidate := byte(0); candidate < maxMembers; candidate++ {
|
|
if !occupied(candidate) {
|
|
member.Slot = candidate
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return append(members, member)
|
|
}
|
|
|
|
func containsRuntimePartyMember(members []alignedcmd.PartyMemberState, userID uint16) bool {
|
|
for _, member := range members {
|
|
if member.UserID == userID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func selectedCharacterID(session *gameSession) uint16 {
|
|
if session == nil {
|
|
return 0
|
|
}
|
|
return session.selectedCharacterID
|
|
}
|