DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,204 @@
package dnfbridge
import (
"fmt"
"sort"
)
// party_selector_copresence.go —— 选择地下城页面(op15 选图页)上的**队内**共现。
//
// 症状与定位
//
// 玩家报的"出副本有时候再进会提示队友不在附近"是**客户端**自己判的距离:它手里的
// 队伍名册来自 op9,而"队友站在哪"只来自场景演员包(对端 mode0/mode1 + op23)。
// 服务端的同名判定是 currentPartyEntryCoPresence(同 城镇/区域/实例/频道),它
// 在这条路径上从来**没有**拒绝过(203.0.113.12 全天 game-party-dungeon-entry-
// copresence-refused = 0),也就是说服务端认为大家在同一个格子,客户端手里却没有
// 那些演员。
//
// 原因在 leaveTownPresenceForDungeonSelector 的运营决定里:角色一打开选图页就
// 从城镇名册上摘掉(选图页上的人不该被城镇里其他人看见)。这条摘除只针对**城镇
// 可见性**,但它顺带把 party 侧的演员扇出也一起断了 —— broadcastCurrentPartyActor
// Projection 走的是 PeersInSameArea(名册),名册里没有这条记录,于是:
// · 选图页上的人收不到队友的演员包;
// · 队友也收不到他的演员包。
// 两者在同一个 op6 里本来是被**刻意保留**的("op6 对同队成员不下发",否则客户端
// 会把它当成退队),也就是说"队内互相看得见"本来就是既定语义,缺的只是这两份
// 演员投影。
//
// 本文件做什么
//
// 在选图页那条路径上,按**队伍**(而不是城镇名册)补一次双向演员投影,使用的都是
// 已被回城共现验证过的包型(对端 mode0/mode1 + op23 区域通知),不碰 op11(op11 是
// 组队握手,重放会把已经健康的 P2P 链路拆掉,重新变成"连接中")。
//
// 边界
//
// - 只发给同一个队伍里**锚点一致**的成员(同一 城镇/区域/实例/频道)。锚点不一致
// 时客户端的"不在附近"是正确的,不去掩盖它,只记原因。
// - 只有多人队才发;单人开页面是高频动作,不能因此产生任何包或日志。
// - 攻坚频道整体跳过:等待地图是服务端私有实例,队友演员由攻坚自己的投影
// (raid_dungeon_loaded_raid_mate_actors)负责,不在这里叠第二套。
// - 出错不返回错误:这条路径挂在 op15 的 ACK 之后,选图页不能因为一次演员投影
// 失败而回滚。
func (s *Service) rebuildRuntimePartySelectorPageCoPresence(session *gameSession, source string) int {
if s == nil || session == nil || session.selectedCharacterID == 0 || s.onlinePlayers == nil {
return 0
}
state := runtimePartyStateSnapshot(session)
members := runtimePartyMembers(state)
if state.PartyID <= 0 || len(members) <= 1 {
return 0
}
if _, raidChannel := currentRaidTownSpecForSession(session); raidChannel {
s.logGameEvent(session, "game-party-selector-copresence-skipped",
"source", source,
"party_id", state.PartyID,
"member_count", len(members),
"char_id", session.selectedCharacterID,
"reason", "raid_waiting_instance_owns_its_own_mate_actor_projection")
return 0
}
selfPresence, selfResolved := s.currentTownActorAreaDetachPresence(session)
if !selfResolved {
s.logGameEvent(session, "game-party-selector-copresence-deferred",
"source", source,
"party_id", state.PartyID,
"member_count", len(members),
"char_id", session.selectedCharacterID,
"reason", "selector_owner_has_no_resolvable_town_anchor")
s.recordRaidEvent(session, "town_return_copresence", map[string]any{
"source": source,
"scope": "selector_page_party_only",
"party_id": state.PartyID,
"member_count": len(members),
"projected": 0,
"result": "deferred",
"reason": "selector_owner_has_no_resolvable_town_anchor",
"packet_sequence": "remote_mode0_mode1_then_op23_both_directions",
"op11_replayed": false,
"visibility_scope": "party_members_only",
})
return 0
}
projected := 0
skipped := make(map[string][]uint16)
recordSkip := func(reason string, memberID uint16) {
skipped[reason] = append(skipped[reason], memberID)
}
for _, member := range members {
if member.UserID == 0 || member.UserID == session.selectedCharacterID {
continue
}
memberSession, online := s.onlineGameSession(member.UserID)
if !online || memberSession == nil || memberSession == session {
recordSkip("member_session_offline", member.UserID)
continue
}
// The anchor test is the same one party dungeon entry makes, so this pass
// can never make the client believe two members share a spot the server
// would refuse.
if coPresent, reason := s.currentPartyEntryCoPresence(session, memberSession); !coPresent {
recordSkip(reason, member.UserID)
continue
}
memberPresence, memberResolved := s.currentTownActorAreaDetachPresence(memberSession)
if !memberResolved {
recordSkip("member_has_no_resolvable_town_anchor", member.UserID)
continue
}
if err := s.projectSelectorPartyActorPair(session, selfPresence, memberSession, memberPresence, source); err != nil {
s.logGameEvent(session, "game-party-selector-copresence-projection-failed",
"source", source,
"party_id", state.PartyID,
"char_id", session.selectedCharacterID,
"member_id", member.UserID,
"error", err)
recordSkip("projection_write_failed", member.UserID)
continue
}
projected++
}
reasons := make([]string, 0, len(skipped))
for reason := range skipped {
reasons = append(reasons, reason)
}
sort.Strings(reasons)
fields := map[string]any{
"source": source,
"scope": "selector_page_party_only",
"party_id": state.PartyID,
"member_count": len(members),
"projected": projected,
"skipped": len(members) - 1 - projected,
"result": "rebuilt",
"reason": "selector_page_party_members_need_each_others_town_actor_for_the_client_side_proximity_check",
"packet_sequence": "remote_mode0_mode1_then_op23_both_directions",
"op11_replayed": false,
"visibility_scope": "party_members_only",
}
if len(reasons) != 0 {
fields["skip_reasons"] = reasons
for _, reason := range reasons {
fields["skipped_"+reason] = skipped[reason]
}
}
s.logGameEvent(session, "game-party-selector-copresence-rebuilt", fields)
s.recordRaidEvent(session, "town_return_copresence", fields)
return projected
}
// projectSelectorPartyActorPair writes both directions of one party pair's town
// actor projection. The selector-page session is not on the town roster, so the
// ordinary area fan-out skips it; the actor bodies themselves are the same ones
// the completed-return co-presence rebuild uses.
//
// Deliberately the RECORD form (mode0 + the op9 party frame, no mode1 binding).
// mode1 describes the *receiver's own* HP/MP/stat and worn-equipment rows: a
// peer's copy has no consumer here and the client's native handler applies it to
// the actor it controls. It is also the expensive half -- the measured raid
// fan-out carried 15.7 MB of mode1 in 1.7 h, every full body recomputing
// equipment attributes through the PVF (online_player_manager.go, "every one of
// them carrying a mode1 half") -- and the operator's rule is that too much mode1
// on the wire stutters the client. The record form is what the channel-wide
// co-presence pass (sendChannelWidePeerActors) already uses.
func (s *Service) projectSelectorPartyActorPair(
selector *gameSession,
selectorPresence onlinePlayerInfo,
memberSession *gameSession,
memberPresence onlinePlayerInfo,
source string,
) error {
if err := s.sendTownRemoteActorRecord(memberSession, selectorPresence, source+"_selector_owner_to_member"); err != nil {
return fmt.Errorf("selector owner actor to member %d: %w", selectedCharacterID(memberSession), err)
}
if err := s.sendSelectorPartyActorAreaNotification(memberSession, selectorPresence, source+"_selector_owner_area_to_member"); err != nil {
return err
}
if err := s.sendTownRemoteActorRecord(selector, memberPresence, source+"_member_to_selector_owner"); err != nil {
return fmt.Errorf("member %d actor to selector owner: %w", selectedCharacterID(memberSession), err)
}
return s.sendSelectorPartyActorAreaNotification(selector, memberPresence, source+"_member_area_to_selector_owner")
}
func (s *Service) sendSelectorPartyActorAreaNotification(
receiver *gameSession,
actor onlinePlayerInfo,
source string,
) error {
if receiver == nil || actor.CharacterID == 0 {
return nil
}
body := buildCurrentTownUserAreaNotificationBody(
currentSceneActorObjectKey(actor.CharacterID),
actor.TownID,
actor.AreaID,
actor.PositionX,
actor.PositionY,
actor.Direction,
actor.AreaState,
)
return s.sendCurrentSceneFixedClass0Packet(receiver, currentTownUserAreaNotificationMsgID, body, source)
}