DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,296 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
"longheng.io/server/internal/modules/dnf/premium"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
type handler struct{}
|
||||
|
||||
// NewHandler creates the mailbox protocol handler.
|
||||
func NewHandler() alignedcmd.Handler {
|
||||
return handler{}
|
||||
}
|
||||
|
||||
// Domain returns the mailbox business domain.
|
||||
func (handler) Domain() dnfenum.AlignedDomain {
|
||||
return dnfenum.AlignedDomainMail
|
||||
}
|
||||
|
||||
// Handle routes the mailbox command family using the current NoPack.exe
|
||||
// readers/writers as the packet authority.
|
||||
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
opcode := dnfenum.CmdPacket(req.Opcode)
|
||||
switch opcode {
|
||||
case dnfenum.CmdPacketMailboxOpen:
|
||||
return handleMailboxOpen(ctx, req)
|
||||
case dnfenum.CmdPacketMailboxSend:
|
||||
return handleMailboxSend(ctx, req)
|
||||
case dnfenum.CmdPacketMailboxExtractItem:
|
||||
return handleMailboxExtract(ctx, req)
|
||||
case dnfenum.CmdPacketChangeLetterStat:
|
||||
return handleMailboxChangeState(ctx, req)
|
||||
case dnfenum.CmdPacketMultiMailboxSend:
|
||||
return handleMailboxMultiSend(ctx, req)
|
||||
case dnfenum.CmdPacketQueryCharacInfoMailbox:
|
||||
return handleMailboxQueryCharacter(ctx, req)
|
||||
case dnfenum.CmdPacketRequestServerCharacterList:
|
||||
return handleMailboxRecipientCharacterList(ctx, req)
|
||||
default:
|
||||
return alignedcmd.Result{
|
||||
Handled: false,
|
||||
ResponseAllowed: false,
|
||||
Operation: "unregistered",
|
||||
Reason: fmt.Sprintf("mail module has no route for opcode %d", req.Opcode),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleMailboxRecipientCharacterList(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
parsed, err := decodeRecipientCharacterListRequest(req.Body)
|
||||
if err != nil {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: "mailbox_recipient_list_rejected",
|
||||
Reason: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
if req.Repositories.Character == nil {
|
||||
return alignedcmd.Result{}, ErrMailboxCharacterMissing
|
||||
}
|
||||
accountID := strings.TrimSpace(req.AccountID)
|
||||
if accountID == "" {
|
||||
return alignedcmd.Result{}, ErrMailboxCharacterMissing
|
||||
}
|
||||
characters, err := req.Repositories.Character.ListByAccount(ctx, accountID, dnfrepo.MaxCharacterSlotLimit)
|
||||
if err != nil {
|
||||
return alignedcmd.Result{}, err
|
||||
}
|
||||
body, err := buildRecipientCharacterListBody(parsed.ServerID, characters, req.SelectedCharacterID)
|
||||
if err != nil {
|
||||
return alignedcmd.Result{}, err
|
||||
}
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: "mailbox_recipient_list",
|
||||
Reason: fmt.Sprintf("current op789 server=%d returned %d other account roles through op718", parsed.ServerID, body[1]),
|
||||
UpperResponses: []alignedcmd.UpperResponse{
|
||||
class0Response(mailboxRecipientListMessageID, body),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func handleMailboxOpen(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
if err := decodeMailboxOpenRequest(req.Body); err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_open_rejected", err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
characterID, err := characterIDFromRequest(req.SelectedCharacterID)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_open_rejected", err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
result, err := NewOwner(req.Repositories).Open(ctx, characterID)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_open_rejected", err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
snapshot, err := buildMailboxListNotification(result.Mails, result.NotLoaded, result.ObservedAt)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_open_rejected", "mailbox snapshot unavailable: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
out := class1Result(
|
||||
req.Opcode,
|
||||
"mailbox_open",
|
||||
fmt.Sprintf("current op96 ACK not_loaded=%d total=%d unread=%d claimable=%d", result.NotLoaded, result.Total, result.Unread, result.Claimable),
|
||||
buildMailboxOpenAck(result.NotLoaded),
|
||||
)
|
||||
out.UpperResponses = append(out.UpperResponses, class0Response(MailboxListNotificationMessageID, snapshot))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func handleMailboxSend(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
return handleMailboxSendOpcode(ctx, req, dnfenum.CmdPacketMailboxSend)
|
||||
}
|
||||
|
||||
func handleMailboxSendOpcode(ctx context.Context, req alignedcmd.Request, opcode dnfenum.CmdPacket) (alignedcmd.Result, error) {
|
||||
parsed, err := decodeSendRequest(opcode, req.Body)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_send_rejected", "invalid current mailbox send body: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
characterID, err := characterIDFromRequest(req.SelectedCharacterID)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_send_rejected", err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
result, err := NewOwner(req.Repositories, req.MailboxItemResolver).Send(ctx, characterID, parsed)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_send_rejected", "mailbox send rejected: "+err.Error()+"; request="+parsed.String(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
out := class1Result(
|
||||
req.Opcode,
|
||||
"mailbox_send_ack",
|
||||
fmt.Sprintf("mailbox send committed mail=%s recipient=%s; request=%s", result.MailID, result.RecipientCharacterID, parsed.String()),
|
||||
buildSuccessAck(),
|
||||
)
|
||||
if recipientID, parseErr := strconv.ParseUint(result.RecipientCharacterID, 10, 16); parseErr == nil && recipientID != 0 {
|
||||
out.MailboxAlarmRecipientID = uint16(recipientID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func handleMailboxExtract(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
parsed, err := decodeExtractRequest(req.Body)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_extract_rejected", "invalid current mailbox extract body: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
characterID, err := characterIDFromRequest(req.SelectedCharacterID)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_extract_rejected", err.Error(), buildErrorAck(mailboxClaimErrorCode(err))), nil
|
||||
}
|
||||
owner := NewOwner(req.Repositories, req.MailboxItemResolver).
|
||||
WithPremiumContracts(req.PremiumContractResolver, req.AccountID)
|
||||
result, err := owner.Claim(ctx, characterID, parsed)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_extract_rejected", "mailbox extract rejected: "+err.Error()+"; request="+parsed.String(), buildErrorAck(mailboxClaimErrorCode(err))), nil
|
||||
}
|
||||
out := class1Result(
|
||||
req.Opcode,
|
||||
"mailbox_extract_ack",
|
||||
fmt.Sprintf("claimed mails=%v rows=%v gold=%d items=%d premium=%t", result.MailIDs, result.Claimed, result.Gold, result.ItemCount, result.PremiumActivated),
|
||||
buildExtractAck(result.Claimed),
|
||||
)
|
||||
// A claimed 契约 extends the account entitlement inside the claim transaction,
|
||||
// but the client keeps drawing its old remaining time until it is told. This
|
||||
// is the same class0 notification the inventory and cera-shop activation paths
|
||||
// send; without it the mailed contract looked like it granted nothing at all.
|
||||
for _, activation := range result.PremiumActivations {
|
||||
if !premium.CanNotifyActivation(activation.PremiumType) {
|
||||
continue
|
||||
}
|
||||
out.UpperResponses = append(out.UpperResponses, class0Response(
|
||||
premium.ActivatedNotificationMessageID,
|
||||
premium.BuildActivatedBody(activation.PremiumType, activation.RemainingSeconds),
|
||||
))
|
||||
if activation.PremiumType == premium.TypeCrystal {
|
||||
// The crystal contract also drives a separate bridge-owned state block.
|
||||
out.PostActions = append(out.PostActions, alignedcmd.PostActionRefreshCrystalContractState)
|
||||
}
|
||||
}
|
||||
// Current NoPack keeps the selected mail object alive until the op95 handler
|
||||
// finishes. A clear-first op97 page here destroys that object and crashes the
|
||||
// client after the durable claim has already committed. Preserve the captured
|
||||
// native order: op95 ACK -> exact op14 rows -> class0/op98 remove notification.
|
||||
out.ItemSlotRefreshes = append(out.ItemSlotRefreshes, result.ItemSlotRefreshes...)
|
||||
out.EpicBookBalances = append(out.EpicBookBalances, result.EpicBookBalances...)
|
||||
if result.Gold > 0 {
|
||||
goldAfter := result.GoldAfter
|
||||
out.CharacterGoldAfter = &goldAfter
|
||||
}
|
||||
out.PostItemSlotRefreshResponses = append(out.PostItemSlotRefreshResponses, class0Response(
|
||||
mailboxRemoveNotificationMessageID,
|
||||
buildMailboxRemoveNotification(result.MailIDs),
|
||||
))
|
||||
if result.Wallet != nil {
|
||||
out.CeraWallet = &alignedcmd.CeraWalletProjection{
|
||||
Cera: result.Wallet.Cera,
|
||||
TokenCera: result.Wallet.TokenCera,
|
||||
HappyTokenCera: result.Wallet.HappyTokenCera,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func handleMailboxChangeState(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
parsed, err := decodeChangeStateRequest(req.Body)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_state_rejected", "invalid current mailbox state body: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
characterID, err := characterIDFromRequest(req.SelectedCharacterID)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_state_rejected", err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
if err := NewOwner(req.Repositories).ChangeState(ctx, characterID, parsed); err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_state_rejected", err.Error()+"; request="+parsed.String(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
out := class1Result(
|
||||
req.Opcode,
|
||||
"mailbox_state_ack",
|
||||
"mailbox state committed; request="+parsed.String(),
|
||||
buildChangeStateAck(parsed.MailIDs, parsed.Status),
|
||||
)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func handleMailboxMultiSend(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
return handleMailboxSendOpcode(ctx, req, dnfenum.CmdPacketMultiMailboxSend)
|
||||
}
|
||||
|
||||
func handleMailboxQueryCharacter(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
parsed, err := decodeQueryCharacterRequest(req.Body)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_query_rejected", "invalid current mailbox query body: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
if req.Repositories.Character == nil {
|
||||
return class1Result(req.Opcode, "mailbox_query_rejected", ErrMailboxCharacterMissing.Error(), buildErrorAck(mailboxErrorCode(ErrMailboxCharacterMissing))), nil
|
||||
}
|
||||
characterID, found, err := req.Repositories.Character.FindIDByName(ctx, parsed.Name)
|
||||
if err != nil {
|
||||
return class1Result(req.Opcode, "mailbox_query_rejected", "query character failed: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
if !found {
|
||||
return class1Result(req.Opcode, "mailbox_query_rejected", "recipient not found: "+parsed.Name, buildErrorAck(mailboxErrorCode(ErrMailboxRecipientMissing))), nil
|
||||
}
|
||||
character, found, err := req.Repositories.Character.Load(ctx, characterID)
|
||||
if err != nil || !found {
|
||||
if err == nil {
|
||||
err = ErrMailboxRecipientMissing
|
||||
}
|
||||
return class1Result(req.Opcode, "mailbox_query_rejected", "load queried character: "+err.Error(), buildErrorAck(mailboxErrorCode(err))), nil
|
||||
}
|
||||
job, grow, level, err := mailboxCharacterProjection(character)
|
||||
if err != nil {
|
||||
return class1Result(
|
||||
req.Opcode,
|
||||
"mailbox_query_rejected",
|
||||
"queried character projection is unavailable: "+err.Error(),
|
||||
buildErrorAck(mailboxErrorCode(err)),
|
||||
), nil
|
||||
}
|
||||
return class1Result(
|
||||
req.Opcode,
|
||||
"mailbox_query_ack",
|
||||
fmt.Sprintf("current op324 character=%s job=%d grow=%d level=%d mode=%d", characterID, job, grow, level, parsed.Mode),
|
||||
buildQueryCharacterAck(parsed.NameRaw, grow, level, job),
|
||||
), nil
|
||||
}
|
||||
|
||||
func mailboxCharacterProjection(character dnfrepo.CharacterRecord) (byte, byte, uint16, error) {
|
||||
jobValue, err := strconv.ParseUint(strings.TrimSpace(character.Job), 10, 8)
|
||||
if err != nil {
|
||||
return 0, 0, 0, fmt.Errorf("%w: invalid job %q", ErrMailboxInvalidRequest, character.Job)
|
||||
}
|
||||
growValue, found := character.Stats["grow_type"]
|
||||
if !found || growValue < 0 || growValue > 0xff {
|
||||
return 0, 0, 0, fmt.Errorf("%w: invalid grow_type", ErrMailboxInvalidRequest)
|
||||
}
|
||||
if character.Level <= 0 || character.Level > 0xffff {
|
||||
return 0, 0, 0, fmt.Errorf("%w: invalid level %d", ErrMailboxInvalidRequest, character.Level)
|
||||
}
|
||||
return byte(jobValue), byte(growValue), uint16(character.Level), nil
|
||||
}
|
||||
|
||||
func class1Result(opcode uint16, operation string, reason string, body []byte) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: operation,
|
||||
Reason: reason,
|
||||
UpperResponses: []alignedcmd.UpperResponse{class1Response(opcode, body)},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user