Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

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
}