Files
90CNServer/server/internal/services/dnfbridge/dungeon_hell_party_button.go
T
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

267 lines
10 KiB
Go

package dnfbridge
import (
"context"
"fmt"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
// The world map page's 挑战深渊派对 button is greyed by exactly one server-owned
// input: the first list of the class0/op27 ENTER_SELECT_DUNGEON notification.
//
// Client evidence (DNF.exe is the current EXE; the same binary the IDA database
// D:\ida_copy\NoPack.exe.i64 was built from -- the string-xref scan below was
// re-run on our own client-patch\bin\DNF.exe and matched every address):
//
// op27 class0 reader sub_1D868B0
// 0x01D86B84 call 0x34E3970 read u8 -> list0 count
// 0x01D86B98 lea edi,[ebp-24] 8 x u16 scratch array (0xFFFF filled)
// 0x01D86BA0 call 0x34E39E0 read u16 -> array, count times
// 0x01D86BCC mov [ebp-450], 0 count > 0 => hell button disabled
// 0x01D86BD9 push 0x1537 5431 "因存在未完成深渊派对任务的队员,无法激活深渊派对按钮。"
// 0x01D86F79 call sub_341B6A0 publish (hellEnabled, dungeonSupportsHell)
// 0x0341B740 mov [esi+0x438],dl the selection window's hell state
//
// sub_341B6A0 has exactly two callers: sub_1D868B0 above and the selector
// teardown at 0x01CF8EB8 (which clears it with (0,0)). Its two local gates
// (sub_3419C60: actor byte at +0x220 and a party word at +0x66) never consult
// the quest manager, so a quest-driven grey-out can only arrive through this
// packet. The list's *values* are read into a scratch array and never used
// again -- the client only counts entries -- so an entry list can only ever
// disable the button, never enable it.
//
// Wire layout inside the op27 body (verified against the reader, the C#
// reference and the live body):
//
// +0 u32, +4 u32, +8 u8 count, +9 count x u16 slots
//
// The shipped body is 37 bytes; every added slot adds 2 bytes.
//
// The reference servers compute the list the same way (86JP and S4A21 are the
// same file): Network/Handlers/Dungeon/DungeonEntryHandler.cs
// BuildHellPartySelectionState + Network/Builders/Dungeon/
// EnterSelectDungeonStateBuilder.cs BuildA21EnterSelectDungeon(userIds,
// hellQuestBlockedSlots) -- for every party member (solo is a one-member party
// at slot 0), when the current world map area owns [hell dungeon] and the member
// fails CheckHellQuestRequirement (Game/Dungeon/DungeonEntryCostService.cs),
// that member's SlotIndex goes into the list.
const currentHellPartyBlockedSlotLimit = 8
// currentHellPartyButtonMember is one participant of the selector page whose
// eligibility the client renders. CharacterID is the runtime character id the
// quest and permission repositories are keyed by; Slot is the native party slot
// carried by class0/op9 (alignedcmd.PartyMemberState.Slot).
type currentHellPartyButtonMember struct {
CharacterID uint16
Slot uint16
}
// currentHellPartyButtonBlockedSlots projects the party members who may not
// challenge the current area's hell party onto the op27 list0 entries.
//
// Fail-open contract: this runs on the select-page critical path, so every
// unexpected condition (no repositories, no world map, no town gate, repository
// error, timeout) returns nil and the packet stays byte-identical to what it
// was before this projection existed. Only a proven "member is not entitled"
// adds an entry.
func (s *Service) currentHellPartyButtonBlockedSlots(session *gameSession, source string) []uint16 {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
area, found := s.currentSelectorHellPartyArea(session)
if !found {
return nil
}
repositories, available := s.repositoryGroup()
if !available || repositories.Quest == nil || repositories.HellPartyPermission == nil {
s.logGameEvent(session, "game-upper-current-dungeon-context-hell-blocked-skipped",
"source", source,
"char_id", session.selectedCharacterID,
"area_id", area.ID,
"reason", "repository_unavailable")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
started := time.Now()
members := s.currentSelectorHellPartyMembers(session)
blocked := make([]uint16, 0, len(members))
for _, member := range members {
if member.CharacterID == 0 {
continue
}
granted, err := s.currentHellPartyAreaGranted(ctx, repositories, member.CharacterID, area)
if err != nil {
s.logGameEvent(session, "game-upper-current-dungeon-context-hell-blocked-skipped",
"source", source,
"char_id", session.selectedCharacterID,
"area_id", area.ID,
"member_char_id", member.CharacterID,
"reason", "eligibility_unavailable",
"error", err)
return nil
}
if !granted {
blocked = append(blocked, member.Slot)
}
}
if len(blocked) > currentHellPartyBlockedSlotLimit {
// The client reads this list into an eight entry array; over-long lists
// would run past it.
blocked = blocked[:currentHellPartyBlockedSlotLimit]
}
s.logGameEvent(session, "game-upper-current-dungeon-context-hell-blocked",
"source", source,
"char_id", session.selectedCharacterID,
"area_id", area.ID,
"hell_quest_ids", area.HellQuestIDs,
"party_members", len(members),
"blocked_slots", blocked,
"elapsed_ms", time.Since(started).Milliseconds(),
"sequence", "current_exe_class0_op27_list0_hell_quest_blocked_slots")
return blocked
}
// currentSelectorHellPartyArea resolves the world map area the open select page
// belongs to, and reports whether that area owns a hell dungeon.
//
// The page is owned by the selection owner (the party leader), so a follower
// resolves the leader's frozen selector origin first and only falls back to its
// own. The town-area link is the PVF's own [dungeon gate] value, which is a
// world map area id -- evidence: town 22 area 3 (Elvengard_hendon.map) declares
// [dungeon gate] 114, CentralPark declares 39, and the hell-party permission
// rows written for those gates are keyed by exactly those area ids (area 39 in
// the GM unlock path). The reference server reads the same value as
// Town.cs CeraRoomInfo.WorldMapAreaId = area.LinkedId.
func (s *Service) currentSelectorHellPartyArea(session *gameSession) (worldmap.Area, bool) {
owner := s.currentSelectorOwnerSession(session)
if area, found := s.selectorHellPartyAreaForSession(owner); found {
return area, true
}
if owner != nil && owner != session {
if area, found := s.selectorHellPartyAreaForSession(session); found {
return area, true
}
}
return worldmap.Area{}, false
}
func (s *Service) selectorHellPartyAreaForSession(session *gameSession) (worldmap.Area, bool) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return worldmap.Area{}, false
}
session.townMu.Lock()
snapshot := session.townPositionSnapshot
session.townMu.Unlock()
if !snapshot.PositionValid || snapshot.CharacterID != session.selectedCharacterID {
return worldmap.Area{}, false
}
gate, found := s.townArea(int64(snapshot.TownID), int64(snapshot.AreaID))
if !found || gate.DungeonGate == nil || *gate.DungeonGate <= 0 {
return worldmap.Area{}, false
}
table, _, err := s.dungeonWorldMap()
if err != nil {
return worldmap.Area{}, false
}
area, found := table.FindArea(*gate.DungeonGate)
if !found || !area.HellDungeon.Set || area.HellDungeon.Value != 1 || len(area.HellQuestIDs) == 0 {
return worldmap.Area{}, false
}
return area, true
}
// currentSelectorOwnerSession returns the session that owns the open selection
// page: the party leader for a follower, otherwise the session itself. A page
// opened by a leader is shared by every member, but each member receives its own
// op27, so the followers resolve the leader's town gate rather than their own.
func (s *Service) currentSelectorOwnerSession(session *gameSession) *gameSession {
if s == nil || session == nil {
return nil
}
state := runtimePartyStateSnapshot(session)
if state.PartyID <= 0 || state.UserID == 0 || state.UserID == session.selectedCharacterID {
return session
}
if leader, found := s.onlineGameSession(state.UserID); found && leader != nil {
return leader
}
return session
}
// currentSelectorHellPartyMembers lists the characters whose eligibility the
// client evaluates. A character without a runtime party is the one-member party
// the reference builder uses, at slot 0.
func (s *Service) currentSelectorHellPartyMembers(session *gameSession) []currentHellPartyButtonMember {
if session == nil || session.selectedCharacterID == 0 {
return nil
}
state := runtimePartyStateSnapshot(session)
if state.PartyID > 0 {
members := make([]currentHellPartyButtonMember, 0, len(state.Members))
for _, member := range runtimePartyMembers(state) {
if member.UserID == 0 {
continue
}
members = append(members, currentHellPartyButtonMember{
CharacterID: member.UserID,
Slot: uint16(member.Slot),
})
}
if len(members) != 0 {
return members
}
}
return []currentHellPartyButtonMember{{
CharacterID: session.selectedCharacterID,
Slot: 0,
}}
}
// currentHellPartyAreaGranted is the single accepted proof that a character may
// challenge the hell party of one world map area. The dungeon entry gate
// (authorizeCurrentDungeonHellParty) and the selector button projection must
// never diverge: a greyed button with an accepted entry, or the reverse, is the
// bug this helper exists to prevent.
func (s *Service) currentHellPartyAreaGranted(
ctx context.Context,
repositories dnfrepo.Group,
characterID uint16,
area worldmap.Area,
) (bool, error) {
if repositories.HellPartyPermission == nil || characterID == 0 {
return false, nil
}
characterKey := fmt.Sprintf("%d", characterID)
permission, found, err := repositories.HellPartyPermission.Load(ctx, characterKey)
if err != nil {
return false, err
}
if found {
for _, entry := range permission.Entries {
if entry.AreaID == area.ID {
return true, nil
}
}
}
// The durable permission row is written only by the normal finish path of a
// [hell challenge] quest. A quest that was marked completed any other way
// (GM "unlock all Hell", records that predate the permission table) never
// reaches that path again, so the persisted completed state of the area's
// [hell quest] is the second accepted proof.
if repositories.Quest == nil {
return false, nil
}
quests, found, err := repositories.Quest.Load(ctx, characterKey)
if err != nil {
return false, err
}
if found && currentHellQuestRecordUnlocksArea(quests, area) {
return true, nil
}
return false, nil
}