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,211 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnffatigue "longheng.io/server/internal/modules/dnf/fatigue"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestUseStackableAction58RestoresFatigueAndConsumesOnePotion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
now := time.Date(2026, time.August, 29, 12, 0, 0, 0, time.UTC)
|
||||
dayID := premiumServiceDayForTest(now)
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "77", AccountID: "acc", Stats: map[string]int64{
|
||||
"fatigue": 65, "fatigue_limit": 188, "fatigue_display_update": 123,
|
||||
dnffatigue.DailyRecoveryDayStatKey: dayID, dnffatigue.ItemUsageDayStatKey: dayID,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:115": {ItemID: 490702338, Count: 2, Extra: map[string]string{"pvf_path": "stackable/cash/chn_490702338.stk"}},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := make([]byte, 19)
|
||||
binary.LittleEndian.PutUint16(body[0:2], 115)
|
||||
binary.LittleEndian.PutUint32(body[7:11], fatiguePotionActionIndex)
|
||||
result, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketUseStackableAction), Body: body,
|
||||
AccountID: "acc", SelectedCharacterID: 77, Repositories: repos,
|
||||
FatiguePotionNow: now,
|
||||
StackableUseResolver: func(itemID int64) (alignedcmd.StackableUseResolution, error) {
|
||||
return alignedcmd.StackableUseResolution{
|
||||
ItemID: itemID, PVFPath: "stackable/cash/chn_490702338.stk", StackableType: "[expert town potion]",
|
||||
FatigueRecovery: 50, FatigueMaximumRemaining: 106, DailyUseLimit: 1,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.ResponseAllowed || len(result.UpperResponses) != 1 {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
wantAck := []byte{1, 115, 0, 0, 0, 1, 0, 0, 0, 1}
|
||||
if got := result.UpperResponses[0].Body; !bytes.Equal(got, wantAck) {
|
||||
t.Fatalf("op515 ACK=%x want=%x", got, wantAck)
|
||||
}
|
||||
if len(result.ItemSlotRefreshes) != 1 || result.ItemSlotRefreshes[0].SlotIndex != 115 ||
|
||||
len(result.PostActions) != 1 || result.PostActions[0] != alignedcmd.PostActionRefreshSelectedFatigue {
|
||||
t.Fatalf("refresh result=%+v", result)
|
||||
}
|
||||
bag, found, err := repos.Inventory.Load(ctx, "77")
|
||||
if err != nil || !found || bag.Slots["0:115"].Count != 1 {
|
||||
t.Fatalf("inventory=%+v found=%t err=%v", bag, found, err)
|
||||
}
|
||||
character, found, err := repos.Character.Load(ctx, "77")
|
||||
if err != nil || !found || character.Stats["fatigue"] != 115 || character.Stats["fatigue_display_update"] != 73 ||
|
||||
character.Stats[dnffatigue.ItemUsageStatPrefix+"490702338"] != 1 {
|
||||
t.Fatalf("character=%+v found=%t err=%v", character, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseStackableAction58RejectsDailyLimitWithoutMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
now := time.Now().UTC()
|
||||
dayID := premiumServiceDayForTest(now)
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{
|
||||
"fatigue": 65, "fatigue_limit": 188,
|
||||
dnffatigue.DailyRecoveryDayStatKey: dayID,
|
||||
dnffatigue.ItemUsageDayStatKey: dayID,
|
||||
dnffatigue.ItemUsageStatPrefix + "490702338": 1,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:115": {ItemID: 490702338, Count: 2, Extra: map[string]string{"pvf_path": "stackable/cash/chn_490702338.stk"}},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := make([]byte, 19)
|
||||
binary.LittleEndian.PutUint16(body[0:2], 115)
|
||||
binary.LittleEndian.PutUint32(body[7:11], fatiguePotionActionIndex)
|
||||
result, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketUseStackableAction), Body: body, SelectedCharacterID: 77, Repositories: repos,
|
||||
FatiguePotionNow: now,
|
||||
StackableUseResolver: func(itemID int64) (alignedcmd.StackableUseResolution, error) {
|
||||
return alignedcmd.StackableUseResolution{ItemID: itemID, PVFPath: "stackable/cash/chn_490702338.stk", StackableType: "[expert town potion]", FatigueRecovery: 50, FatigueMaximumRemaining: 106, DailyUseLimit: 1}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.UpperResponses) != 1 || !bytes.Equal(result.UpperResponses[0].Body, []byte{0, useStackableActionFailureCode}) {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
bag, _, _ := repos.Inventory.Load(ctx, "77")
|
||||
if got := bag.Slots["0:115"].Count; got != 2 {
|
||||
t.Fatalf("count=%d want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseFatiguePotionClearsAllPriorDayPerItemCounters(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
now := time.Date(2026, time.August, 29, 12, 0, 0, 0, time.UTC)
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{
|
||||
"fatigue": 10, "fatigue_limit": 156, dnffatigue.ItemUsageDayStatKey: premiumServiceDayForTest(now.Add(-24 * time.Hour)),
|
||||
dnffatigue.DailyRecoveryDayStatKey: premiumServiceDayForTest(now),
|
||||
dnffatigue.ItemUsageStatPrefix + "1001": 3, dnffatigue.ItemUsageStatPrefix + "1002": 2,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:1": {ItemID: 1001, Count: 1, Extra: map[string]string{"pvf_path": "stackable/fatigue.stk"}},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = owner.UseFatiguePotionAction(ctx, Command{SelectedCharacterID: 77, SourceListType: listTypeMain, SourceSlotIndex: 1, ActionIndex: fatiguePotionActionIndex}, func(itemID int64) (alignedcmd.StackableUseResolution, error) {
|
||||
return alignedcmd.StackableUseResolution{ItemID: itemID, PVFPath: "stackable/fatigue.stk", StackableType: "[expert town potion]", FatigueRecovery: 1, FatigueMaximumRemaining: 155, DailyUseLimit: 3}, nil
|
||||
}, now, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
character, _, _ := repos.Character.Load(ctx, "77")
|
||||
if character.Stats[dnffatigue.ItemUsageStatPrefix+"1001"] != 1 || character.Stats[dnffatigue.ItemUsageStatPrefix+"1002"] != 0 {
|
||||
t.Fatalf("stats=%+v", character.Stats)
|
||||
}
|
||||
}
|
||||
|
||||
func premiumServiceDayForTest(now time.Time) int64 {
|
||||
return (now.Unix() + int64(2*time.Hour/time.Second)) / int64(24*time.Hour/time.Second)
|
||||
}
|
||||
|
||||
// 每日上限被打满时的拒绝必须是**可识别**的错误:桥接层据此给玩家一条看得见的提示,
|
||||
// 而不是只回一个客户端渲染成沉默的失败码(活体 2026-09-14 23:05:疲劳药用满 3/3 后
|
||||
// 第 4 次点击毫无反馈)。
|
||||
func TestUseFatiguePotionActionReportsDailyLimitAsTypedRejection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 9, 14, 15, 5, 0, 0, time.UTC)
|
||||
day := premiumServiceDayForTest(now)
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "77", AccountID: "acc", Level: 90,
|
||||
Stats: map[string]int64{
|
||||
"fatigue": 60, "fatigue_limit": 156,
|
||||
dnffatigue.FatigueDailyDateStatKey: day,
|
||||
dnffatigue.PotionUsageDayStatKey: day,
|
||||
dnffatigue.ItemUsageDayStatKey: day,
|
||||
dnffatigue.ItemUsageStatPrefix + "1001": 3,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:1": {ItemID: 1001, Count: 5, Extra: map[string]string{"pvf_path": "stackable/fatigue.stk"}},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = owner.UseFatiguePotionAction(ctx, Command{
|
||||
SelectedCharacterID: 77, SourceListType: listTypeMain, SourceSlotIndex: 1,
|
||||
ActionIndex: fatiguePotionActionIndex,
|
||||
}, func(itemID int64) (alignedcmd.StackableUseResolution, error) {
|
||||
return alignedcmd.StackableUseResolution{
|
||||
ItemID: itemID, PVFPath: "stackable/fatigue.stk", StackableType: "[expert town potion]",
|
||||
FatigueRecovery: 1, FatigueMaximumRemaining: 155, DailyUseLimit: 3,
|
||||
}, nil
|
||||
}, now, false)
|
||||
|
||||
var limitErr FatiguePotionDailyLimitError
|
||||
if !errors.Is(err, ErrFatiguePotionDailyLimit) || !errors.As(err, &limitErr) ||
|
||||
limitErr.ItemID != 1001 || limitErr.Used != 3 || limitErr.Limit != 3 {
|
||||
t.Fatalf("每日上限拒绝不是可识别的错误: %v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrFatiguePotionContract) {
|
||||
t.Fatalf("契约哨兵丢失: %v", err)
|
||||
}
|
||||
// 被拒的那一次不能消耗道具,也不能改写计数。
|
||||
inventory, _, loadErr := repos.Inventory.Load(ctx, "77")
|
||||
if loadErr != nil || inventory.Slots["0:1"].Count != 5 {
|
||||
t.Fatalf("被拒的使用改动了道具: %+v err=%v", inventory.Slots, loadErr)
|
||||
}
|
||||
character, _, _ := repos.Character.Load(ctx, "77")
|
||||
if character.Stats[dnffatigue.ItemUsageStatPrefix+"1001"] != 3 {
|
||||
t.Fatalf("被拒的使用改动了计数: %+v", character.Stats)
|
||||
}
|
||||
|
||||
// 提示由客户端自己处理(它已经从 op541 拿到计数),服务端不再手搓文案。
|
||||
_ = fmt.Sprintf("%v", err)
|
||||
}
|
||||
Reference in new issue
Block a user