Files
90CNServer/server/internal/services/dnfbridge/cera_package_account_shared_test.go
T
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

182 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// 礼包(赛丽亚商店开箱)的奖励里同样会有无色小晶块 / 灵魂,必须落账号共享仓。
//
// 这条路的事务回调一直拿得到账号仓记录,却用 `_ dnfrepo.AccountInventoryRepository`
// 丢掉了 —— 与翻牌那次同一形态。统一入口见 account_shared_grant.go。
// cubePackageTestCatalog 造一个"开箱即得 20 个无色小晶块"的礼包。
//
// ⚠️ 无色小晶块的 .stk **故意不声明 `[stack limit]`**,与线上 Script.pvf 一致
// (2026-09-21 用 pvfcat 核对:12 个道具全部没有 [stack limit])。这不是细节:
// 若给个 999,而账号格已经有 43890,唯一入库原语会按"容器内同 id 已超上限"正确拒绝,
// 整份奖励退回角色背包 —— 那测的就不是本路径了。
func cubePackageTestCatalog(t *testing.T) *pvfDungeonDropCatalog {
t.Helper()
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "500 `cash/cube_package.stk`\n3037 `material/cubepiece_clear.stk`\n",
"equipment/equipment.lst": "",
"stackable/cash/cube_package.stk": "[stackable type]\n`[usable cera package]`\n[stack limit]\n100\n[expiration date]\n`2028-08-16 06:00:00`\n[package data]\n3037 20\n[/package data]\n",
"stackable/material/cubepiece_clear.stk": "[stackable type]\n`[material]` 2\n",
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
return catalog
}
func openCubePackageForTest(t *testing.T, catalog *pvfDungeonDropCatalog) dnfrepo.Group {
t.Helper()
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
// 现场形态:账号晶体仓里已经堆着 43890 个无色。
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "account-1",
Slots: map[string]dnfrepo.ItemStack{"0:358": {ItemID: 3037, Count: 43890}},
}); err != nil {
t.Fatal(err)
}
sourceStack := dnfrepo.ItemStack{ItemID: 500, Count: 2}
sourceEntry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, 75, sourceStack)
sourceStack.RawEntry = append([]byte(nil), sourceEntry.data[:]...)
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{"0:75": sourceStack},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "account-1",
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
pvfItemCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
service.gameplayTimers = newFakeCurrentDungeonDeathTimerQueue()
session := &gameSession{conn: connection, connID: "cera-package-cube-test", selectedCharacterID: 19}
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketOpenCerapackage),
currentCeraPackageTestRequestBody(75),
); err != nil {
t.Fatal(err)
}
return repositories
}
// 开箱得到无色小晶块 → 并进账号晶体仓 0:358,背包里一个都不许有;礼包本身照旧被消耗。
//
// ⚠️ 注意这个用例必须断言**溯源标记**,不能只断言数量:
// 开箱成功后 handleCurrentCeraPackageOpen 会重建 list-0,而重建里跑着 P4 的收敛兜底
// (inventory.MigrateAccountSharedSlots)—— 就算这里没接统一入口,兜底也会把这 20 个
// 从背包搬进账号格,数量对得上、背包也是空的。两种情况唯一的区别是**账号行由谁写**:
//
// · 接了统一入口 → 礼包路径自己写账号行,Extra 带 source=cera_package
// · 只靠兜底 → 账号行是原有的那一行被合并,Extra 保持原样
//
// 所以下面同时断言 Extra,这个用例才真的钉住了本路径的修复。
func TestCurrentCeraPackageRoutesCubeRewardIntoAccountCell(t *testing.T) {
ctx := context.Background()
repositories := openCubePackageForTest(t, cubePackageTestCatalog(t))
account, found, err := repositories.AccountInventory.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account inventory found=%t err=%v", found, err)
}
stack, exists := account.Slots["0:358"]
if !exists || stack.ItemID != 3037 || stack.Count != 43910 {
t.Fatalf("account crystal cell=%+v want 3037x43910 (43890 + the 20 from the package)", stack)
}
if stack.Extra["source"] != "cera_package" || stack.Extra["last_grant_source"] != "cera_package" {
t.Fatalf("account row provenance=%+v want source=cera_package: the package path must write the row itself, not leave it to the login sweep", stack.Extra)
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
for key, stack := range inventory.Slots {
if stack.ItemID == 3037 {
t.Fatalf("the cube reward must not sit in the backpack: %s=%+v", key, stack)
}
}
if source := inventory.Slots["0:75"]; source.ItemID != 500 || source.Count != 1 {
t.Fatalf("package source after=%+v want 500x1 (one of the two was consumed)", source)
}
}
// 行为保持:普通奖励(600)照旧落角色背包,账号仓不因这次开箱新增行。
func TestCurrentCeraPackageLeavesOrdinaryRewardsInTheBag(t *testing.T) {
ctx := context.Background()
catalog := mustCurrentCeraPackageTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{AccountID: "account-1", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
sourceStack := dnfrepo.ItemStack{ItemID: 500, Count: 1}
sourceEntry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, 75, sourceStack)
sourceStack.RawEntry = append([]byte(nil), sourceEntry.data[:]...)
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{"0:75": sourceStack},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "account-1",
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
pvfItemCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
service.gameplayTimers = newFakeCurrentDungeonDeathTimerQueue()
session := &gameSession{conn: connection, connID: "cera-package-ordinary-test", selectedCharacterID: 19}
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketOpenCerapackage),
currentCeraPackageTestRequestBody(75, currentCeraPackageChoice{ItemID: 700, Option: 9}),
); err != nil {
t.Fatal(err)
}
inventory, _, err := repositories.Inventory.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
if got := inventory.Slots["0:121"]; got.ItemID != 600 || got.Count != 3 {
t.Fatalf("ordinary reward must stay in the backpack: %+v", got)
}
account, _, err := repositories.AccountInventory.Load(ctx, "account-1")
if err != nil {
t.Fatal(err)
}
if len(account.Slots) != 0 {
t.Fatalf("an ordinary package must not add account rows: %+v", account.Slots)
}
}