服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
267 lines
10 KiB
Go
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
|
|
}
|