服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
150 lines
6.5 KiB
Go
150 lines
6.5 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
// 这组用例锁死一条曾经被打破的不变量:**未解锁的背包扩展行绝不能被当作落点**。
|
||
//
|
||
// 背景:main 背包标准分类每个跨 7 行 × 8 格 = 56 格,新角色只解锁前 4 行(32 格),
|
||
// 扩展券每张解锁 1 行(最多 3 行 = 24 格)。limitCurrentMainInventoryDefinitionSlots
|
||
// 负责把落点 SlotEnd 夹到已解锁的最后一格。
|
||
//
|
||
// 曾经的 bug:grantCurrentCeraShopProduct 把扩展行数做成了 `...uint16` 可选变参,
|
||
// 结果 15 个调用点里只有赛丽亚商店的 4 个传了值,其余静默不夹取 —— 玩家 4 行放满后
|
||
// 在 NPC 商店买东西,物品落进了客户端不渲染的未解锁格,看起来就"消失"了。
|
||
//
|
||
// 现在该参数是必填的:任何新调用点漏传都会编译失败,不会再退化成静默放行。
|
||
func TestLimitCurrentMainInventoryDefinitionSlotsClampsToUnlockedRows(t *testing.T) {
|
||
// 材料分类:65..120,共 56 格(= currentMainInventoryCategorySlotCount)。
|
||
material := dungeonDropItemDefinition{SlotStart: 65, SlotEnd: 120}
|
||
|
||
cases := []struct {
|
||
name string
|
||
mainSlotCount uint16
|
||
wantSlotEnd int16
|
||
why string
|
||
}{
|
||
{"no expansion", 0, 96, "默认 4 行:65 + 32 - 1 + 0"},
|
||
{"one row bought", 8, 104, "5 行:65 + 32 - 1 + 8"},
|
||
{"two rows bought", 16, 112, "6 行:65 + 32 - 1 + 16"},
|
||
{"three rows bought", 24, 120, "7 行:与 PVF 原始 SlotEnd 相同,等于不夹取"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := limitCurrentMainInventoryDefinitionSlots(material, tc.mainSlotCount).SlotEnd
|
||
if got != tc.wantSlotEnd {
|
||
t.Fatalf("mainSlotCount=%d: SlotEnd=%d want=%d (%s)",
|
||
tc.mainSlotCount, got, tc.wantSlotEnd, tc.why)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 非 56 格的分类(快捷栏、特殊材料等)不能被夹取,否则会误伤这些固定槽位。
|
||
func TestLimitCurrentMainInventoryDefinitionSlotsLeavesOtherRangesAlone(t *testing.T) {
|
||
// 快捷栏 3..8:不是 56 格范围。
|
||
quick := dungeonDropItemDefinition{SlotStart: 3, SlotEnd: 8}
|
||
if got := limitCurrentMainInventoryDefinitionSlots(quick, 0).SlotEnd; got != 8 {
|
||
t.Fatalf("快捷栏被误夹取: SlotEnd=%d want=8", got)
|
||
}
|
||
// 特殊材料 345..359:固定仓库区段,同样不是 56 格范围。
|
||
fixed := dungeonDropItemDefinition{SlotStart: 345, SlotEnd: 359}
|
||
if got := limitCurrentMainInventoryDefinitionSlots(fixed, 0).SlotEnd; got != 359 {
|
||
t.Fatalf("固定材料区段被误夹取: SlotEnd=%d want=359", got)
|
||
}
|
||
}
|
||
|
||
// 助手取不到角色/仓储时必须**安全失败**(返回 0 = 夹到默认 4 行),
|
||
// 而不是返回一个很大的值把未解锁行放出来。
|
||
func TestPlacementMainSlotCountFailsSafe(t *testing.T) {
|
||
if got := currentInventoryPlacementMainSlotCountFor(context.Background(), nil, "77"); got != 0 {
|
||
t.Fatalf("仓储为 nil 时应返回 0,实际 %d", got)
|
||
}
|
||
if got := currentInventoryPlacementMainSlotCountFor(context.Background(), nil, ""); got != 0 {
|
||
t.Fatalf("角色为空时应返回 0,实际 %d", got)
|
||
}
|
||
// 安全失败的语义是"夹到默认 4 行",不是"不夹取"。
|
||
// 这里断言这个回退值确实产生 4 行上限,避免将来有人把回退改成 24。
|
||
material := dungeonDropItemDefinition{SlotStart: 65, SlotEnd: 120}
|
||
if got := limitCurrentMainInventoryDefinitionSlots(material, 0).SlotEnd; got != 96 {
|
||
t.Fatalf("回退值 0 必须等价于 4 行上限 96,实际 %d", got)
|
||
}
|
||
}
|
||
|
||
// 副本内拾取是第二条曾经漏夹取的路径:整条链路把 PVF 的 SlotEnd(材料 65..120 等)
|
||
// 原样交给落点算法,于是已解锁的 4 行填满后,拾取到的物品会落进未解锁的扩展行 ——
|
||
// 客户端不渲染那些格子,物品看起来就"消失"了(用户 2026-09-12 报的
|
||
// "副本内拾取 物品会跑到未解锁的槽位")。
|
||
//
|
||
// 这里把已解锁的 4 行全部填满(每格都顶到堆叠上限,无法并入),再拾取同类材料:
|
||
// 必须拒绝这次拾取并保留地面掉落物,绝不能落到第 5~7 行。
|
||
func TestCurrentDungeonPickupNeverUsesLockedExpansionSlots(t *testing.T) {
|
||
ctx := context.Background()
|
||
service, runtime, repositories := prepareCurrentDungeonDropTest(t, 1, 3227)
|
||
connection := &bufferConn{}
|
||
session := &gameSession{
|
||
conn: connection,
|
||
connID: "dungeon-locked-expansion-slot-test",
|
||
selectedCharacterID: 99,
|
||
dungeon: dungeonSessionState{runtime: runtime},
|
||
}
|
||
catalog, err := service.dungeonMonsterCatalog()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
drops, err := catalog.DropCatalog()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
definition, err := drops.ResolveItem(3227)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
unlockedEnd := currentMainInventoryUnlockedEnd(definition.SlotStart, 0)
|
||
if unlockedEnd >= definition.SlotEnd {
|
||
t.Fatalf("fixture item no longer spans a locked row: slots=%d..%d unlocked_end=%d",
|
||
definition.SlotStart, definition.SlotEnd, unlockedEnd)
|
||
}
|
||
record, found, err := repositories.Inventory.Load(ctx, "99")
|
||
if err != nil || !found {
|
||
t.Fatalf("inventory found=%t err=%v", found, err)
|
||
}
|
||
record.Slots = map[string]dnfrepo.ItemStack{}
|
||
for slot := definition.SlotStart; slot <= unlockedEnd; slot++ {
|
||
record.Slots[currentDungeonPickupMainSlotKey(slot)] = dnfrepo.ItemStack{
|
||
ItemID: int64(definition.ItemID),
|
||
Count: definition.StackLimit,
|
||
}
|
||
}
|
||
if err := repositories.Inventory.Save(ctx, record); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
dropObjectKey := seedCurrentDungeonPickupDrop(t, service, runtime, 3227, currentSceneActorObjectKey(99))
|
||
if err := service.handleCurrentDungeonPickup(session, currentDungeonPickupTestBody(dropObjectKey)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
after, found, err := repositories.Inventory.Load(ctx, "99")
|
||
if err != nil || !found {
|
||
t.Fatalf("inventory after pickup found=%t err=%v", found, err)
|
||
}
|
||
for slot := unlockedEnd + 1; slot <= definition.SlotEnd; slot++ {
|
||
if stack, occupied := after.Slots[currentDungeonPickupMainSlotKey(slot)]; occupied && stack.Count > 0 {
|
||
t.Fatalf("pickup stored item %d in locked slot %d (unlocked end %d)",
|
||
stack.ItemID, slot, unlockedEnd)
|
||
}
|
||
}
|
||
if len(after.Slots) != int(unlockedEnd-definition.SlotStart+1) {
|
||
t.Fatalf("pickup added a stack while the unlocked rows were full: slots=%d", len(after.Slots))
|
||
}
|
||
drop := runtime.DropOwner.byObjectKey[dropObjectKey]
|
||
if drop == nil || drop.Status != runtimeDungeonDropAvailable {
|
||
t.Fatalf("full unlocked rows must leave the ground drop available: drop=%+v", drop)
|
||
}
|
||
}
|