服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
715 lines
25 KiB
Go
715 lines
25 KiB
Go
package party
|
|
|
|
import (
|
|
"sort"
|
|
"sync"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
)
|
|
|
|
// RuntimePartyManager owns the process-local lifecycle of online parties.
|
|
// It deliberately contains no connection or packet code: dnfbridge validates
|
|
// the live session generation at its boundary, then supplies that immutable
|
|
// identity here. This keeps one authoritative membership graph instead of a
|
|
// divergent party copy on every game session.
|
|
type RuntimePartyManager struct {
|
|
mu sync.Mutex
|
|
|
|
nextID uint16
|
|
parties map[uint16]*runtimeParty
|
|
byUser map[uint16]uint16
|
|
peerRequests map[uint16]runtimePartyPeerRequest
|
|
}
|
|
|
|
type RuntimePartyMember struct {
|
|
UserID uint16
|
|
SessionGeneration uint64
|
|
State alignedcmd.PartyMemberState
|
|
Slot byte
|
|
}
|
|
|
|
type RuntimePartySnapshot struct {
|
|
ID uint16
|
|
Leader uint16
|
|
Settings alignedcmd.PartyState
|
|
Members []RuntimePartyMember
|
|
}
|
|
|
|
type RuntimePartyResult struct {
|
|
OK bool
|
|
Reason string
|
|
Party RuntimePartySnapshot
|
|
Previous *RuntimePartySnapshot
|
|
PriorLeave *RuntimePartyResult
|
|
TargetUser uint16
|
|
Disbanded bool
|
|
LeaderMoved bool
|
|
}
|
|
|
|
type runtimeParty struct {
|
|
id uint16
|
|
leader uint16
|
|
settings alignedcmd.PartyState
|
|
members [currentRuntimePartyMaxMembers]*RuntimePartyMember
|
|
}
|
|
|
|
// RuntimePartyPeerRequestContext is what the bridge learns from the pending op10
|
|
// peer request it just consumed. The direction is part of it because the two
|
|
// ordinary party modes travel in the same packet shape both ways: the packet
|
|
// alone cannot say whether the sender invited the target or applied to join the
|
|
// target's party (see runtimePartyPeerRequest.application).
|
|
type RuntimePartyPeerRequestContext struct {
|
|
// PartyID is the requester's own party at request time, zero when the
|
|
// requester had none.
|
|
PartyID uint16
|
|
// JoinApplication is true when the requester asked to JOIN the decider's
|
|
// party. The decider owns the party that survives, so an acceptance moves
|
|
// the REQUESTER; when it is false the request was an invitation and the
|
|
// acceptance moves the DECIDER (the answering client) instead.
|
|
JoinApplication bool
|
|
}
|
|
|
|
// runtimePartyPeerRequest is one pending op10 peer request, keyed by the
|
|
// character that received the prompt and owes the op11 answer.
|
|
type runtimePartyPeerRequest struct {
|
|
decider uint16
|
|
deciderGeneration uint64
|
|
requester uint16
|
|
requesterGeneration uint64
|
|
partyID uint16
|
|
mode byte
|
|
// application records that the requester asked to join the decider's party.
|
|
// The current client sends op10 mode 0 for both menu entries -- 邀请组队 for
|
|
// a partyless target and 加入 for a target who already has a party -- so the
|
|
// bridge decides the direction from the target's party state at request time
|
|
// and stores it here. Re-deriving it when the answer arrives would instead
|
|
// read whatever the party graph looks like a second later.
|
|
application bool
|
|
}
|
|
|
|
const currentRuntimePartyMaxMembers = 4
|
|
|
|
// currentRuntimePartyMinMembers is the smallest membership this server keeps as
|
|
// a party. Operator rule (2026-09-22): 「一个人不成队」-- whenever a leave or a
|
|
// kick would leave exactly one member behind, the party dissolves and that last
|
|
// member ends up partyless as well.
|
|
//
|
|
// The retained one-member aggregate used to be deliberate (see
|
|
// dnfbridge.publishRuntimePartyMembershipSnapshot: the native kind-2 transition
|
|
// owns the multiplayer-to-solo confirmation). Its visible cost was that the
|
|
// survivor sat in a party window the client never draws and could not leave --
|
|
// the open item in the 2026-09-20 party-peer-selection ghost-party fix, section
|
|
// 6: the bridge therefore publishes the native kind-2 one-member row first and
|
|
// clears the party object right after (see broadcastRuntimePartyMemberRemoved),
|
|
// so the client still gets that transition before the window disappears.
|
|
const currentRuntimePartyMinMembers = 2
|
|
|
|
func NewRuntimePartyManager() *RuntimePartyManager {
|
|
return &RuntimePartyManager{
|
|
// Current NoPack reserves party model key 1 during its own bootstrap.
|
|
nextID: 2,
|
|
parties: make(map[uint16]*runtimeParty),
|
|
byUser: make(map[uint16]uint16),
|
|
peerRequests: make(map[uint16]runtimePartyPeerRequest),
|
|
}
|
|
}
|
|
|
|
func (m *RuntimePartyManager) SnapshotByUser(userID uint16, generation uint64) (RuntimePartySnapshot, bool) {
|
|
if m == nil || userID == 0 {
|
|
return RuntimePartySnapshot{}, false
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
party, member := m.partyMemberLocked(userID)
|
|
if party == nil || member == nil || (generation != 0 && member.SessionGeneration != generation) {
|
|
return RuntimePartySnapshot{}, false
|
|
}
|
|
return snapshotRuntimeParty(party), true
|
|
}
|
|
|
|
func (m *RuntimePartyManager) SnapshotByID(partyID uint16) (RuntimePartySnapshot, bool) {
|
|
if m == nil || partyID == 0 {
|
|
return RuntimePartySnapshot{}, false
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
party := m.parties[partyID]
|
|
if party == nil {
|
|
return RuntimePartySnapshot{}, false
|
|
}
|
|
return snapshotRuntimeParty(party), true
|
|
}
|
|
|
|
func (m *RuntimePartyManager) Snapshots() []RuntimePartySnapshot {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
ids := make([]int, 0, len(m.parties))
|
|
for partyID := range m.parties {
|
|
ids = append(ids, int(partyID))
|
|
}
|
|
sort.Ints(ids)
|
|
result := make([]RuntimePartySnapshot, 0, len(ids))
|
|
for _, partyID := range ids {
|
|
result = append(result, snapshotRuntimeParty(m.parties[uint16(partyID)]))
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Create starts a fresh party for leader. If that character already belongs
|
|
// to another party, it is detached atomically and the old-party result is
|
|
// returned through PriorLeave so the bridge can notify survivors.
|
|
func (m *RuntimePartyManager) Create(leader RuntimePartyMember, settings alignedcmd.PartyState) RuntimePartyResult {
|
|
if m == nil || !validRuntimePartyMember(leader) {
|
|
return runtimePartyFailure("invalid_leader")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
partyID := m.nextPartyIDLocked()
|
|
if partyID == 0 {
|
|
return runtimePartyFailure("party_id_exhausted")
|
|
}
|
|
prior := m.leaveLocked(leader.UserID, leader.SessionGeneration, true)
|
|
party := &runtimeParty{id: partyID, leader: leader.UserID, settings: normalizeRuntimePartySettings(settings)}
|
|
party.settings.PartyID = int(party.id)
|
|
party.settings.UserID = leader.UserID
|
|
party.settings.IsLeader = true
|
|
if !party.add(leader) {
|
|
return runtimePartyFailure("add_leader_failed")
|
|
}
|
|
m.parties[party.id] = party
|
|
m.byUser[leader.UserID] = party.id
|
|
result := runtimePartySuccess(party, leader.UserID)
|
|
if prior.OK {
|
|
result.PriorLeave = &prior
|
|
}
|
|
return result
|
|
}
|
|
|
|
// EnsureLeader creates a lobby only when this exact live leader has no party.
|
|
// It is used by bridge-side migration/first-contact paths, where two packets
|
|
// may observe the same partyless target concurrently. Unlike Create, it never
|
|
// detaches an existing party merely because a duplicate bootstrap arrived.
|
|
func (m *RuntimePartyManager) EnsureLeader(leader RuntimePartyMember, settings alignedcmd.PartyState) RuntimePartyResult {
|
|
if m == nil || !validRuntimePartyMember(leader) {
|
|
return runtimePartyFailure("invalid_leader")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if existing, member := m.partyMemberLocked(leader.UserID); existing != nil && member != nil {
|
|
if member.SessionGeneration != leader.SessionGeneration {
|
|
return runtimePartyFailure("stale_member_session")
|
|
}
|
|
if existing.leader != leader.UserID {
|
|
return runtimePartyFailure("already_party_member")
|
|
}
|
|
return runtimePartySuccess(existing, leader.UserID)
|
|
}
|
|
party := &runtimeParty{id: m.nextPartyIDLocked(), leader: leader.UserID, settings: normalizeRuntimePartySettings(settings)}
|
|
if party.id == 0 {
|
|
return runtimePartyFailure("party_id_exhausted")
|
|
}
|
|
party.settings.PartyID = int(party.id)
|
|
party.settings.UserID = leader.UserID
|
|
party.settings.IsLeader = true
|
|
if !party.add(leader) {
|
|
return runtimePartyFailure("add_leader_failed")
|
|
}
|
|
m.parties[party.id] = party
|
|
m.byUser[leader.UserID] = party.id
|
|
return runtimePartySuccess(party, leader.UserID)
|
|
}
|
|
|
|
// Join joins member to the current party generation. A previous membership is
|
|
// always resolved within the same lock, so no character can exist in two
|
|
// parties even while join, leave and disconnect packets race.
|
|
func (m *RuntimePartyManager) Join(partyID uint16, member RuntimePartyMember) RuntimePartyResult {
|
|
if m == nil || partyID == 0 || !validRuntimePartyMember(member) {
|
|
return runtimePartyFailure("invalid_join")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
party := m.parties[partyID]
|
|
if party == nil {
|
|
return runtimePartyFailure("party_not_found")
|
|
}
|
|
if existing := party.member(member.UserID); existing != nil {
|
|
if existing.SessionGeneration == member.SessionGeneration {
|
|
return runtimePartyFailure("already_member")
|
|
}
|
|
return runtimePartyFailure("stale_member_session")
|
|
}
|
|
if party.full() {
|
|
return runtimePartyFailure("party_full")
|
|
}
|
|
prior := m.leaveLocked(member.UserID, member.SessionGeneration, true)
|
|
if !party.add(member) {
|
|
return runtimePartyFailure("add_member_failed")
|
|
}
|
|
m.byUser[member.UserID] = party.id
|
|
result := runtimePartySuccess(party, member.UserID)
|
|
if prior.OK {
|
|
result.PriorLeave = &prior
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (m *RuntimePartyManager) Leave(userID uint16, generation uint64) RuntimePartyResult {
|
|
if m == nil || userID == 0 || generation == 0 {
|
|
return runtimePartyFailure("invalid_leave")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.leaveLocked(userID, generation, false)
|
|
}
|
|
|
|
func (m *RuntimePartyManager) leaveLocked(userID uint16, generation uint64, allowAbsent bool) RuntimePartyResult {
|
|
party, member := m.partyMemberLocked(userID)
|
|
if party == nil || member == nil {
|
|
if allowAbsent {
|
|
return RuntimePartyResult{}
|
|
}
|
|
return runtimePartyFailure("not_in_party")
|
|
}
|
|
if generation != 0 && member.SessionGeneration != generation {
|
|
return runtimePartyFailure("stale_session")
|
|
}
|
|
previous := snapshotRuntimeParty(party)
|
|
previousCopy := previous
|
|
wasLeader := party.leader == userID
|
|
party.remove(userID)
|
|
delete(m.byUser, userID)
|
|
m.clearPeerRequestsForLocked(userID, generation)
|
|
if party.count() == 0 {
|
|
delete(m.parties, party.id)
|
|
return RuntimePartyResult{OK: true, Previous: &previousCopy, TargetUser: userID, Disbanded: true}
|
|
}
|
|
if result, disbanded := m.disbandSingleMemberPartyLocked(party, previousCopy, userID); disbanded {
|
|
return result
|
|
}
|
|
if !wasLeader {
|
|
result := runtimePartySuccess(party, userID)
|
|
result.Previous = &previousCopy
|
|
return result
|
|
}
|
|
|
|
// Same-version NoPack retains the native party model and fixed slots when
|
|
// the leader leaves. Authority passes to the lowest occupied slot and the
|
|
// complete kind-2 membership row carries that new leader slot.
|
|
members := party.membersBySlot()
|
|
newLeader := members[0]
|
|
party.leader = newLeader.UserID
|
|
party.settings.UserID = newLeader.UserID
|
|
party.settings.IsLeader = true
|
|
result := runtimePartySuccess(party, userID)
|
|
result.Previous = &previousCopy
|
|
result.LeaderMoved = true
|
|
return result
|
|
}
|
|
|
|
func (m *RuntimePartyManager) Kick(byUser uint16, byGeneration uint64, targetUser uint16, targetGeneration uint64) RuntimePartyResult {
|
|
if m == nil || byUser == 0 || targetUser == 0 || byUser == targetUser || byGeneration == 0 || targetGeneration == 0 {
|
|
return runtimePartyFailure("invalid_kick")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
party, leader := m.partyMemberLocked(byUser)
|
|
if party == nil || leader == nil {
|
|
return runtimePartyFailure("not_in_party")
|
|
}
|
|
if leader.SessionGeneration != byGeneration {
|
|
return runtimePartyFailure("stale_session")
|
|
}
|
|
if party.leader != byUser {
|
|
return runtimePartyFailure("not_leader")
|
|
}
|
|
target := party.member(targetUser)
|
|
if target == nil {
|
|
return runtimePartyFailure("target_not_member")
|
|
}
|
|
if target.SessionGeneration != targetGeneration {
|
|
return runtimePartyFailure("stale_target_session")
|
|
}
|
|
previous := snapshotRuntimeParty(party)
|
|
party.remove(targetUser)
|
|
delete(m.byUser, targetUser)
|
|
m.clearPeerRequestsForLocked(targetUser, targetGeneration)
|
|
if result, disbanded := m.disbandSingleMemberPartyLocked(party, previous, targetUser); disbanded {
|
|
return result
|
|
}
|
|
result := runtimePartySuccess(party, targetUser)
|
|
result.Previous = &previous
|
|
return result
|
|
}
|
|
|
|
// disbandSingleMemberPartyLocked drops a party that a leave or a kick just
|
|
// reduced below the minimum membership, and detaches the last member with it.
|
|
// Both sides end up partyless, and Previous still carries the full pre-mutation
|
|
// roster so the bridge can publish the reduction to whoever was left behind.
|
|
// The caller must already hold m.mu and must have removed targetUser from
|
|
// m.byUser.
|
|
func (m *RuntimePartyManager) disbandSingleMemberPartyLocked(
|
|
party *runtimeParty,
|
|
previous RuntimePartySnapshot,
|
|
targetUser uint16,
|
|
) (RuntimePartyResult, bool) {
|
|
if m == nil || party == nil || party.count() >= currentRuntimePartyMinMembers {
|
|
return RuntimePartyResult{}, false
|
|
}
|
|
for _, member := range party.membersBySlot() {
|
|
party.remove(member.UserID)
|
|
delete(m.byUser, member.UserID)
|
|
}
|
|
delete(m.parties, party.id)
|
|
return RuntimePartyResult{
|
|
OK: true,
|
|
Previous: &previous,
|
|
TargetUser: targetUser,
|
|
Disbanded: true,
|
|
}, true
|
|
}
|
|
|
|
// TransferLeader changes only the authoritative leader of the existing party.
|
|
// Current NoPack sends the target's stable slot in op121 and expects the same
|
|
// party model and slot table back with a different trailing leader-slot byte.
|
|
// Rebuilding the party here would restart P2P and destroy the fixed-slot
|
|
// identity used by the HUD and minimap.
|
|
func (m *RuntimePartyManager) TransferLeader(byUser uint16, byGeneration uint64, newLeader uint16, newLeaderGeneration uint64) RuntimePartyResult {
|
|
if m == nil || byUser == 0 || newLeader == 0 || byGeneration == 0 || newLeaderGeneration == 0 {
|
|
return runtimePartyFailure("invalid_leader_transfer")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
party, leader := m.partyMemberLocked(byUser)
|
|
if party == nil || leader == nil || leader.SessionGeneration != byGeneration {
|
|
return runtimePartyFailure("stale_session")
|
|
}
|
|
if party.leader != byUser {
|
|
return runtimePartyFailure("not_leader")
|
|
}
|
|
next := party.member(newLeader)
|
|
if next == nil || next.SessionGeneration != newLeaderGeneration {
|
|
return runtimePartyFailure("stale_target_session")
|
|
}
|
|
previous := snapshotRuntimeParty(party)
|
|
party.leader = newLeader
|
|
party.settings.UserID = newLeader
|
|
party.settings.IsLeader = true
|
|
result := runtimePartySuccess(party, newLeader)
|
|
result.Previous = &previous
|
|
result.LeaderMoved = true
|
|
return result
|
|
}
|
|
|
|
// RecordInvite registers an ordinary party INVITATION: the requester asked the
|
|
// decider (the op10 target, which is the client that shows the prompt and
|
|
// answers with op11) to join the requester's own party.
|
|
func (m *RuntimePartyManager) RecordInvite(invitee uint16, inviteeGeneration uint64, inviter uint16, inviterGeneration uint64, partyID uint16, mode byte) bool {
|
|
return m.recordPeerRequest(invitee, inviteeGeneration, inviter, inviterGeneration, partyID, mode, false)
|
|
}
|
|
|
|
// RecordJoinApplication registers an ordinary party JOIN APPLICATION: the
|
|
// requester asked to join a party the decider already owns, so the decider is
|
|
// the one who decides and the requester is the one that moves on acceptance.
|
|
//
|
|
// Named the other way round from RecordInvite on purpose -- owner/applicant
|
|
// instead of invitee/inviter -- because the two differ only in which side ends
|
|
// up changing party, and a call site that quietly swapped them would reproduce
|
|
// the 2026-09-26 defect where a leader was pulled out of his own party.
|
|
func (m *RuntimePartyManager) RecordJoinApplication(owner uint16, ownerGeneration uint64, applicant uint16, applicantGeneration uint64, partyID uint16, mode byte) bool {
|
|
return m.recordPeerRequest(owner, ownerGeneration, applicant, applicantGeneration, partyID, mode, true)
|
|
}
|
|
|
|
func (m *RuntimePartyManager) recordPeerRequest(decider uint16, deciderGeneration uint64, requester uint16, requesterGeneration uint64, partyID uint16, mode byte, application bool) bool {
|
|
if m == nil || decider == 0 || requester == 0 || decider == requester || deciderGeneration == 0 || requesterGeneration == 0 {
|
|
return false
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.peerRequests[decider] = runtimePartyPeerRequest{
|
|
decider: decider,
|
|
deciderGeneration: deciderGeneration,
|
|
requester: requester,
|
|
requesterGeneration: requesterGeneration,
|
|
partyID: partyID,
|
|
mode: mode,
|
|
application: application,
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ConsumePeerRequest validates and consumes the pending request that the given
|
|
// op11 answer belongs to. Both generations are checked, so an answer from a
|
|
// retired session -- or one aimed at a requester that has since reconnected --
|
|
// never moves anybody.
|
|
func (m *RuntimePartyManager) ConsumePeerRequest(decider uint16, deciderGeneration uint64, requester uint16, requesterGeneration uint64, mode byte) (RuntimePartyPeerRequestContext, bool) {
|
|
if m == nil {
|
|
return RuntimePartyPeerRequestContext{}, false
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
request, ok := m.peerRequests[decider]
|
|
if !ok || request.deciderGeneration != deciderGeneration || request.requester != requester || request.requesterGeneration != requesterGeneration || request.mode != mode {
|
|
return RuntimePartyPeerRequestContext{}, false
|
|
}
|
|
delete(m.peerRequests, decider)
|
|
return RuntimePartyPeerRequestContext{PartyID: request.partyID, JoinApplication: request.application}, true
|
|
}
|
|
|
|
func (m *RuntimePartyManager) UpdateSettings(userID uint16, generation uint64, settings alignedcmd.PartyState) RuntimePartyResult {
|
|
if m == nil || userID == 0 || generation == 0 {
|
|
return runtimePartyFailure("invalid_settings")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
party, member := m.partyMemberLocked(userID)
|
|
if party == nil || member == nil {
|
|
return runtimePartyFailure("not_in_party")
|
|
}
|
|
if member.SessionGeneration != generation {
|
|
return runtimePartyFailure("stale_session")
|
|
}
|
|
if party.leader != userID {
|
|
return runtimePartyFailure("not_leader")
|
|
}
|
|
settings = normalizeRuntimePartySettings(settings)
|
|
settings.PartyID = int(party.id)
|
|
settings.UserID = party.leader
|
|
settings.IsLeader = true
|
|
party.settings = settings
|
|
return runtimePartySuccess(party, userID)
|
|
}
|
|
|
|
func (m *RuntimePartyManager) OnDisconnected(userID uint16, generation uint64) RuntimePartyResult {
|
|
return m.Leave(userID, generation)
|
|
}
|
|
|
|
// RebindSession moves an existing member to a replacement game-session
|
|
// generation without changing its party, slot or leader. Channel reconnects
|
|
// temporarily have two sockets for one character; accepting packets from the
|
|
// retired generation after this point would let it remove the replacement
|
|
// member, so this is deliberately generation-conditional.
|
|
func (m *RuntimePartyManager) RebindSession(userID uint16, priorGeneration uint64, replacement RuntimePartyMember) RuntimePartyResult {
|
|
if m == nil || userID == 0 || priorGeneration == 0 || !validRuntimePartyMember(replacement) || replacement.UserID != userID {
|
|
return runtimePartyFailure("invalid_session_rebind")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
party, member := m.partyMemberLocked(userID)
|
|
if party == nil || member == nil {
|
|
m.clearPeerRequestsForLocked(userID, priorGeneration)
|
|
return runtimePartyFailure("not_in_party")
|
|
}
|
|
if member.SessionGeneration != priorGeneration {
|
|
return runtimePartyFailure("stale_session")
|
|
}
|
|
replacement.Slot = member.Slot
|
|
replacement.State = normalizeRuntimePartyMember(replacement.State, userID)
|
|
*member = replacement
|
|
m.clearPeerRequestsForLocked(userID, priorGeneration)
|
|
return runtimePartySuccess(party, userID)
|
|
}
|
|
|
|
// Reposition moves any member between adjacent persistent party slots. The
|
|
// leader authority stays attached to the member identity and the authoritative
|
|
// leader slot is projected separately in op9.
|
|
func (m *RuntimePartyManager) Reposition(byUser uint16, byGeneration uint64, fromSlot uint8, toSlot uint8) RuntimePartyResult {
|
|
if m == nil || byUser == 0 || byGeneration == 0 || fromSlot >= currentRuntimePartyMaxMembers || toSlot >= currentRuntimePartyMaxMembers || fromSlot == toSlot {
|
|
return runtimePartyFailure("invalid_reposition")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
party, leader := m.partyMemberLocked(byUser)
|
|
if party == nil || leader == nil || leader.SessionGeneration != byGeneration {
|
|
return runtimePartyFailure("stale_session")
|
|
}
|
|
if party.leader != byUser {
|
|
return runtimePartyFailure("not_leader")
|
|
}
|
|
if party.members[fromSlot] == nil {
|
|
return runtimePartyFailure("invalid_source_slot")
|
|
}
|
|
party.members[fromSlot], party.members[toSlot] = party.members[toSlot], party.members[fromSlot]
|
|
if party.members[fromSlot] != nil {
|
|
party.members[fromSlot].Slot = fromSlot
|
|
}
|
|
if party.members[toSlot] != nil {
|
|
party.members[toSlot].Slot = toSlot
|
|
}
|
|
return runtimePartySuccess(party, byUser)
|
|
}
|
|
|
|
func (m *RuntimePartyManager) nextPartyIDLocked() uint16 {
|
|
for attempts := 0; attempts < int(^uint16(0))-1; attempts++ {
|
|
id := m.nextID
|
|
m.nextID++
|
|
if m.nextID == 0 || m.nextID == ^uint16(0) {
|
|
m.nextID = 2
|
|
}
|
|
if id != 0 && id != ^uint16(0) && m.parties[id] == nil {
|
|
return id
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m *RuntimePartyManager) partyMemberLocked(userID uint16) (*runtimeParty, *RuntimePartyMember) {
|
|
partyID := m.byUser[userID]
|
|
party := m.parties[partyID]
|
|
if party == nil {
|
|
return nil, nil
|
|
}
|
|
return party, party.member(userID)
|
|
}
|
|
|
|
func (m *RuntimePartyManager) clearPeerRequestsForLocked(userID uint16, generation uint64) {
|
|
for decider, request := range m.peerRequests {
|
|
if (decider == userID && (generation == 0 || request.deciderGeneration == generation)) ||
|
|
(request.requester == userID && (generation == 0 || request.requesterGeneration == generation)) {
|
|
delete(m.peerRequests, decider)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *runtimeParty) count() int {
|
|
count := 0
|
|
for _, member := range p.members {
|
|
if member != nil {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (p *runtimeParty) full() bool {
|
|
limit := p.settings.MaxMembers
|
|
if limit == 0 || limit > currentRuntimePartyMaxMembers {
|
|
limit = currentRuntimePartyMaxMembers
|
|
}
|
|
return p.count() >= int(limit)
|
|
}
|
|
|
|
func (p *runtimeParty) member(userID uint16) *RuntimePartyMember {
|
|
for _, member := range p.members {
|
|
if member != nil && member.UserID == userID {
|
|
return member
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *runtimeParty) add(member RuntimePartyMember) bool {
|
|
if p == nil || !validRuntimePartyMember(member) || p.full() || p.member(member.UserID) != nil {
|
|
return false
|
|
}
|
|
for slot := range p.members {
|
|
if p.members[slot] != nil {
|
|
continue
|
|
}
|
|
member.Slot = byte(slot)
|
|
member.State = normalizeRuntimePartyMember(member.State, member.UserID)
|
|
p.members[slot] = &member
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *runtimeParty) remove(userID uint16) bool {
|
|
for slot, member := range p.members {
|
|
if member != nil && member.UserID == userID {
|
|
p.members[slot] = nil
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *runtimeParty) membersBySlot() []RuntimePartyMember {
|
|
result := make([]RuntimePartyMember, 0, currentRuntimePartyMaxMembers)
|
|
for _, member := range p.members {
|
|
if member != nil {
|
|
result = append(result, cloneRuntimePartyMember(*member))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func snapshotRuntimeParty(p *runtimeParty) RuntimePartySnapshot {
|
|
if p == nil {
|
|
return RuntimePartySnapshot{}
|
|
}
|
|
return RuntimePartySnapshot{ID: p.id, Leader: p.leader, Settings: cloneRuntimePartySettings(p.settings), Members: p.membersBySlot()}
|
|
}
|
|
|
|
func (s RuntimePartySnapshot) StateFor(userID uint16) alignedcmd.PartyState {
|
|
if s.ID == 0 || !s.contains(userID) {
|
|
return alignedcmd.PartyState{}
|
|
}
|
|
state := cloneRuntimePartySettings(s.Settings)
|
|
state.PartyID = int(s.ID)
|
|
state.UserID = s.Leader
|
|
state.IsLeader = s.Leader == userID
|
|
state.Members = make([]alignedcmd.PartyMemberState, 0, len(s.Members))
|
|
for _, member := range s.Members {
|
|
projected := normalizeRuntimePartyMember(member.State, member.UserID)
|
|
projected.Slot = member.Slot
|
|
state.Members = append(state.Members, projected)
|
|
}
|
|
return state
|
|
}
|
|
|
|
func (s RuntimePartySnapshot) contains(userID uint16) bool {
|
|
for _, member := range s.Members {
|
|
if member.UserID == userID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func runtimePartySuccess(p *runtimeParty, target uint16) RuntimePartyResult {
|
|
return RuntimePartyResult{OK: true, Party: snapshotRuntimeParty(p), TargetUser: target}
|
|
}
|
|
|
|
func runtimePartyFailure(reason string) RuntimePartyResult { return RuntimePartyResult{Reason: reason} }
|
|
|
|
func validRuntimePartyMember(member RuntimePartyMember) bool {
|
|
return member.UserID != 0 && member.UserID != ^uint16(0) && member.SessionGeneration != 0
|
|
}
|
|
|
|
func normalizeRuntimePartySettings(state alignedcmd.PartyState) alignedcmd.PartyState {
|
|
state = cloneRuntimePartySettings(state)
|
|
if state.MaxMembers == 0 || state.MaxMembers > currentRuntimePartyMaxMembers {
|
|
state.MaxMembers = currentRuntimePartyMaxMembers
|
|
}
|
|
state.Members = nil
|
|
return state
|
|
}
|
|
|
|
func cloneRuntimePartySettings(state alignedcmd.PartyState) alignedcmd.PartyState {
|
|
state.NameBytes = append([]byte(nil), state.NameBytes...)
|
|
state.Members = append([]alignedcmd.PartyMemberState(nil), state.Members...)
|
|
return state
|
|
}
|
|
|
|
func normalizeRuntimePartyMember(member alignedcmd.PartyMemberState, userID uint16) alignedcmd.PartyMemberState {
|
|
member.UserID = userID
|
|
if member.UserState == 0 || member.UserState == 1 {
|
|
member.UserState = 100
|
|
}
|
|
if member.HPPercent == 0 {
|
|
member.HPPercent = 100
|
|
}
|
|
if member.MPPercent == 0 {
|
|
member.MPPercent = 100
|
|
}
|
|
return member
|
|
}
|
|
|
|
func cloneRuntimePartyMember(member RuntimePartyMember) RuntimePartyMember {
|
|
member.State = normalizeRuntimePartyMember(member.State, member.UserID)
|
|
return member
|
|
}
|