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

276 lines
9.8 KiB
Go

package dnfbridge
import (
"encoding/binary"
"sort"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/party"
)
const (
currentPartyDirectoryMsgID = uint16(dnfenum.CmdPacketCompleteDisplay)
currentPartyDirectoryJoin = uint16(dnfenum.CmdPacketEnterWarroom)
currentPartyDirectoryQuery = uint16(dnfenum.CmdPacketQuickJoinRoom)
runtimePartyDirectoryRequestModeFull = byte(0)
runtimePartyDirectoryRequestModeRegion = byte(1)
)
// runtimePartyDirectoryRecords takes an immutable, deterministic projection of
// every active server-side party. Resident channel IDs do not belong in op87:
// the current EXE interprets that byte as a bounded directory discriminator.
// party.BuildDirectorySnapshot owns the ordinary-directory discriminator.
func (s *Service) runtimePartyDirectoryRecords() []party.DirectoryRecord {
// Importing a pre-rewrite client cache is a one-time compatibility path.
// The returned directory is nevertheless sourced only from the central
// manager, never by selecting whichever session cache happens to be read
// last.
for _, session := range s.runtimePartyDirectorySessions() {
s.bootstrapManagedRuntimePartyForSession(session)
}
manager := s.runtimePartyManagerForService()
if manager == nil {
return nil
}
snapshots := manager.Snapshots()
records := make([]party.DirectoryRecord, 0, len(snapshots))
for _, snapshot := range snapshots {
if snapshot.ID == 0 || len(snapshot.Members) == 0 {
continue
}
memberIDs := make([]uint16, 0, len(snapshot.Members))
for _, member := range snapshot.Members {
memberIDs = append(memberIDs, member.UserID)
}
records = append(records, party.DirectoryRecord{
PartyID: snapshot.ID,
SelectionID: snapshot.Settings.SelectionID,
MemberIDs: memberIDs,
})
}
return records
}
func (s *Service) runtimePartyDirectorySessions() []*gameSession {
if s == nil {
return nil
}
s.mu.Lock()
sessions := make([]*gameSession, 0, len(s.gameSessions))
for _, session := range s.gameSessions {
if session != nil && session.selectedCharacterID != 0 {
sessions = append(sessions, session)
}
}
s.mu.Unlock()
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].selectedCharacterID < sessions[j].selectedCharacterID
})
return sessions
}
func (s *Service) sendRuntimePartyDirectorySnapshot(session *gameSession, requestMode byte, source string) error {
if session == nil || session.selectedCharacterID == 0 || session.conn == nil {
return nil
}
records := s.runtimePartyDirectoryRecords()
body := party.BuildDirectorySnapshot(records)
s.logPacketEvent("game-party-directory-op87-send",
"conn_id", session.connID,
"channel_id", session.channel.ID,
"source", source,
"request_mode", requestMode,
"char_id", session.selectedCharacterID,
"msg_id", currentPartyDirectoryMsgID,
"classification", 0,
"party_count", len(records),
"directory_type", party.DirectoryTypeOrdinary,
"body_len", len(body),
"body_source", "current_exe_sub_1D3F040_directory_type_minus_protected_base_2_and_fixed_28_byte_records")
return s.sendGameUpperRawClass(session, currentPartyDirectoryMsgID, body, 0)
}
func runtimePartyDirectorySessionReady(session *gameSession) bool {
if session == nil || session.selectedCharacterID == 0 || session.conn == nil {
return false
}
session.townMu.Lock()
ready := session.townSceneReadyCharacterID == session.selectedCharacterID ||
session.sceneBootstrapTailSent ||
session.postStartMapPlayerStateSent
session.townMu.Unlock()
return ready && !currentDungeonSceneActive(session)
}
func (s *Service) handleRuntimePartyDirectoryRefresh(session *gameSession, body []byte) (bool, error) {
if len(body) != 1 || !runtimePartyDirectorySessionReady(session) {
return false, nil
}
if isPVPChannel(session.channel) && body[0] <= 1 {
return true, s.sendCurrentPVPDirectory(session)
}
requestMode := body[0]
source := ""
switch requestMode {
case runtimePartyDirectoryRequestModeFull:
source = "current_exe_op98_full_directory"
case runtimePartyDirectoryRequestModeRegion:
source = "current_exe_op98_region_summary"
default:
return false, nil
}
s.logGameEvent(session, "game-party-directory-refresh-request",
"type", currentPartyDirectoryQuery,
"body_len", len(body),
"request_mode", requestMode,
"source", source)
return true, s.sendRuntimePartyDirectorySnapshot(session, requestMode, source)
}
func (s *Service) handleRuntimePartyDirectoryJoin(session *gameSession, body []byte) (bool, error) {
if len(body) != 2 || session == nil || session.selectedCharacterID == 0 ||
currentDungeonSceneActive(session) {
return false, nil
}
partyID := binary.LittleEndian.Uint16(body)
targetSession, targetState, found := s.runtimePartyDirectoryTarget(partyID)
if !found {
return false, nil
}
if targetSession == session || containsRuntimePartyMember(runtimePartyMembers(targetState), session.selectedCharacterID) {
if err := s.sendGameUpperRawClass(session, currentPartyDirectoryJoin, nil, 0); err != nil {
return true, err
}
return true, s.sendRuntimePartySnapshot(session, targetState)
}
state, result, ok := s.createOrJoinManagedRuntimeParty(session, targetSession)
if !ok {
s.logGameEvent(session, "game-party-directory-join-rejected",
"type", currentPartyDirectoryJoin,
"party_id", partyID,
"source_char_id", session.selectedCharacterID,
"reason", result.Reason)
return true, s.sendGameUpperRawClass(session, currentPartyDirectoryJoin, nil, 0)
}
if err := s.sendGameUpperRawClass(session, currentPartyDirectoryJoin, nil, 0); err != nil {
return true, err
}
if err := s.sendManagedRuntimePartySnapshots(state); err != nil {
return true, err
}
s.logGameEvent(session, "game-party-directory-join-complete",
"type", currentPartyDirectoryJoin,
"party_id", partyID,
"source_char_id", session.selectedCharacterID,
"leader_char_id", state.UserID,
"member_count", len(runtimePartyMembers(state)))
return true, nil
}
func (s *Service) runtimePartyDirectoryTarget(partyID uint16) (*gameSession, alignedcmd.PartyState, bool) {
if partyID == 0 || partyID == ^uint16(0) {
return nil, alignedcmd.PartyState{}, false
}
// Make direct op90 joins work even if the requester did not first open the
// op87 directory panel.
for _, session := range s.runtimePartyDirectorySessions() {
s.bootstrapManagedRuntimePartyForSession(session)
}
manager := s.runtimePartyManagerForService()
snapshot, found := manager.SnapshotByID(partyID)
if !found {
return nil, alignedcmd.PartyState{}, false
}
leader, online := s.onlineGameSession(snapshot.Leader)
if !online {
return nil, alignedcmd.PartyState{}, false
}
return leader, snapshot.StateFor(snapshot.Leader), true
}
// detachRuntimePartySession removes a disconnecting character from its party.
//
// When the session still holds a shared party-dungeon binding, the detach goes
// through the same confirmed town-return projection a walking member uses: the
// shared dungeon roster drops the row, the survivors get op26 re-pointed at the
// new leader slot and scene op6 retires the disconnected actor. Without that, a
// mid-dungeon disconnect left the roster row, the op26 host slot and the
// client-side model behind -- owner.removeMember has no other caller, and
// releaseDungeonRuntimePayload only clears the pointer on the departing
// session's own runtime. unbindGameSession therefore calls this before it
// releases the dungeon attempt.
func (s *Service) detachRuntimePartySession(session *gameSession, source string) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return
}
identity, bound := s.boundGameSessionCharacterSnapshot(session)
if !bound {
return
}
state := runtimePartyStateSnapshot(session)
if owner, partyState := s.currentDisconnectedPartyDungeonBinding(session); owner != nil &&
partyState.PartyID > 0 {
if err := s.detachRuntimePartyDungeonMemberForTownReturn(
session,
owner,
partyState,
source+"_party_dungeon_session_detach",
); err != nil {
s.logWarn("dnfbridge deferred party dungeon member removal during session detach",
"source", source,
"char_id", identity.character,
"party_id", partyState.PartyID,
"error", err)
}
return
}
if !s.hasManagedRuntimeParty(session) && state.PartyID <= 0 {
return
}
nextState, _, removed := s.leaveManagedRuntimeParty(identity)
if !removed {
storeRuntimePartyState(session, alignedcmd.PartyState{})
return
}
if nextState.PartyID > 0 {
s.syncRuntimePartyUDPRelay(nextState)
} else if state.PartyID > 0 {
s.closeRuntimePartyUDPRelay(state.PartyID)
}
if err := s.broadcastRuntimePartyMemberRemoved(session, state, nextState, identity.character, session); err != nil {
s.logWarn("dnfbridge deferred party member removal during session detach",
"source", source,
"char_id", identity.character,
"party_id", nextState.PartyID,
"error", err)
}
}
// currentDisconnectedPartyDungeonBinding snapshots the shared party-dungeon
// binding a session still holds, plus the party roster it belongs to.
//
// It has to run before releaseDisconnectedDungeonAttempt: that call retires the
// runtime and releases its payload, and releaseDungeonRuntimePayload is what
// clears runtime.partyDungeonOwner. Asking afterwards always answers "no
// binding", which is why a disconnect inside a party dungeon never reduced the
// shared roster.
func (s *Service) currentDisconnectedPartyDungeonBinding(
session *gameSession,
) (*runtimePartyDungeonOwner, alignedcmd.PartyState) {
if s == nil || session == nil {
return nil, alignedcmd.PartyState{}
}
session.dungeon.mu.Lock()
owner := (*runtimePartyDungeonOwner)(nil)
if session.dungeon.runtime != nil {
owner = session.dungeon.runtime.partyDungeonOwner
}
session.dungeon.mu.Unlock()
if owner == nil {
return nil, alignedcmd.PartyState{}
}
return owner, runtimePartyStateSnapshot(session)
}