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

204 lines
8.8 KiB
Go
Raw Permalink 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"
"encoding/binary"
"strings"
"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"
)
// 现场 2026-10-03(服主):角色 1 礼白666 用的
// stackable/10159001/10159717.stk(暗之偶像阿努比斯 塔罗牌)是**混合奖池**:
//
// [int data]
// 108040415 175000 1 100270516 175000 1 114030107 175000 1 10159142 300000 250
// [/int data]
//
// 前三条是装备(血红战斧 / 阿努比斯陶瓷长靴 / 战戟),第四条才是「光之根源」。
// 装备行按权重占 63.6%,而**第一行就是装备** —— 于是"roll 恒为 0"这个 bug 让这张卡
// 100% 用不了,日志上表现为连点 6 次全是同一个 item=108040415。
//
// 这条用例把那个形状原样搬进内存夹具:第一行装备、第二行堆叠品。
const (
tarotMixedTestCardItemID = uint32(513)
tarotMixedTestCardSlot = int16(72)
tarotMixedTestGold = int64(300000)
tarotMixedTestGoldCost = int64(100000)
tarotMixedTestEquipmentID = int64(703)
tarotMixedTestMaterialID = int64(600)
tarotMixedTestDurability = 45
)
func currentTarotMixedTableTestSource() dungeonDropCatalogTestSource {
source := currentBoosterTestSource()
source["stackable/stackable.lst"] += "513 `cash/tarot_mixed.stk`\n"
// 与真档同形:equipment.lst 记的是**相对 equipment/ 的路径**,归档里才是全路径。
source["equipment/equipment.lst"] += "703 `character/priest/weapon/axe/tarot_reward.equ`\n"
source["stackable/cash/tarot_mixed.stk"] =
"[stackable type]\n`[monster tarot card]` 19\n" +
"[stack limit]\n100\n" +
"[lottery use cost]\n100000\n" +
// 第一行是装备,第二行是可叠加物 —— 正是 10159717 的形状。
"[int data]\n703 175000 1 600 300000 250\n[/int data]\n"
source["equipment/character/priest/weapon/axe/tarot_reward.equ"] =
"[name]\n`tarot axe`\n[equipment type]\n`[weapon]` 24\n[attach type]\n`[sealing]`\n[durability]\n45\n"
return source
}
func mustCurrentTarotMixedTableTestCatalog(t *testing.T) *pvfDungeonDropCatalog {
t.Helper()
catalog, err := newPVFDungeonDropCatalog(currentTarotMixedTableTestSource())
if err != nil {
t.Fatal(err)
}
return catalog
}
func newTarotMixedTableFixture(
t *testing.T,
roll int64,
) (*Service, *gameSession, *bufferConn, context.Context, dnfrepo.Group) {
t.Helper()
ctx := context.Background()
catalog := mustCurrentTarotMixedTableTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 90,
Stats: map[string]int64{"gold": tarotMixedTestGold},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:72": {ItemID: int64(tarotMixedTestCardItemID), Count: 1},
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := currentBoosterTestService(catalog, repositories)
service.dungeonChoice = func(int) (int, error) { return int(roll), nil }
session := &gameSession{conn: connection, connID: "tarot-mixed", selectedCharacterID: 19}
return service, session, connection, ctx, repositories
}
// 抽到**装备行**必须真的发出去,而且发出去的必须是一件"装备"而不是一件叠加物。
// 旧代码在这里回 op27 {0,4}(成功位=0),源卡与金币都不动 —— 也就是服主看到的"用不了"。
func TestTarotLotteryGrantsAnEquipmentRow(t *testing.T) {
// roll=0 落在第一行(装备)。旧代码的 bug 就是"永远 roll=0",所以这一行
// 恰好就是线上每一张卡都会撞上的那一行。
service, session, connection, ctx, repositories := newTarotMixedTableFixture(t, 0)
openTarotLotteryCard(t, service, session, tarotMixedTestCardSlot)
result, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if result.Header.MsgID != uint16(dnfenum.CmdPacketUseLotteryItem) {
t.Fatalf("第一个包不是 op27: header=%+v body=%x", result.Header, result.Body)
}
if result.Body[0] != 1 || len(result.Body) != currentLotteryNativeResultBodySize {
t.Fatalf("装备行被拒了: body=%x(装备行必须发得出去)", result.Body)
}
row := result.Body[3 : 3+currentItemListEntryWireSize]
if got := binary.LittleEndian.Uint32(row[2:6]); got != uint32(tarotMixedTestEquipmentID) {
t.Fatalf("op27 行里的道具 id=%d want %d", got, tarotMixedTestEquipmentID)
}
// 装备行由服务端完全拥有 +0x0B(耐久)与 +0x0D(封装):PVF [durability] 45 与
// [sealing] 必须落在这两个字节上,否则客户端把新装备画成 0 耐久 / 未封装。
if got := binary.LittleEndian.Uint16(row[0x0B:0x0D]); got != tarotMixedTestDurability {
t.Fatalf("op27 行 +0x0B 耐久=%d want %d", got, tarotMixedTestDurability)
}
if row[0x0D] != 1 {
t.Fatalf("op27 行 +0x0D 封装位=%d want 1([sealing])", row[0x0D])
}
// op27 之后只允许一个钱包包(源卡花钱了)。
wallet, rest := splitGameServerUpperPacket(t, trailing)
if wallet.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("第二个包不是钱包同步: header=%+v body=%x", wallet.Header, wallet.Body)
}
if len(rest) != 0 {
t.Fatalf("op27+钱包之后多了包: %x", rest)
}
character, _, _ := repositories.Character.Load(ctx, "19")
if got := character.Stats["gold"]; got != tarotMixedTestGold-tarotMixedTestGoldCost {
t.Fatalf("金币=%d want %d", got, tarotMixedTestGold-tarotMixedTestGoldCost)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
if source := inventory.Slots["0:72"]; source.ItemID != 0 || source.Count != 0 {
t.Fatalf("源卡没有被消耗: %+v", source)
}
key, granted, ok := findRealTarotLotteryGrantedEquipment(inventory, tarotMixedTestEquipmentID)
if !ok {
t.Fatalf("背包里没有发出去的装备: %v", realTarotLotterySlotSummary(inventory))
}
if granted.Count != 1 {
t.Fatalf("装备数量=%d want 1(一格一件)", granted.Count)
}
if granted.Extra["item_kind"] != "equipment" || granted.Extra["equipment_type"] != "[weapon]" {
t.Fatalf("装备行元数据不对: %v", granted.Extra)
}
if granted.Extra["seal_flag"] != "1" {
t.Fatalf("[sealing] 没有写进 seal_flag: %v", granted.Extra)
}
if got := granted.Extra["durability"]; got != "45" {
t.Fatalf("耐久=%q want 45", got)
}
if seed := granted.Extra["quality_seed"]; seed == "" || seed == "0" {
t.Fatalf("品级种子=%q(不写的话客户端把奖励装备画成最下级)", seed)
}
if len(granted.RawEntry) != currentItemListEntryWireSize {
t.Fatalf("RawEntry 长=%d want %d", len(granted.RawEntry), currentItemListEntryWireSize)
}
t.Logf("装备行落点 %s: %+v", key, granted.Extra)
}
// 抽到可叠加行必须与改动前逐字同形:数量来自 [int data] 的 count 列(250),
// 走原有的合并/开行逻辑。
func TestTarotLotteryGrantsAStackableRowAsBefore(t *testing.T) {
// 175000 是第一行的权重,也就是第二行的起始 roll。
service, session, connection, ctx, repositories := newTarotMixedTableFixture(t, 175000)
openTarotLotteryCard(t, service, session, tarotMixedTestCardSlot)
result, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
if result.Body[0] != 1 {
t.Fatalf("可叠加行被拒了: body=%x", result.Body)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
key, granted, ok := findRealTarotLotteryGrantedEquipment(inventory, tarotMixedTestMaterialID)
if !ok {
t.Fatalf("背包里没有发出去的材料: %v", realTarotLotterySlotSummary(inventory))
}
if granted.Count != 250 {
t.Fatalf("材料数量=%d want 250([int data] 的 count 列)", granted.Count)
}
if !strings.EqualFold(granted.Extra["item_kind"], "stackable") {
t.Fatalf("材料行的 item_kind=%q want stackable", granted.Extra["item_kind"])
}
t.Logf("可叠加行落点 %s: count=%d", key, granted.Count)
}
// 「修 A 不坏 B」:非塔罗牌的 op27 道具必须原样落回罐子路径,本改动不得改变分派。
// (tarot_lottery_item_test.go 的 TestCurrentTarotLotteryDispatchLeavesEveryOtherOp27ItemAlone
// 钉的是分派本身,这里补一条:混合表夹具里的**非塔罗牌**槽位同样不被接管。)
func TestTarotMixedTableLeavesTheJarRouteAlone(t *testing.T) {
service, session, _, ctx, _ := newTarotMixedTableFixture(t, 0)
catalog := service.pvfItemCatalog
// 71 号槽是空的;90 号也是。两者都必须 handled=false。
for _, slot := range []int16{71, 90} {
handled, err := service.tryHandleCurrentTarotLotteryItem(ctx, session, catalog, false, slot)
if err != nil {
t.Fatalf("slot=%d err=%v", slot, err)
}
if handled {
t.Fatalf("slot=%d 被塔罗牌分支接管了,但它不是塔罗牌", slot)
}
}
}