服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
355 lines
16 KiB
Go
355 lines
16 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/party"
|
|
"longheng.io/server/internal/modules/dnf/raid"
|
|
)
|
|
|
|
// Attack-party admission rides on the generic class-1/op10 peer request with
|
|
// mode 10. The same wire mode is directional: a raid leader clicking an
|
|
// outsider invites that outsider, an outsider clicking a raid member applies to
|
|
// that raid's current leader. Both directions end with the responder's op11,
|
|
// and only an accepted response admits the applicant through the same roster
|
|
// rule as the native op668 path.
|
|
//
|
|
// Hypothesis source (never authority for the wire layout): the read-only 86JP
|
|
// C# server, RaidHandler.PeerAdmission.cs. The op7 prompt body itself comes
|
|
// from party.BuildRequestPeerNotice, which follows the current EXE reader.
|
|
const (
|
|
runtimeRaidPeerRequestMode byte = 10
|
|
runtimeRaidPeerAdmissionTimeout = 30 * time.Second
|
|
)
|
|
|
|
type runtimeRaidPeerAdmissionKey struct {
|
|
Responder uint16
|
|
Requester uint16
|
|
}
|
|
|
|
type runtimeRaidPeerAdmission struct {
|
|
RaidKey uint32
|
|
IsApplication bool
|
|
Requester boundGameSessionCharacter
|
|
Responder boundGameSessionCharacter
|
|
Applicant boundGameSessionCharacter
|
|
Leader uint16
|
|
PeerValue uint32
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// resolveRuntimeRaidPeerAdmission decides who is admitting whom. Only a leader
|
|
// may invite; anyone outside may apply; two members of different raids, or two
|
|
// outsiders, have no admission relationship.
|
|
func resolveRuntimeRaidPeerAdmission(requesterInRaid, targetInRaid, requesterIsLeader bool) (isApplication bool, ok bool) {
|
|
switch {
|
|
case requesterInRaid && !targetInRaid:
|
|
return false, requesterIsLeader
|
|
case !requesterInRaid && targetInRaid:
|
|
return true, true
|
|
default:
|
|
return false, false
|
|
}
|
|
}
|
|
|
|
func runtimeRaidOpenForPeerAdmission(state runtimeRaidState) bool {
|
|
return state.RaidKey != 0 &&
|
|
state.TailFlag != 1 &&
|
|
!state.Started &&
|
|
!state.StartPending &&
|
|
len(state.Members) < raid.MaxAttackPartyMembers
|
|
}
|
|
|
|
// handleRuntimeRaidPeerRequest owns mode-10 op10 requests inside a team
|
|
// channel. It reports whether the request was consumed so the ordinary party
|
|
// path never treats a one-member raid as a four-member party.
|
|
func (s *Service) handleRuntimeRaidPeerRequest(session *gameSession, parsed party.RequestPeerRequest) (bool, error) {
|
|
if s == nil || session == nil || parsed.Mode != runtimeRaidPeerRequestMode || runtimeRaidModeForSession(session) == 0 {
|
|
return false, nil
|
|
}
|
|
return s.recordRuntimeRaidPeerAdmission(session, parsed)
|
|
}
|
|
|
|
// recordRuntimeRaidPeerAdmission is the mode-10 admission itself, without the
|
|
// team-channel gate. Split out of handleRuntimeRaidPeerRequest so the op820
|
|
// browse-window application can reuse it: that request names the raid by key
|
|
// instead of by a clicked actor, and the applicant does not have to be inside a
|
|
// team channel for the leader to be told about it -- which is exactly the
|
|
// same-channel/different-area case where clicking the leader's actor is
|
|
// impossible.
|
|
func (s *Service) recordRuntimeRaidPeerAdmission(session *gameSession, parsed party.RequestPeerRequest) (bool, error) {
|
|
if s == nil || session == nil || parsed.Mode != runtimeRaidPeerRequestMode {
|
|
return false, nil
|
|
}
|
|
requester, requesterBound := s.boundGameSessionCharacterSnapshot(session)
|
|
target, targetBound := s.onlineGameSessionCharacterSnapshot(parsed.TargetID)
|
|
if !requesterBound || !targetBound || target.session == nil || requester.character == target.character {
|
|
s.logGameEvent(session, "game-raid-peer-request-rejected",
|
|
"target_id", parsed.TargetID, "reason", "requester_or_target_not_bound")
|
|
return true, nil
|
|
}
|
|
if currentExpertJobSessionInDungeon(session) || currentExpertJobSessionInDungeon(target.session) {
|
|
s.logGameEvent(session, "game-raid-peer-request-rejected",
|
|
"target_id", parsed.TargetID, "reason", "requester_or_target_in_dungeon")
|
|
return true, nil
|
|
}
|
|
|
|
requesterRaid, requesterInRaid := s.runtimeRaidForMember(requester.character)
|
|
targetRaid, targetInRaid := s.runtimeRaidForMember(target.character)
|
|
if requesterInRaid && targetInRaid && requesterRaid.RaidKey == targetRaid.RaidKey {
|
|
// Clicking a teammate is a status refresh, not an admission.
|
|
s.logGameEvent(session, "game-raid-peer-request-same-raid-refresh",
|
|
"raid_key", requesterRaid.RaidKey, "target_id", parsed.TargetID)
|
|
return true, normalizeRuntimeRaidRejoinError(s.sendRuntimeRaidRejoinRecovery(requester, requesterRaid, s.runtimeRaidRejoinNow()))
|
|
}
|
|
requesterIsLeader := requesterInRaid && requesterRaid.LeaderID == requester.character
|
|
isApplication, ok := resolveRuntimeRaidPeerAdmission(requesterInRaid, targetInRaid, requesterIsLeader)
|
|
if !ok {
|
|
s.logGameEvent(session, "game-raid-peer-request-rejected",
|
|
"target_id", parsed.TargetID,
|
|
"requester_in_raid", requesterInRaid,
|
|
"target_in_raid", targetInRaid,
|
|
"requester_is_leader", requesterIsLeader,
|
|
"reason", "direction_not_admissible")
|
|
return true, nil
|
|
}
|
|
state := requesterRaid
|
|
if isApplication {
|
|
state = targetRaid
|
|
}
|
|
if !runtimeRaidOpenForPeerAdmission(state) {
|
|
s.logGameEvent(session, "game-raid-peer-request-rejected",
|
|
"raid_key", state.RaidKey,
|
|
"target_id", parsed.TargetID,
|
|
"started", state.Started,
|
|
"start_pending", state.StartPending,
|
|
"member_count", len(state.Members),
|
|
"reason", "raid_not_open")
|
|
return true, nil
|
|
}
|
|
|
|
responder := target
|
|
applicant := target
|
|
if isApplication {
|
|
applicant = requester
|
|
leader, leaderOnline := s.onlineGameSessionCharacterSnapshot(state.LeaderID)
|
|
if !leaderOnline || leader.session == nil {
|
|
s.logGameEvent(session, "game-raid-peer-request-rejected",
|
|
"raid_key", state.RaidKey, "leader_id", state.LeaderID, "reason", "leader_offline")
|
|
return true, nil
|
|
}
|
|
responder = leader
|
|
}
|
|
|
|
now := s.gameplayNow()
|
|
pending := runtimeRaidPeerAdmission{
|
|
RaidKey: state.RaidKey,
|
|
IsApplication: isApplication,
|
|
Requester: requester,
|
|
Responder: responder,
|
|
Applicant: applicant,
|
|
Leader: state.LeaderID,
|
|
PeerValue: parsed.Value0,
|
|
ExpiresAt: now.Add(runtimeRaidPeerAdmissionTimeout),
|
|
}
|
|
key := runtimeRaidPeerAdmissionKey{Responder: responder.character, Requester: requester.character}
|
|
s.storeRuntimeRaidPeerAdmission(key, pending, now)
|
|
|
|
noticeBody := party.BuildRequestPeerNotice(requester.character, parsed, s.currentPeerNoticeStats(requester.session, parsed))
|
|
// Install the applicant's actor record before the prompt. The client resolves op7's
|
|
// source through its own user table and drops the whole notification when the
|
|
// key is missing (sub_1D82CF0 reads u16 source, calls sub_2699A30, and jumps
|
|
// straight to its epilogue when that returns null). Measured: with the
|
|
// applicant standing in a Seria room -- a character-private scene the server
|
|
// deliberately keeps out of the public presence map -- the leader received the
|
|
// packet and showed nothing at all, and the same request worked as soon as the
|
|
// applicant walked out into the public town.
|
|
//
|
|
// Record form (mode0 + op9), not the scene-derived binding form: mode0 is the
|
|
// half that fills that user table -- name, level, portrait -- while mode1 is the
|
|
// half that binds equipment and skill pages and is documented as the RECEIVER's
|
|
// own stat block (see sendTownRemoteActorRecord / sendTownRemoteActorState). A
|
|
// prompt needs the applicant resolvable, not his equipment, and building the
|
|
// binding form here was also the single most expensive step of the whole request
|
|
// (see the note on the removed reverse install below).
|
|
s.sendRuntimeRaidPeerActorRecord(state, responder.session, requester.character, "raid_peer_request_source_actor")
|
|
// The reverse install -- the LEADER's own record into the applicant -- has been
|
|
// removed on purpose. It had no measurement behind it (the leader's prompt is the
|
|
// only direction with a live symptom), it was redundant (an application is made
|
|
// through the raid directory, whose browse had already installed that row's actor
|
|
// into the applicant: measured 2026-09-26 13:56:42.030, 1.3 s before this request),
|
|
// and on admission syncRuntimeRaidMateActors installs both directions anyway.
|
|
// Cost of sending it: the leader's own record (mode0 472 B + mode1 1373 B with 15
|
|
// equipment rows) plus ~87 ms of equipment/PVF building that sat IN FRONT of the
|
|
// leader's prompt -- the operator's "申请后只有团长会卡一下".
|
|
if err := s.sendGameUpperRawClassCodec(responder.session, 0x0007, noticeBody, 0, true); err != nil {
|
|
s.removeRuntimeRaidPeerAdmission(key)
|
|
return true, err
|
|
}
|
|
s.logGameEvent(session, "game-raid-peer-request-recorded",
|
|
"raid_key", state.RaidKey,
|
|
"requester_id", requester.character,
|
|
"target_id", parsed.TargetID,
|
|
"responder_id", responder.character,
|
|
"applicant_id", applicant.character,
|
|
"application", isApplication,
|
|
"expires_in_seconds", int(runtimeRaidPeerAdmissionTimeout/time.Second))
|
|
return true, nil
|
|
}
|
|
|
|
// handleRuntimeRaidPeerResponse owns mode-10 op11 responses. parsed.TargetID is
|
|
// the original requester; the responding session is the party that was
|
|
// prompted. A declined or expired prompt only drops the pending record.
|
|
func (s *Service) handleRuntimeRaidPeerResponse(session *gameSession, parsed party.ResponsePeerRequest, accepted bool) (bool, error) {
|
|
if s == nil || session == nil || parsed.Mode != runtimeRaidPeerRequestMode {
|
|
return false, nil
|
|
}
|
|
responder, responderBound := s.boundGameSessionCharacterSnapshot(session)
|
|
if !responderBound {
|
|
return true, nil
|
|
}
|
|
key := runtimeRaidPeerAdmissionKey{Responder: responder.character, Requester: parsed.TargetID}
|
|
now := s.gameplayNow()
|
|
pending, found := s.takeRuntimeRaidPeerAdmission(key, now)
|
|
if !found {
|
|
s.logGameEvent(session, "game-raid-peer-response-ignored",
|
|
"requester_id", parsed.TargetID, "reason", "no_pending_or_expired")
|
|
return true, nil
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketResponsePeer), party.BuildResponsePeerAckPayload(parsed.TargetID, parsed.Mode)); err != nil {
|
|
return true, err
|
|
}
|
|
if !accepted {
|
|
s.logGameEvent(session, "game-raid-peer-response-declined",
|
|
"raid_key", pending.RaidKey, "requester_id", parsed.TargetID, "application", pending.IsApplication)
|
|
return true, nil
|
|
}
|
|
// Both parties must still be the exact sessions that were prompted; a
|
|
// character reselection or reconnect in between voids the prompt.
|
|
if !s.boundGameSessionCharacterCurrent(pending.Responder) || !s.boundGameSessionCharacterCurrent(pending.Applicant) ||
|
|
pending.Responder.character != responder.character || pending.Responder.generation != responder.generation {
|
|
s.logGameEvent(session, "game-raid-peer-response-ignored",
|
|
"raid_key", pending.RaidKey, "reason", "session_identity_changed")
|
|
return true, nil
|
|
}
|
|
if currentExpertJobSessionInDungeon(pending.Applicant.session) {
|
|
s.logGameEvent(session, "game-raid-peer-response-ignored",
|
|
"raid_key", pending.RaidKey, "reason", "applicant_in_dungeon")
|
|
return true, nil
|
|
}
|
|
approvingLeader := pending.Responder
|
|
if !pending.IsApplication {
|
|
approvingLeader = pending.Requester
|
|
}
|
|
state, added, ok := s.admitRuntimeRaidPeer(pending.RaidKey, approvingLeader.character, pending.Applicant.character)
|
|
if !ok {
|
|
s.logGameEvent(session, "game-raid-peer-response-join-rejected",
|
|
"raid_key", pending.RaidKey,
|
|
"applicant_id", pending.Applicant.character,
|
|
"leader_id", approvingLeader.character,
|
|
"reason", "raid_changed_or_roster_rule")
|
|
return true, nil
|
|
}
|
|
if !added {
|
|
s.logGameEvent(session, "game-raid-peer-response-already-member",
|
|
"raid_key", pending.RaidKey, "applicant_id", pending.Applicant.character)
|
|
return true, nil
|
|
}
|
|
// Bind the applicant exactly like a fresh 664: the object must exist before
|
|
// the create-format success ACK selects the raid key locally, and the mode=3
|
|
// roster afterwards materializes every row for every member.
|
|
if err := s.sendRuntimeRaidCreateBinding(pending.Applicant.session, uint16(dnfenum.CmdPacketCreateRaid), state); err != nil {
|
|
return true, err
|
|
}
|
|
// Membership just changed, so refresh every member material column too.
|
|
// The 入场材料 column is rendered from the 0x0256 snapshot and no roster push
|
|
// refreshes it, which is why the leader -- the first to open the window, whose
|
|
// snapshot was captured before anyone joined -- kept seeing every later member
|
|
// column empty while the members who joined later saw a nearly complete one.
|
|
// Kept out of broadcastRuntimeRaidRefresh on purpose: that function also runs in
|
|
// the start flow, where the ack-before-cost-sync ordering is load bearing.
|
|
if err := s.broadcastRuntimeRaidEntryCost(state); err != nil {
|
|
return true, err
|
|
}
|
|
if err := s.broadcastRuntimeRaidBuffRegistration(state, 0); err != nil {
|
|
return true, err
|
|
}
|
|
// The applicant joins while standing wherever it was -- often a different area
|
|
// of the raid channel, or still inside its own Seria room. The roster row that
|
|
// just appeared renders its portrait and level from the client's own user
|
|
// table, which only actor creation fills, so both sides need each other's
|
|
// record now rather than whenever they next happen to share an area.
|
|
//
|
|
// Two operator decisions (2026-09-27) shape this call:
|
|
//
|
|
// - Record-only installs. The roster row resolves from the mode0 record alone;
|
|
// the mode1 half carries the peer's equipment and skill pages, which nothing
|
|
// on the accepted-member path reads. Before the change the acceptance paid
|
|
// 679 B + 1373 B of bindings plus two 14-slot equipment projections for them.
|
|
// - Exactly one call. syncRuntimeRaidMateActorsWith installs BOTH directions of
|
|
// every pair, so the second call this path used to make for the approving
|
|
// leader repeated every pair it touched -- live 2026-09-26 17:18:06Z, raid
|
|
// 1507329: identical sizes twice per direction (472/1373 to the applicant at
|
|
// .404 and .529, 459/679 to the leader at .434 and .463), 2983 B of that
|
|
// 6674 B acceptance.
|
|
s.syncRuntimeRaidMateActorRecords(pending.Applicant.session, "raid_admitted_member_actor")
|
|
s.logGameEvent(session, "game-raid-peer-admitted",
|
|
"raid_key", state.RaidKey,
|
|
"applicant_id", pending.Applicant.character,
|
|
"leader_id", state.LeaderID,
|
|
"application", pending.IsApplication,
|
|
"member_count", len(state.Members))
|
|
return true, nil
|
|
}
|
|
|
|
// admitRuntimeRaidPeer re-validates the approving leader under the raid lock
|
|
// and then applies the shared op668 roster rule.
|
|
func (s *Service) admitRuntimeRaidPeer(raidKey uint32, leaderID uint16, applicantID uint16) (runtimeRaidState, bool, bool) {
|
|
if raidKey == 0 || leaderID == 0 || applicantID == 0 {
|
|
return runtimeRaidState{}, false, false
|
|
}
|
|
s.raidMu.Lock()
|
|
defer s.raidMu.Unlock()
|
|
state := s.raids[raidKey]
|
|
if state == nil || state.LeaderID != leaderID || !runtimeRaidOpenForPeerAdmission(*state) {
|
|
return runtimeRaidState{}, false, false
|
|
}
|
|
return s.rejoinRuntimeRaidAdmissionLocked(raidKey, applicantID)
|
|
}
|
|
|
|
func (s *Service) storeRuntimeRaidPeerAdmission(key runtimeRaidPeerAdmissionKey, pending runtimeRaidPeerAdmission, now time.Time) {
|
|
s.raidMu.Lock()
|
|
defer s.raidMu.Unlock()
|
|
if s.raidPeerAdmissions == nil {
|
|
s.raidPeerAdmissions = make(map[runtimeRaidPeerAdmissionKey]runtimeRaidPeerAdmission)
|
|
}
|
|
for existing, entry := range s.raidPeerAdmissions {
|
|
// One outstanding prompt per requester, and never keep expired rows.
|
|
if existing.Requester == key.Requester || !now.Before(entry.ExpiresAt) {
|
|
delete(s.raidPeerAdmissions, existing)
|
|
}
|
|
}
|
|
s.raidPeerAdmissions[key] = pending
|
|
}
|
|
|
|
func (s *Service) takeRuntimeRaidPeerAdmission(key runtimeRaidPeerAdmissionKey, now time.Time) (runtimeRaidPeerAdmission, bool) {
|
|
s.raidMu.Lock()
|
|
defer s.raidMu.Unlock()
|
|
pending, found := s.raidPeerAdmissions[key]
|
|
if !found {
|
|
return runtimeRaidPeerAdmission{}, false
|
|
}
|
|
delete(s.raidPeerAdmissions, key)
|
|
if !now.Before(pending.ExpiresAt) {
|
|
return runtimeRaidPeerAdmission{}, false
|
|
}
|
|
return pending, true
|
|
}
|
|
|
|
func (s *Service) removeRuntimeRaidPeerAdmission(key runtimeRaidPeerAdmissionKey) {
|
|
s.raidMu.Lock()
|
|
delete(s.raidPeerAdmissions, key)
|
|
s.raidMu.Unlock()
|
|
}
|