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,367 @@
package dnfbridge
import (
"context"
"encoding/binary"
"fmt"
"math"
"sort"
"longheng.io/server/internal/modules/dnf/adventuregroup"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
// Current DNF.exe registers the tower reward notifications in sub_19B2A30.
// sub_19B20A0/sub_19B2570 display the tower window and set the clear flag;
// ordinary op69/70/71 card selection is not part of that window's lifecycle.
const (
currentAdventureTowerCountsMsgID uint16 = 1294
currentTowerCharacterEntryMsgID uint16 = 546
currentTowerEmptyRewardItemID uint32 = math.MaxUint32
)
// sub_1D58B70 reads four u8 values, writes selector+0xB44 and refreshes
// the native tooltip. This class-0 notification is not the op1294 request.
func (s *Service) sendCurrentAdventureTowerCounts(ctx context.Context, session *gameSession, source string) error {
summary, err := s.currentAccountAdventureGroupSummary(ctx, dnfrepo.CharacterRecord{}, false, session)
if err != nil {
return err
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Account == nil {
return errAdventureGroupRosterUnavailable
}
account, found, err := repositories.Account.Load(ctx, s.accountIDForSession(session))
if err != nil {
return err
}
if !found {
return errAdventureGroupRosterUnavailable
}
var projection adventuregroup.Projection
s.projectAdventureTowerCounts(&projection, account, true, summary.ManageLevel)
character, err := s.dungeonCharacter(ctx, session.selectedCharacterID, session)
if err != nil {
return err
}
// op546 updates actor+0x66E0. Send both character entries before op1294
// rebuilds the tooltip, so the two independently consumed pools agree.
var characterRemaining [2]byte
now := s.gameplayNow()
for index, kind := range [...]dnfdungeon.TowerKind{dnfdungeon.TowerDespair, dnfdungeon.TowerGrief} {
characterRemaining[index] = dnfdungeon.TowerCharacterDailyRemaining(character, kind, now)
var entry packetWriter
entry.writeUint32([2]uint32{11008, 5115}[index])
entry.writeByte(characterRemaining[index])
if err := s.sendGameUpperRawClass(session, currentTowerCharacterEntryMsgID, entry.bytes(), 0); err != nil {
return err
}
}
var writer packetWriter
writeCurrentAdventureTowerCounters(&writer, projection)
if err := s.sendGameUpperRawClass(session, currentAdventureTowerCountsMsgID, writer.bytes(), 0); err != nil {
return err
}
s.logGameEvent(session, "game-adventure-tower-counts-sent", "source", source,
"msg_id", currentAdventureTowerCountsMsgID, "adventure_level", summary.ManageLevel,
"remaining", projection.TowerRemaining, "limits", projection.TowerLimits,
"character_remaining", characterRemaining)
return nil
}
func currentDungeonTowerKind(runtime *runtimeDungeonState) dnfdungeon.TowerKind {
switch currentDungeonTowerRewardMsgID(runtime) {
case currentDespairTowerClearRewardMsgID:
return dnfdungeon.TowerDespair
case currentGriefTowerClearRewardMsgID:
return dnfdungeon.TowerGrief
default:
return ""
}
}
// currentDungeonTowerClear builds the clear for this run. Despair grants what
// the player picked up; grief draws its ten PVF card slots from the run's
// server-owned completion key so a retry produces the same rewards.
func (s *Service) currentDungeonTowerClear(runtime *runtimeDungeonState) (*dnfdungeon.TowerClear, error) {
kind := currentDungeonTowerKind(runtime)
if kind == "" {
return nil, nil
}
floor, err := s.currentDungeonTowerFloor(runtime)
if err != nil {
return nil, err
}
clear := &dnfdungeon.TowerClear{Kind: kind, Floor: floor}
s.initialEquipmentMu.Lock()
archive, archiveErr := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if archiveErr != nil {
return nil, archiveErr
}
if kind == dnfdungeon.TowerGrief {
clear.Rewards, err = dnfdungeon.GriefTowerRewards(archive, floor, runtime.clearMapCompletionKey)
if err != nil {
return nil, err
}
items, err := s.currentPVFItemCatalog()
if err != nil {
return nil, err
}
clear.Grant = func(inventory *dnfrepo.InventoryRecord, itemID, count uint32) error {
definition, err := items.ResolveItem(itemID)
if err != nil {
return err
}
if definition.Kind != dungeonDropItemStackable || definition.InventoryListType != dnfrepo.MainInventoryListType {
return errDungeonPickupItemInvalid
}
_, err = currentQuestDropGrantMainStack(inventory, definition, count)
return err
}
}
return clear, nil
}
func (s *Service) validateCurrentTowerAdmission(ctx context.Context, session *gameSession, character dnfrepo.CharacterRecord, dungeon worldmap.Dungeon) error {
kind := currentDungeonTowerKind(&runtimeDungeonState{Dungeon: dungeon})
if kind == "" {
return nil
}
summary, err := s.currentAccountAdventureGroupSummary(ctx, character, true, session)
if err != nil {
return err
}
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return err
}
limit, err := dnfdungeon.TowerDailyEnterableLimit(archive, kind, summary.ManageLevel)
if err != nil {
return err
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Account == nil {
return errAdventureGroupRosterUnavailable
}
account, found, err := repositories.Account.Load(ctx, s.accountIDForSession(session))
if err != nil {
return err
}
if !found {
return errAdventureGroupRosterUnavailable
}
now := s.gameplayNow()
used, err := dnfdungeon.TowerDailyUsed(account, kind, now)
if err != nil {
return err
}
characterRemaining := dnfdungeon.TowerCharacterDailyRemaining(character, kind, now)
s.logGameEvent(session, "game-dungeon-tower-admission", "dungeon_id", dungeon.ID,
"kind", kind, "adventure_level", summary.ManageLevel, "used", used, "limit", limit,
"character_remaining", characterRemaining)
if used >= limit && characterRemaining == 0 {
return fmt.Errorf("%w: kind=%s used=%d limit=%d", dnfdungeon.ErrTowerDailyLimitReached, kind, used, limit)
}
return nil
}
func currentDungeonTowerDisplayRewards(runtime *runtimeDungeonState, character dnfrepo.CharacterRecord, pickups []currentDungeonClearRewardPair) ([]currentDungeonClearRewardPair, error) {
if currentDungeonTowerKind(runtime) != dnfdungeon.TowerGrief {
return pickups, nil
}
rewards, err := dnfdungeon.TowerLastRewards(character, dnfdungeon.TowerGrief)
if err != nil {
return nil, err
}
out := make([]currentDungeonClearRewardPair, len(rewards))
for i, reward := range rewards {
out[i] = currentDungeonClearRewardPair{Key: reward.ItemID, Value: reward.Count}
if reward.ItemID == 0 {
out[i].Key = currentTowerEmptyRewardItemID
}
}
return out, nil
}
// Both native selector icons may send their first-floor base dungeon. Resolve
// that alias using the persisted floor before applying the existing PVF route.
func (s *Service) resolveCurrentTowerDungeonSelection(character dnfrepo.CharacterRecord, dungeon worldmap.Dungeon) (int64, error) {
kind := currentDungeonTowerKind(&runtimeDungeonState{Dungeon: dungeon})
if kind == "" {
return dungeon.ID, nil
}
floor, err := dnfdungeon.TowerFloor(character, kind)
if err != nil {
return 0, err
}
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return 0, err
}
want, err := dnfdungeon.TowerDungeonID(archive, kind, floor)
if err != nil {
return 0, err
}
base, err := dnfdungeon.TowerDungeonID(archive, kind, 1)
if err != nil {
return 0, err
}
if dungeon.ID != base && dungeon.ID != want {
return 0, fmt.Errorf("tower %s floor mismatch: requested=%d expected=%d", kind, dungeon.ID, want)
}
return want, nil
}
func (s *Service) currentTowerSelectorContext(ctx context.Context, session *gameSession) (currentDungeonContext, error) {
packet := currentDungeonContext{}
character, err := s.dungeonCharacter(ctx, session.selectedCharacterID, session)
if err != nil {
return packet, err
}
packet.ContextValue1, err = dnfdungeon.TowerFloor(character, dnfdungeon.TowerDespair)
if err != nil {
return packet, err
}
packet.CappedValue, err = dnfdungeon.TowerFloor(character, dnfdungeon.TowerGrief)
return packet, err
}
func currentDungeonTowerRewardMsgID(runtime *runtimeDungeonState) uint16 {
if runtime == nil || currentDungeonRuntimeIsPractice(runtime) {
return 0
}
if currentDungeonIsTowerOfDespair(runtime.Dungeon) {
return currentDespairTowerClearRewardMsgID
}
if runtime.Dungeon.Metadata.Flags["tower of grief"] {
return currentGriefTowerClearRewardMsgID
}
for _, section := range runtime.Dungeon.SourceSections {
if normalizeDungeonPVFSymbol(section.Name) == "tower of grief" {
return currentGriefTowerClearRewardMsgID
}
}
return 0
}
func (s *Service) currentDungeonTowerFloor(runtime *runtimeDungeonState) (uint16, error) {
msgID := currentDungeonTowerRewardMsgID(runtime)
if msgID == 0 {
return 0, nil
}
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return 0, err
}
return resolveCurrentDungeonTowerFloor(archive, runtime.Dungeon, msgID)
}
// Project only actual pickups that still have a committed inventory receipt.
// Do not manufacture ordinary free/paid/Black Diamond card rewards for a tower.
// Amount is this run's pickup delta, not the destination stack's total count.
func currentDungeonTowerPickupRewards(runtime *runtimeDungeonState, receipts []currentDungeonClearRewardDrop) []currentDungeonClearRewardPair {
if runtime == nil || runtime.DropOwner == nil {
return nil
}
valid := make(map[uint16]currentDungeonClearRewardDrop, len(receipts))
for _, receipt := range receipts {
valid[receipt.ObjectKey] = receipt
}
bySlot := make(map[uint16]uint32)
for _, drop := range runtime.DropOwner.byObjectKey {
if drop == nil || drop.Status != runtimeDungeonDropConsumed || drop.Item.ItemID == 0 ||
drop.Amount == 0 || valid[drop.DestinationSlot].TemplateID != drop.Item.ItemID || len(drop.PickupEpicBookBody) != 0 {
continue
}
bySlot[drop.DestinationSlot] = saturatingCurrentDungeonUint32Add(bySlot[drop.DestinationSlot], drop.Amount)
}
discarded := currentDungeonActiveDiscardAmounts(runtime)
amounts := make(map[uint32]uint32)
for slot, amount := range bySlot {
receipt := valid[slot]
removed := discarded[currentDungeonDiscardReceiptKey{SourceSlot: int16(slot), ItemID: receipt.TemplateID}]
if removed >= uint64(amount) {
continue
}
amount = min(amount-uint32(removed), receipt.StackCount)
if amount != 0 {
amounts[receipt.TemplateID] = saturatingCurrentDungeonUint32Add(amounts[receipt.TemplateID], amount)
}
}
rewards := make([]currentDungeonClearRewardPair, 0, len(amounts))
for itemID, count := range amounts {
rewards = append(rewards, currentDungeonClearRewardPair{Key: itemID, Value: count})
}
sort.Slice(rewards, func(i, j int) bool { return rewards[i].Key < rewards[j].Key })
return rewards
}
func buildCurrentDungeonTowerRewardBody(elapsedMS uint32, floor uint16, rewards []currentDungeonClearRewardPair) ([]byte, error) {
if floor == 0 || floor > 100 || len(rewards) > dnfdungeon.TowerRewardSlots {
return nil, errCurrentDungeonSettlementPlanShape
}
// sub_18035F8 initializes empty cards to -1; sub_1803D77 draws their
// native blank face. Even a rewardless clear needs a participant panel.
rows := make([]currentDungeonClearRewardPair, dnfdungeon.TowerRewardSlots)
for i := range rows {
rows[i].Key = currentTowerEmptyRewardItemID
}
copy(rows, rewards)
var writer packetWriter
writer.writeUint32(elapsedMS)
writer.writeUint16(floor)
writer.writeByte(byte(len(rows)))
for _, reward := range rows {
if reward.Key == 0 || (reward.Key != currentTowerEmptyRewardItemID && reward.Value == 0) || (reward.Key == currentTowerEmptyRewardItemID && reward.Value != 0) {
return nil, errCurrentDungeonSettlementPlanShape
}
writer.writeUint32(reward.Key)
writer.writeUint32(reward.Value)
}
return writer.bytes(), nil
}
func currentDungeonTowerPlanValid(runtime *runtimeDungeonState, plan *currentDungeonSettlementPacketPlan) bool {
msgID := currentDungeonTowerRewardMsgID(runtime)
if msgID == 0 {
return plan.TowerRewardMsgID == 0 && len(plan.TowerRewardBody) == 0
}
body := plan.TowerRewardBody
if plan.TowerRewardMsgID != msgID || len(body) != 7+8*dnfdungeon.TowerRewardSlots || body[6] != dnfdungeon.TowerRewardSlots {
return false
}
floor := binary.LittleEndian.Uint16(body[4:6])
if floor == 0 || floor > 100 {
return false
}
for offset := 7; offset < len(body); offset += 8 {
id, count := binary.LittleEndian.Uint32(body[offset:]), binary.LittleEndian.Uint32(body[offset+4:])
if id == 0 || (id == currentTowerEmptyRewardItemID && count != 0) || (id != currentTowerEmptyRewardItemID && count == 0) {
return false
}
}
return true
}
func completedCurrentDungeonTowerSettlementExitReady(runtime *runtimeDungeonState, characterID uint16) bool {
return currentDungeonTowerRewardMsgID(runtime) != 0 && runtime.settlementTowerRewardSent &&
completedDungeonSettlementResultReady(runtime, characterID)
}
// currentQuestDropGrantMainStack places a stackable into the main inventory
// list, matching the ordinary quest-material grant contract.
func currentQuestDropGrantMainStack(inventory *dnfrepo.InventoryRecord, definition dungeonDropItemDefinition, count uint32) (int16, error) {
if definition.InventoryListType != dnfrepo.MainInventoryListType {
return 0, fmt.Errorf("quest material grant received list %d", definition.InventoryListType)
}
return currentQuestDropGrantStack(inventory, definition, count)
}