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

212 lines
9.1 KiB
Go

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)
}