服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
192 lines
7.4 KiB
Go
192 lines
7.4 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
// 这组回归锁定"一个容器里同一道具 id 的总量不得超过 PVF [stack limit]"在
|
|
// 赛丽亚商店/NPC 商店(含 list 38 勋章栏)入包路径上的行为。grantCurrentCeraShopProduct
|
|
// 是这些路径共用的入包助手。
|
|
|
|
func stackLimitGrantDefinition(itemID uint32, stackLimit int64, slotStart, slotEnd int16) dungeonDropItemDefinition {
|
|
return dungeonDropItemDefinition{
|
|
ItemID: itemID,
|
|
Kind: dungeonDropItemStackable,
|
|
PVFPath: "stackable/material/grant_limit.stk",
|
|
StackableType: "[material]",
|
|
StackLimit: stackLimit,
|
|
SlotStart: slotStart,
|
|
SlotEnd: slotEnd,
|
|
}
|
|
}
|
|
|
|
// 容器里已经有这道具的行、还有额度:并进那一行,不另开一行。
|
|
func TestGrantCurrentCeraShopProductMergesIntoExistingRowUnderStackLimit(t *testing.T) {
|
|
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
|
|
"0:121": {ItemID: 3227, Count: 5},
|
|
}}
|
|
slots, err := grantCurrentCeraShopProduct(&inventory, stackLimitGrantDefinition(3227, 1000, 121, 176), 3, 24)
|
|
if err != nil {
|
|
t.Fatalf("grantCurrentCeraShopProduct: %v", err)
|
|
}
|
|
if len(slots) != 1 || slots[0] != 121 {
|
|
t.Fatalf("slots=%v, want the existing row 121", slots)
|
|
}
|
|
if len(inventory.Slots) != 1 || inventory.Slots["0:121"].Count != 8 {
|
|
t.Fatalf("inventory=%+v", inventory.Slots)
|
|
}
|
|
}
|
|
|
|
// [stack limit] 1:容器里已经有一件时整包拒绝(既有错误 errDungeonPickupInventoryFull
|
|
// 让调用方走邮件等溢出),不许写第二行。
|
|
func TestGrantCurrentCeraShopProductRefusesSecondRowForStackLimitOne(t *testing.T) {
|
|
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
|
|
"0:121": {ItemID: 3227, Count: 1},
|
|
}}
|
|
if _, err := grantCurrentCeraShopProduct(&inventory, stackLimitGrantDefinition(3227, 1, 121, 176), 1, 24); !errors.Is(err, errDungeonPickupInventoryFull) {
|
|
t.Fatalf("error=%v, want errDungeonPickupInventoryFull", err)
|
|
}
|
|
if len(inventory.Slots) != 1 || inventory.Slots["0:121"].Count != 1 {
|
|
t.Fatalf("refused grant mutated the container: %+v", inventory.Slots)
|
|
}
|
|
}
|
|
|
|
// 额度按容器总量算:历史拆行合计已经到上限时再入包必须拒绝;还有余量时并进
|
|
// 现有行并把容器刚好填到上限。
|
|
func TestGrantCurrentCeraShopProductCapsByContainerTotal(t *testing.T) {
|
|
full := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
|
|
"0:121": {ItemID: 3227, Count: 4},
|
|
"0:122": {ItemID: 3227, Count: 1},
|
|
}}
|
|
if _, err := grantCurrentCeraShopProduct(&full, stackLimitGrantDefinition(3227, 5, 121, 176), 1, 24); !errors.Is(err, errDungeonPickupInventoryFull) {
|
|
t.Fatalf("error=%v, want errDungeonPickupInventoryFull", err)
|
|
}
|
|
if len(full.Slots) != 2 || full.Slots["0:121"].Count != 4 || full.Slots["0:122"].Count != 1 {
|
|
t.Fatalf("refused grant mutated the container: %+v", full.Slots)
|
|
}
|
|
|
|
room := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
|
|
"0:121": {ItemID: 3227, Count: 3},
|
|
"0:122": {ItemID: 3227, Count: 1},
|
|
}}
|
|
slots, err := grantCurrentCeraShopProduct(&room, stackLimitGrantDefinition(3227, 5, 121, 176), 1, 24)
|
|
if err != nil {
|
|
t.Fatalf("grantCurrentCeraShopProduct: %v", err)
|
|
}
|
|
if len(slots) != 1 || slots[0] != 121 {
|
|
t.Fatalf("slots=%v, want the lowest existing row", slots)
|
|
}
|
|
if len(room.Slots) != 2 || room.Slots["0:121"].Count != 4 || room.Slots["0:122"].Count != 1 {
|
|
t.Fatalf("inventory=%+v", room.Slots)
|
|
}
|
|
}
|
|
|
|
// 没写 [stack limit] 的道具不受这条约束:容器里那一行不在落点范围内时,
|
|
// 仍然可以在范围内开新行(有上限时同一场景必须拒绝)。
|
|
func TestGrantCurrentCeraShopProductKeepsUncappedItemsUnrestricted(t *testing.T) {
|
|
uncapped := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
|
|
"0:9": {ItemID: 3227, Count: 5},
|
|
}}
|
|
slots, err := grantCurrentCeraShopProduct(&uncapped, stackLimitGrantDefinition(3227, 0, 121, 176), 3, 24)
|
|
if err != nil {
|
|
t.Fatalf("uncapped grantCurrentCeraShopProduct: %v", err)
|
|
}
|
|
if len(slots) != 1 || slots[0] != 121 {
|
|
t.Fatalf("uncapped slots=%v, want a fresh row at 121", slots)
|
|
}
|
|
if stack := uncapped.Slots["0:121"]; stack.ItemID != 3227 || stack.Count != 3 {
|
|
t.Fatalf("uncapped fresh row=%+v", stack)
|
|
}
|
|
if stack := uncapped.Slots["0:9"]; stack.Count != 5 {
|
|
t.Fatalf("uncapped existing row was mutated: %+v", stack)
|
|
}
|
|
|
|
// 到容器上限(这里 limit=5,容器里已有 5 件):既放不进那一行,也不能另开一行,
|
|
// 整笔拒绝并回滚,由调用方走溢出(邮件)。
|
|
capped := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
|
|
"0:9": {ItemID: 3227, Count: 5},
|
|
}}
|
|
if _, err := grantCurrentCeraShopProduct(&capped, stackLimitGrantDefinition(3227, 5, 121, 176), 3, 24); !errors.Is(err, errDungeonPickupInventoryFull) {
|
|
t.Fatalf("capped error=%v, want errDungeonPickupInventoryFull", err)
|
|
}
|
|
if len(capped.Slots) != 1 {
|
|
t.Fatalf("capped grant opened a second row: %+v", capped.Slots)
|
|
}
|
|
if stack := capped.Slots["0:9"]; stack.Count != 5 {
|
|
t.Fatalf("capped grant was not rolled back: %+v", stack)
|
|
}
|
|
}
|
|
|
|
// NPC 商店的 list 38 勋章/守护宝石栏是独立容器:同一容器里已经有该道具的行时
|
|
// 不再开第二行。
|
|
func TestGrantCurrentGuildMedalListProductRefusesSecondRowForStackLimitOne(t *testing.T) {
|
|
definition := dungeonDropItemDefinition{
|
|
ItemID: 702,
|
|
Kind: dungeonDropItemStackable,
|
|
PVFPath: "stackable/flag/guild_medal.stk",
|
|
StackableType: "[flag]",
|
|
StackLimit: 1,
|
|
SlotStart: 1,
|
|
SlotEnd: 1,
|
|
}
|
|
key := currentCeraShopInventorySlotKey(currentGuildMedalInventoryListType, currentGuildMedalPageSlotStart)
|
|
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
|
|
key: {ItemID: 702, Count: 1},
|
|
}}
|
|
_, err := grantCurrentGuildMedalListProduct(
|
|
&inventory,
|
|
definition,
|
|
1,
|
|
currentGuildMedalPageSlotStart,
|
|
currentGuildMedalPageSlotStart,
|
|
)
|
|
if !errors.Is(err, errDungeonPickupInventoryFull) {
|
|
t.Fatalf("error=%v, want errDungeonPickupInventoryFull", err)
|
|
}
|
|
if len(inventory.Slots) != 1 || inventory.Slots[key].Count != 1 {
|
|
t.Fatalf("refused medal grant mutated the container: %+v", inventory.Slots)
|
|
}
|
|
}
|
|
|
|
// 守护宝石(同一 list 38 容器)还有额度时并进现有行。
|
|
func TestGrantCurrentGuildMedalListProductMergesIntoExistingRow(t *testing.T) {
|
|
definition := dungeonDropItemDefinition{
|
|
ItemID: 606,
|
|
Kind: dungeonDropItemStackable,
|
|
PVFPath: "stackable/flag/guardian_gem.stk",
|
|
StackableType: "[flag gem]",
|
|
StackLimit: 100,
|
|
SlotStart: 1,
|
|
SlotEnd: 1,
|
|
}
|
|
key := currentCeraShopInventorySlotKey(currentGuildMedalInventoryListType, currentGuardianGemPageSlotStart)
|
|
inventory := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{
|
|
key: {ItemID: 606, Count: 2},
|
|
}}
|
|
slots, err := grantCurrentGuildMedalListProduct(
|
|
&inventory,
|
|
definition,
|
|
3,
|
|
currentGuardianGemPageSlotStart,
|
|
currentGuardianGemPageSlotStart,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("grantCurrentGuildMedalListProduct: %v", err)
|
|
}
|
|
if len(slots) != 1 || int16(slots[0]) != currentGuardianGemPageSlotStart {
|
|
t.Fatalf("slots=%v", slots)
|
|
}
|
|
if len(inventory.Slots) != 1 || inventory.Slots[key].Count != 5 {
|
|
t.Fatalf("inventory=%+v", inventory.Slots)
|
|
}
|
|
for other := range inventory.Slots {
|
|
if !strings.HasPrefix(other, "38:") {
|
|
t.Fatalf("guardian gem leaked into %s", other)
|
|
}
|
|
}
|
|
}
|