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

311 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
cryptorand "crypto/rand"
"errors"
"fmt"
"math/big"
"strconv"
"time"
dnfenum "longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/onlineevent"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const attendanceMarblePVFPath = onlineevent.MarblePVFPath
func attendanceMarbleGameplayModule() gameplayModuleDefinition {
info := uint16(dnfenum.CmdPacketAttendanceMarbleEventInfo)
dice := uint16(dnfenum.CmdPacketAttendanceMarbleEventDice)
doubleExit := uint16(dnfenum.CmdPacketAttendanceMarbleDoubleExit)
// 「累计在线得好礼」(event 2347) 的「领取」按钮走 op285。
//
// 背景:客户端点「领取」发的是 CmdPacketOntimeEventRequestReward(285)。本树
// 此前**完全没有注册这个 opcode**,该指令被当作"未处理指令"静默丢弃且不回包,
// 表现就是"点击没有反应"。服务端本来只在活动 tick 里自动结算与发放,所以奖励
// 并非拿不到,而是按钮永远没有反馈。
//
// 补上 handler:把已达标的阶段补齐,并把物品回执(op14)与进度(op1206)推给客户端。
ontimeReward := uint16(dnfenum.CmdPacketOntimeEventRequestReward)
// 【2026-10-12 修复】「累计在线得好礼」面板上的「领取」按钮,客户端实际发的是
// op632(CmdPacketGetPcroomTimePointItem,body = [daily, 0xff] 或 [0x10, period])。
//
// 实测证据(明文探针,客户端点「领取」时抓到):
// 17:27:04.727 CMD opcode=632 name=ENUM_CMDPACKET_GET_PCROOM_TIME_POINT_ITEM
// class=1 len=2 body=02ff ← daily=2 即"领取第 2 档"
// 而 op285 **从未出现过** —— 所以之前补的 285 handler 是白补的。
//
// 本树早就实现了处理函数 handleCurrentPCRoomTimePoint(pcroom_timepoint.go:97,
// 它会调用 settleCurrentPCRoomTimePoint 并通过 op561 回状态),但
// `currentPCRoomTimePointRequestID = 632` 这个常量**从未被任何地方引用**,
// 也就是说这个 handler 从来没有注册过 —— 客户端发的 632 被当成"未处理指令"
// 静默丢弃、不回包,表现就是"点领取没有反应"。
pcroomTimePoint := uint16(dnfenum.CmdPacketGetPcroomTimePointItem)
return gameplayModuleDefinition{
Name: "attendance-marble",
LegacyHandlers: map[uint16]gameplayHandler{
info: func(s *Service, session *gameSession, request gameplayRequest) error {
return s.handleAttendanceMarbleInfo(session, request.Body)
},
dice: func(s *Service, session *gameSession, request gameplayRequest) error {
return s.handleAttendanceMarbleDice(session, request.Body)
},
doubleExit: func(s *Service, session *gameSession, request gameplayRequest) error {
return s.handleAttendanceMarbleDoubleExit(session, request.Body)
},
ontimeReward: func(s *Service, session *gameSession, request gameplayRequest) error {
return s.handleCurrentSpendTimeClaim(session, request.Body)
},
pcroomTimePoint: func(s *Service, session *gameSession, request gameplayRequest) error {
return s.handleCurrentPCRoomTimePoint(session, request.Body)
},
},
UpperHandlers: map[uint16]gameplayHandler{
info: defaultClassGameplayHandler("game-attendance-marble-info-class-blocked", "current_exe_attendance_marble_info_requires_default_class", func(s *Service, session *gameSession, body []byte) error {
return s.handleAttendanceMarbleInfo(session, body)
}),
dice: defaultClassGameplayHandler("game-attendance-marble-dice-class-blocked", "current_exe_attendance_marble_dice_requires_default_class", func(s *Service, session *gameSession, body []byte) error {
return s.handleAttendanceMarbleDice(session, body)
}),
doubleExit: defaultClassGameplayHandler("game-attendance-marble-double-exit-class-blocked", "current_exe_attendance_marble_double_exit_requires_default_class", func(s *Service, session *gameSession, body []byte) error {
return s.handleAttendanceMarbleDoubleExit(session, body)
}),
ontimeReward: defaultClassGameplayHandler("game-ontime-event-reward-class-blocked", "current_exe_ontime_event_reward_requires_default_class", func(s *Service, session *gameSession, body []byte) error {
return s.handleCurrentSpendTimeClaim(session, body)
}),
pcroomTimePoint: defaultClassGameplayHandler("game-pcroom-timepoint-class-blocked", "current_exe_pcroom_timepoint_requires_default_class", func(s *Service, session *gameSession, body []byte) error {
return s.handleCurrentPCRoomTimePoint(session, body)
}),
},
}
}
func (s *Service) attendanceMarbleFailure(session *gameSession, opcode uint16, err error) error {
s.logGameEvent(session, "game-attendance-marble-rejected", "opcode", opcode, "reason", err)
code := byte(19)
if errors.Is(err, onlineevent.ErrMarbleSpent) {
code = 22
}
if errors.Is(err, onlineevent.ErrMarbleGold) {
code = 9
}
return s.sendGameUpperRawClass(session, opcode, []byte{0, code}, 1)
}
func (s *Service) handleAttendanceMarbleInfo(session *gameSession, body []byte) error {
if err := parseAttendanceMarbleRequestInfo(body); err != nil {
return s.attendanceMarbleFailure(session, 629, err)
}
state, err := s.settleAttendanceMarble(session, nil, true)
if err != nil {
return s.attendanceMarbleFailure(session, 629, err)
}
s.logGameEvent(session, "game-attendance-marble-info-send", "position", state.Position, "round", state.Round, "earned", state.Turns, "remaining", state.Remaining(), "clears", state.Clears)
if err := s.sendGameUpperSuccess(session, 629, buildAttendanceMarbleInfoBody(state)); err != nil {
return err
}
for i := 29; i <= 32; i++ {
if state.PendingBuffs[i-29] > 0 {
if err := s.sendAttendanceMarbleBuff(session, i, state.PendingBuffs[i-29]); err != nil {
return err
}
}
}
return nil
}
func (s *Service) handleAttendanceMarbleDice(session *gameSession, body []byte) error {
_, err := parseAttendanceMarbleRequestDice(body)
var dice [2]byte
if err == nil {
dice, err = secureAttendanceMarbleDice()
}
var state attendanceMarbleState
if err == nil {
state, err = s.settleAttendanceMarble(session, &dice, false)
}
if err != nil {
if sendErr := s.attendanceMarbleFailure(session, 630, err); sendErr != nil {
return sendErr
}
return s.handleAttendanceMarbleInfo(session, nil)
}
// Native sub_1885380 animates both dice and advances the board. An immediate
// info packet would reset that animation before it reaches the reward cell.
if err := s.sendGameUpperSuccess(session, 630, buildAttendanceMarbleDiceBody(dice, state.Remaining(), state.Round)); err != nil {
return err
}
if state.GoldChanged {
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), buildCurrentGoldStateBody(state.GoldAfter), 0); err != nil {
return err
}
}
s.logGameEvent(session, "game-attendance-marble-dice-committed", "dice", dice, "position", state.Position, "round", state.Round, "remaining", state.Remaining(), "double", state.Double, "mail_count", len(state.MailIDs))
if state.BuffIndex != 0 {
if err := s.sendAttendanceMarbleBuff(session, state.BuffIndex, state.PendingBuffs[state.BuffIndex-29]); err != nil {
return err
}
}
if len(state.MailIDs) > 0 {
return s.sendMailboxAlarmForSession(session, session.selectedCharacterID, "attendance_marble")
}
return nil
}
func (s *Service) sendAttendanceMarbleBuff(session *gameSession, index int, remaining int64) error {
var remove packetWriter
remove.writeByte(1)
remove.writeUint32(uint32(index))
if err := s.sendGameUpperRawClass(session, 488, remove.bytes(), 0); err != nil {
return err
}
var body packetWriter
body.writeByte(1)
body.writeUint32(uint32(index))
body.writeUint32(uint32(remaining))
body.writeUint32(0)
body.writeUint32(0)
return s.sendGameUpperRawClass(session, 487, body.bytes(), 0)
}
func (s *Service) activateAttendanceMarbleDungeonBuffs(session *gameSession, runtime *runtimeDungeonState) error {
if runtime.attendanceMarbleBuffsLoaded || currentDungeonRuntimeIsPractice(runtime) {
return nil
}
// No event marker means ordinary entry needs no additional asset transaction.
pending := false
for i := 29; i <= 32; i++ {
pending = pending || runtime.Character.Stats[attendanceMarbleBuffKey(i)] > 0
}
if !pending {
runtime.attendanceMarbleBuffsLoaded = true
return nil
}
repos, ok := s.repositoryGroup()
if !ok {
return onlineevent.ErrOwnerUnavailable
}
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
defer cancel()
mask, err := onlineevent.ConsumeMarbleDungeonBuffs(ctx, repos, s.accountIDForSession(session), strconv.Itoa(int(session.selectedCharacterID)))
if err != nil {
return err
}
runtime.attendanceMarbleBuffs, runtime.attendanceMarbleBuffsLoaded = mask, true
for i := 29; i <= 32; i++ {
delete(runtime.Character.Stats, attendanceMarbleBuffKey(i))
}
return nil
}
func (s *Service) handleAttendanceMarbleDoubleExit(session *gameSession, body []byte) error {
if len(body) != 0 {
return nil
}
// Opcode 641 has no native response parser.
if _, err := s.settleAttendanceMarble(session, nil, true); err != nil {
s.logGameEvent(session, "game-attendance-marble-double-exit-deferred", "reason", err)
}
return nil
}
func secureAttendanceMarbleDice() ([2]byte, error) {
var dice [2]byte
for i := range dice {
value, err := cryptorand.Int(cryptorand.Reader, big.NewInt(6))
if err != nil {
return dice, err
}
dice[i] = byte(value.Int64() + 1)
}
return dice, nil
}
func (s *Service) currentAttendanceMarbleCatalog() (*attendanceMarbleCatalog, error) {
s.attendanceMarbleMu.Lock()
defer s.attendanceMarbleMu.Unlock()
if s.attendanceMarbleCatalog != nil {
return s.attendanceMarbleCatalog, nil
}
s.initialEquipmentMu.Lock()
archive := s.initialEquipmentArchive
s.initialEquipmentMu.Unlock()
if archive == nil {
return nil, nil
}
text, err := archive.ReadText(attendanceMarblePVFPath)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(attendanceMarblePVFPath, text)
if err != nil {
return nil, err
}
c, err := parseAttendanceMarbleCatalog(doc)
if err != nil {
return nil, err
}
s.attendanceMarbleCatalog = c
return c, nil
}
func parseAttendanceMarbleCatalog(doc *dnfpvf.Document) (*attendanceMarbleCatalog, error) {
return onlineevent.ParseMarbleCatalog(doc)
}
func (s *Service) settleAttendanceMarble(session *gameSession, dice *[2]byte, closeDouble bool) (attendanceMarbleState, error) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return attendanceMarbleState{}, onlineevent.ErrOwnerUnavailable
}
c, err := s.currentAttendanceMarbleCatalog()
if err != nil {
return attendanceMarbleState{}, err
}
repos, ok := s.repositoryGroup()
if !ok || c == nil {
return attendanceMarbleState{}, onlineevent.ErrOwnerUnavailable
}
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
defer cancel()
now := s.gameplayNow()
characterID := strconv.Itoa(int(session.selectedCharacterID))
return onlineevent.SettleMarble(ctx, repos, c, onlineevent.MarbleCommand{
AccountID: s.accountIDForSession(session), CharacterID: characterID, Now: now, Dice: dice, CloseDouble: closeDouble,
Deliver: func(mailboxes dnfrepo.MailboxRepository, reward onlineevent.AttendancePVFItemReward, key string) (string, error) {
items, err := s.currentPVFItemCatalog()
if err != nil {
return "", err
}
definition, err := items.ResolveItem(uint32(reward.ItemID))
if err != nil {
return "", err
}
if definition.Kind != dungeonDropItemStackable || (!definition.ExpirationDate.IsZero() && !definition.ExpirationDate.After(now)) {
return "", fmt.Errorf("marble reward %d expired or unsupported", reward.ItemID)
}
mailID, err := dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{
RecipientCharacterID: characterID, Title: "幸运热舞派对奖励", Body: "热舞奖励已发放,请查收附件。", Source: "attendance_marble", CompletionKey: key, CreatedAt: now,
Attachments: []dnfrepo.MailAttachment{{ItemID: reward.ItemID, Count: reward.Count, ExpireAt: definition.ExpirationDate}},
})
if err != nil {
return "", err
}
mailbox, found, err := mailboxes.Load(ctx, characterID)
if err != nil {
return "", err
}
if !found {
return "", onlineevent.ErrMarbleState
}
mail := mailbox.Mails[mailID]
mail.ExpireAt = mail.CreatedAt.Add(time.Duration(c.PostalDays) * 24 * time.Hour)
delete(mail.Metadata, "unlimited")
mailbox.Mails[mailID] = mail
return mailID, dnfrepo.SaveMailboxFields(ctx, mailboxes, mailbox, dnfrepo.MailboxFieldMails)
},
})
}