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

516 lines
23 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 (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
"errors"
"math"
"os"
"strconv"
"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"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// observedDreamCubeRequestHex 是现场实测的 op1119 请求体(2026-10-04 21:06:45,
// CubeProbe.log 逐字节抄下来):+0x10 = 101560739(玩家点名的稀有装扮头发),
// +0x16 = 8 件材料,+0x18 起 8 个 (槽, 道具)。
const observedDreamCubeRequestHex = "2003000058020000EDA6852B385D0000A3B10D0604000800" +
"230018B20D06240023B20D061C006D8A0D061D006D8A0D061600B6A300001A00638A0D061B006D8A0D06210010B20D06" +
"A7040000580000000C99F81A60F81A00B97B32023C3E199204000000320000000018144360F81A0000143A4364F81A00" +
"E000AA01A8B0716560F81A0001000000E4F81A007A325301"
func observedDreamCubeRequest(t *testing.T) []byte {
t.Helper()
body, err := hex.DecodeString(observedDreamCubeRequestHex)
if err != nil {
t.Fatalf("fixture hex: %v", err)
}
if len(body) != 144 {
t.Fatalf("fixture body=%d want=144", len(body))
}
return body
}
func observedDreamCubeMaterials() []currentAvatarBindPlusMaterial {
return []currentAvatarBindPlusMaterial{
{Slot: 0x23, ItemID: 101560856},
{Slot: 0x24, ItemID: 101560867},
{Slot: 0x1C, ItemID: 101550701},
{Slot: 0x1D, ItemID: 101550701},
{Slot: 0x16, ItemID: 41910},
{Slot: 0x1A, ItemID: 101550691},
{Slot: 0x1B, ItemID: 101550701},
{Slot: 0x21, ItemID: 101560848},
}
}
func TestCurrentAvatarBindPlusParsesObservedDreamCubeRequest(t *testing.T) {
request, err := parseCurrentAvatarBindPlusRequest(observedDreamCubeRequest(t))
if err != nil {
t.Fatal(err)
}
if request.RequestedItemID != 101560739 || request.Count != 8 || request.AbilityNo != 4 || request.BodyLength != 144 {
t.Fatalf("request=%+v", request)
}
want := observedDreamCubeMaterials()
if len(request.Materials) != len(want) {
t.Fatalf("materials=%d want=%d", len(request.Materials), len(want))
}
for index := range want {
if request.Materials[index] != want[index] {
t.Fatalf("material[%d]=%+v want=%+v", index, request.Materials[index], want[index])
}
}
}
func TestCurrentAvatarBindPlusRejectsMalformedRequests(t *testing.T) {
for _, test := range []struct {
name string
mutate func(body []byte) []byte
}{
{"too short", func([]byte) []byte { return make([]byte, 8) }},
{"zero requested item", func(body []byte) []byte {
binary.LittleEndian.PutUint32(body[0x10:0x14], 0)
return body
}},
{"zero material count", func(body []byte) []byte {
binary.LittleEndian.PutUint16(body[0x16:0x18], 0)
return body
}},
{"count over eight", func(body []byte) []byte {
binary.LittleEndian.PutUint16(body[0x16:0x18], 9)
return body
}},
{"duplicate material slot", func(body []byte) []byte {
copy(body[0x1E:0x24], body[0x18:0x1E])
return body
}},
{"truncated materials", func(body []byte) []byte { return body[:0x20] }},
} {
t.Run(test.name, func(t *testing.T) {
if _, err := parseCurrentAvatarBindPlusRequest(test.mutate(observedDreamCubeRequest(t))); err == nil {
t.Fatal("expected rejection")
}
})
}
}
// TestCurrentAvatarBindPlusSuccessBodyMatchesDecodedHandler 把回包逐字节钉死。
// 形状读自客户端处理器 0x1135C10(ReadBytes 0x36)与工作函数 0x1135680 /
// 0x1F2B470(容器下标)/ 0x1F2B850(按槽扣数量)。
func TestCurrentAvatarBindPlusSuccessBodyMatchesDecodedHandler(t *testing.T) {
outcome := currentAvatarBindPlusOutcome{
OutputItemID: 101560739,
OutputSlot: 35,
AbilityNo: 4,
ConsumedSlots: []int16{35, 36, 28, 29, 22, 26, 27, 33},
}
body := buildCurrentAvatarBindPlusSuccessBody(outcome)
if len(body) != currentAvatarBindPlusReplySize {
t.Fatalf("reply=%d want=%d", len(body), currentAvatarBindPlusReplySize)
}
if body[0] != 1 {
t.Fatalf("status=%d", body[0])
}
structured := body[1:]
if len(structured) != currentAvatarBindPlusResultSize {
t.Fatalf("struct=%d", len(structured))
}
if got := binary.LittleEndian.Uint32(structured[0x00:0x04]); got != 0 {
t.Fatalf("+0x00=%d", got)
}
// +0x04 是容器下标(结果对象写进 container[槽]):0x1F2B55D `mov [eax+ecx*4], esi`。
if got := binary.LittleEndian.Uint16(structured[0x04:0x06]); got != 35 {
t.Fatalf("+0x04=%d want=35(新道具落在哪个槽)", got)
}
if got := binary.LittleEndian.Uint32(structured[0x06:0x0A]); got != 101560739 {
t.Fatalf("+0x06=%d want=101560739", got)
}
// +0x0A 是玩家选的属性(结果描述符构造器的参数)。现场曾经把槽号写在这里,
// 客户端就把属性显示成了那个数字。
if got := binary.LittleEndian.Uint16(structured[0x0A:0x0C]); got != 4 {
t.Fatalf("+0x0A=%d want=4(玩家选的属性)", got)
}
if got := binary.LittleEndian.Uint16(structured[0x0C:0x0E]); got != 1 {
t.Fatalf("+0x0C=%d want=1", got)
}
for index, want := range []int16{35, 36, 28, 29, 22, 26, 27, 33} {
offset := 0x0E + index*2
if got := int16(binary.LittleEndian.Uint16(structured[offset : offset+2])); got != want {
t.Fatalf("slot[%d]=%d want=%d", index, got, want)
}
}
for offset := 0x1E; offset < currentAvatarBindPlusResultSize; offset++ {
if structured[offset] != 0 {
t.Fatalf("tail byte %#x=%d want=0", offset, structured[offset])
}
}
if got := hex.EncodeToString(currentAvatarBindPlusFailureBody()); got != "001600" {
t.Fatalf("failure=%s", got)
}
}
// TestCurrentAvatarBindPlusPadsEmptyMaterialGrid 短装(count<8)时后补 0:
// 客户端只为它自己填过的格子取用这些值,空格的 0 不会被读。
func TestCurrentAvatarBindPlusPadsEmptyMaterialGrid(t *testing.T) {
body := buildCurrentAvatarBindPlusSuccessBody(currentAvatarBindPlusOutcome{
OutputItemID: 2004,
OutputSlot: 21,
ConsumedSlots: []int16{21, 22},
})
structured := body[1:]
if got := int16(binary.LittleEndian.Uint16(structured[0x0E:0x10])); got != 21 {
t.Fatalf("slot0=%d", got)
}
if got := int16(binary.LittleEndian.Uint16(structured[0x10:0x12])); got != 22 {
t.Fatalf("slot1=%d", got)
}
for index := 2; index < currentAvatarBindPlusMaterialCount; index++ {
offset := 0x0E + index*2
if got := binary.LittleEndian.Uint16(structured[offset : offset+2]); got != 0 {
t.Fatalf("slot[%d]=%d want=0", index, got)
}
}
}
// TestRealPVFAvatarBindPlusResolvesDreamCubeTable 读真实 Script.pvf:
// 套装 0 的剑士头发必须解析成现场请求里那个 101560739。
func TestRealPVFAvatarBindPlusResolvesDreamCubeTable(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
pvfPath = realLukeRewardPVFPath()
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Skipf("runtime Script.pvf unavailable: %v", err)
}
config, err := loadCurrentAvatarBindPlusConfig(archive)
if err != nil {
t.Fatalf("load %s: %v", currentAvatarBindPlusPVFPath, err)
}
jobKey, err := currentAvatarBindPlusJobKey(archive, 0) // job 0 = 鬼剑士
if err != nil {
t.Fatalf("job key: %v", err)
}
if jobKey != "swordman" {
t.Fatalf("jobKey=%q", jobKey)
}
output, err := currentAvatarBindPlusResolveOutput(config, jobKey, 101560739)
if err != nil {
t.Fatalf("resolve observed output: %v", err)
}
if output.SetIndex != 0 || output.Part != "hair" {
t.Fatalf("output=%+v", output)
}
// 上一轮现场(20:21)点名的是 101546903:两张 PVF 里都没有这个道具,
// 说明它不是这套表的输出,必须被拒。
if _, err := currentAvatarBindPlusResolveOutput(config, jobKey, 101546903); err == nil {
t.Fatal("101546903 must not resolve through the absolute-bind table")
}
// 别的职业的输出不能被鬼剑士点名。
if _, err := currentAvatarBindPlusResolveOutput(config, jobKey, 102560721); err == nil {
t.Fatal("fighter hair must not be requestable by a swordman")
}
// 同一批道具挂在 [swordman]/[demonic swordman]/[knight] 三个名字下,三个都要能解析。
for _, key := range []string{"swordman", "demonicswordman", "knight"} {
if _, err := currentAvatarBindPlusResolveOutput(config, key, 101560739); err != nil {
t.Fatalf("job=%s: %v", key, err)
}
}
if len(config.Values["hair"]) != 3 || config.Goals["hair"] != 2970 {
t.Fatalf("hair values=%v goal=%d", config.Values["hair"], config.Goals["hair"])
}
}
func newAvatarBindPlusPVFTestSource() avatarCompoundPVFTestSource {
cube := "[absolute bind cube]\n0\n[absolute bind plus rare]\n" +
"`[swordman]` `hair` 2004 `hat` 2005 `face` 2006 `neck` 2007 `coat` 2008 `pants` 2009 `belt` 2010 `shoes` 2011 " +
"`[fighter]` `hair` 3004 `hat` 3005 `face` 3006 `neck` 3007 `coat` 3008 `pants` 3009 `belt` 3010 `shoes` 3011 \n" +
"[/absolute bind plus rare]\n[/absolute bind cube]\n" +
"[absolute bind cube]\n1\n[absolute bind plus rare]\n" +
"`[swordman]` `hair` 4004 `hat` 4005 `face` 4006 `neck` 4007 `coat` 4008 `pants` 4009 `belt` 4010 `shoes` 4011 \n" +
"[/absolute bind plus rare]\n[/absolute bind cube]\n" +
"[absolute bind plus value]\n`hair` 198 264 792 \n[/absolute bind plus value]\n" +
"[output avatar goal value]\n`hair` 2970 \n[/output avatar goal value]\n"
return avatarCompoundPVFTestSource{
"etc/compoundavatar.etc": "1 `CompoundAvatar_swordman.etc` 2 `CompoundAvatar_fighter.etc`\n",
"etc/chn_absolute_bind_cube.etc": cube,
}
}
// newAvatarBindPlusCatalog 造一个最小目录:777 = 套装 0 的绝对绑定合成器,
// 778 = 套装 1 的,200x = 套装 0 的输出,301x = 材料装扮。
func newAvatarBindPlusCatalog(source avatarCompoundPVFTestSource, materials []currentAvatarBindPlusMaterial) *pvfDungeonDropCatalog {
source["equipment/output.equ"] = "[equipment type] `[hair avatar]`\n[grade] 3\n"
source["equipment/material.equ"] = "[equipment type] `[hair avatar]`\n[grade] 1\n"
refs := map[uint32]dungeonDropItemReference{
777: {kind: dungeonDropItemStackable, path: "cube.stk"},
778: {kind: dungeonDropItemStackable, path: "cube.stk"},
2004: {kind: dungeonDropItemEquipment, path: "output.equ"},
}
definitions := map[uint32]dungeonDropItemDefinition{
777: {ItemID: 777, Kind: dungeonDropItemStackable, PVFPath: "stackable/cube.stk", ActionType: "[absolute bind cube]", ActionValues: &dungeonDropActionValues{Values: []int64{0, 8, 2, 0, 0}}},
778: {ItemID: 778, Kind: dungeonDropItemStackable, PVFPath: "stackable/cube.stk", ActionType: "[absolute bind cube]", ActionValues: &dungeonDropActionValues{Values: []int64{1, 8, 2, 0, 0}}},
2004: {ItemID: 2004, Kind: dungeonDropItemEquipment, PVFPath: "equipment/output.equ", EquipmentType: "[hair avatar]", Grade: 3},
}
for index, material := range materials {
refs[material.ItemID] = dungeonDropItemReference{kind: dungeonDropItemEquipment, path: "material.equ"}
definitions[material.ItemID] = dungeonDropItemDefinition{
ItemID: material.ItemID, Kind: dungeonDropItemEquipment,
PVFPath: "equipment/material.equ", EquipmentType: "[hair avatar]", Grade: int64(index + 1),
}
}
return &pvfDungeonDropCatalog{source: source, itemRefs: refs, itemCache: definitions}
}
func syntheticBindPlusMaterials() []currentAvatarBindPlusMaterial {
return []currentAvatarBindPlusMaterial{
{Slot: 21, ItemID: 3001}, {Slot: 22, ItemID: 3002}, {Slot: 23, ItemID: 3003}, {Slot: 24, ItemID: 3004},
{Slot: 25, ItemID: 3005}, {Slot: 26, ItemID: 3006}, {Slot: 27, ItemID: 3007}, {Slot: 28, ItemID: 3008},
}
}
func bindPlusTestService(t *testing.T, ctx context.Context, slots map[string]dnfrepo.ItemStack) (*Service, dnfrepo.Group) {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "dnf:1", Job: "0"}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: slots}); err != nil {
t.Fatal(err)
}
service := &Service{options: options{accountPrefix: defaultAccountPrefix},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
return service, repositories
}
// parseBindPlusItemRow 拆一条 0x14 增量行:u8 列表 / u16 行数 / 每行 0x77 字节,
// 行首就是 (slot, itemID, amount)。
func parseBindPlusItemRow(t *testing.T, body []byte) (byte, int16, uint32, uint32) {
t.Helper()
if len(body) < 3+currentItemListEntryWireSize {
t.Fatalf("item row too short: %d", len(body))
}
listType := body[0]
if count := binary.LittleEndian.Uint16(body[1:3]); count != 1 {
t.Fatalf("item row count=%d want=1 body=%x", count, body)
}
entry := body[3:]
return listType,
int16(binary.LittleEndian.Uint16(entry[0x00:0x02])),
binary.LittleEndian.Uint32(entry[0x02:0x06]),
binary.LittleEndian.Uint32(entry[0x06:0x0A])
}
// TestCurrentAvatarBindPlusSendsCubeRowThenReplyThenOutputRow 钉住三包顺序:
//
// 1) 合成器那一格的增量行 —— 现场"合成器不及时消失、要整理背包才消失"的修复:
// 客户端 op1119 的处理器只认回包 +0x0E 的 8 个材料格,合成器那一格必须单独下发;
// 2) class-1 op1119 成功回包;
// 3) 结果装扮那一格的权威行 —— 把客户端自建对象的属性校正成服务端口径。
func TestCurrentAvatarBindPlusSendsCubeRowThenReplyThenOutputRow(t *testing.T) {
ctx := context.Background()
materials := syntheticBindPlusMaterials()
// 提交之后的真实仓库状态:合成器由 2 变 1,第一个材料槽变成输出装扮。
committed := map[string]dnfrepo.ItemStack{"0:93": {ItemID: 777, Count: 1}}
for _, material := range materials[1:] {
committed["1:"+strconv.Itoa(int(material.Slot))] = dnfrepo.ItemStack{ItemID: int64(material.ItemID), Count: 1}
}
committed["1:21"] = dnfrepo.ItemStack{
ItemID: 2004, Count: 1,
Extra: map[string]string{"avatar_ability_no": "7", "item_kind": "avatar"},
}
service, _ := bindPlusTestService(t, ctx, committed)
connection := &bufferConn{}
session := &gameSession{conn: connection, accountID: "dnf:1", selectedCharacterID: 19}
outcome := currentAvatarBindPlusOutcome{
OutputItemID: 2004, OutputSlot: 21, AbilityNo: 7,
ConsumedSlots: []int16{21, 22, 23, 24, 25, 26, 27, 28},
SetIndex: 0, Part: "hair", CubeItemID: 777,
CubeListType: dnfrepo.MainInventoryListType, CubeSlot: 93, CubeRemain: 1,
}
if err := service.sendCurrentAvatarBindPlusResult(session, outcome); err != nil {
t.Fatal(err)
}
first, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
second, rest := splitGameServerUpperPacket(t, rest)
third, rest := splitGameServerUpperPacket(t, rest)
if len(rest) != 0 {
t.Fatalf("多出来的字节: %x", rest)
}
// 1) 合成器行:主背包(list 0) 槽 93,整堆终值 = 1(不是删除行)。
if first.Header.Classification != 0 || first.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("第一包必须先是增量行: %+v", first.Header)
}
listType, slot, itemID, amount := parseBindPlusItemRow(t, first.Body)
if listType != dnfrepo.MainInventoryListType || slot != 93 || itemID != 777 || amount != 1 {
t.Fatalf("合成器行 = list%d slot=%d item=%d amount=%d,want list0 slot93 item777 amount1",
listType, slot, itemID, amount)
}
// 2) 回包:class-1 op1119,body 逐字节等于构造器输出。
if second.Header.Classification != 1 || second.Header.MsgID != uint16(dnfenum.CmdPacketBindPlus) {
t.Fatalf("第二包必须是 class-1 op1119: %+v", second.Header)
}
if want := buildCurrentAvatarBindPlusSuccessBody(outcome); !bytes.Equal(second.Body, want) {
t.Fatalf("回包=%x\nwant=%x", second.Body, want)
}
// 3) 结果装扮行:装扮栏(list 1) 槽 21,道具 2004。
if third.Header.Classification != 0 || third.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("第三包必须是增量行: %+v", third.Header)
}
listType, slot, itemID, amount = parseBindPlusItemRow(t, third.Body)
if listType != currentAvatarInventoryListType || slot != 21 || itemID != 2004 || amount != 1 {
t.Fatalf("结果行 = list%d slot=%d item=%d amount=%d,want list1 slot21 item2004 amount1",
listType, slot, itemID, amount)
}
}
// TestCurrentAvatarBindPlusCubeRowIsDeletionWhenStackEmpties 最后一个合成器用完时,
// 那一行必须是删除行(itemID 0xFFFFFFFF),否则客户端会留着一个数量 0 的空图标。
func TestCurrentAvatarBindPlusCubeRowIsDeletionWhenStackEmpties(t *testing.T) {
ctx := context.Background()
service, _ := bindPlusTestService(t, ctx, map[string]dnfrepo.ItemStack{
"1:21": {ItemID: 2004, Count: 1, Extra: map[string]string{"avatar_ability_no": "4"}},
})
connection := &bufferConn{}
session := &gameSession{conn: connection, accountID: "dnf:1", selectedCharacterID: 19}
outcome := currentAvatarBindPlusOutcome{
OutputItemID: 2004, OutputSlot: 21, AbilityNo: 4, ConsumedSlots: []int16{21},
CubeItemID: 777, CubeListType: dnfrepo.MainInventoryListType, CubeSlot: 93, CubeRemain: 0,
}
if err := service.sendCurrentAvatarBindPlusResult(session, outcome); err != nil {
t.Fatal(err)
}
first, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
_, rest = splitGameServerUpperPacket(t, rest) // class-1 op1119 回包
_, rest = splitGameServerUpperPacket(t, rest) // 结果装扮那一行
if len(rest) != 0 {
t.Fatalf("多出来的字节: %x", rest)
}
_, slot, itemID, amount := parseBindPlusItemRow(t, first.Body)
if slot != 93 || itemID != math.MaxUint32 || amount != 0 {
t.Fatalf("空合成器行 = slot=%d item=%d amount=%d,want slot93 item=0xFFFFFFFF amount=0",
slot, itemID, amount)
}
}
func TestCurrentAvatarBindPlusCommitsEightMaterialsAndOneCube(t *testing.T) {
ctx := context.Background()
materials := syntheticBindPlusMaterials()
catalog := newAvatarBindPlusCatalog(newAvatarBindPlusPVFTestSource(), materials)
slots := map[string]dnfrepo.ItemStack{"0:93": {ItemID: 777, Count: 2}}
for _, material := range materials {
slots["1:"+strconv.Itoa(int(material.Slot))] = dnfrepo.ItemStack{ItemID: int64(material.ItemID), Count: 1}
}
service, repositories := bindPlusTestService(t, ctx, slots)
session := &gameSession{accountID: "dnf:1", selectedCharacterID: 19}
outcome, err := service.commitCurrentAvatarBindPlus(ctx, session, catalog, currentAvatarBindPlusRequest{
RequestedItemID: 2004, AbilityNo: 7, Count: 8, Materials: materials, BodyLength: 144,
})
if err != nil {
t.Fatal(err)
}
if outcome.OutputItemID != 2004 || outcome.OutputSlot != 21 || outcome.SetIndex != 0 ||
outcome.Part != "hair" || outcome.CubeItemID != 777 || outcome.CubeRemain != 1 {
t.Fatalf("outcome=%+v", outcome)
}
if len(outcome.ConsumedSlots) != 8 || outcome.ConsumedSlots[7] != 28 {
t.Fatalf("consumed=%v", outcome.ConsumedSlots)
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load found=%t err=%v", found, err)
}
if inventory.Slots["0:93"].Count != 1 {
t.Fatalf("cube=%+v", inventory.Slots["0:93"])
}
for _, material := range materials[1:] {
if _, remains := inventory.Slots["1:"+strconv.Itoa(int(material.Slot))]; remains {
t.Fatalf("material slot %d remains", material.Slot)
}
}
output := inventory.Slots["1:21"]
if output.ItemID != 2004 || output.Count != 1 || output.Extra["avatar_ability_no"] != "7" ||
len(output.RawEntry) != currentItemListEntryWireSize {
t.Fatalf("output=%+v", output)
}
}
func TestCurrentAvatarBindPlusLastCubeRemovesTheStack(t *testing.T) {
ctx := context.Background()
materials := syntheticBindPlusMaterials()
catalog := newAvatarBindPlusCatalog(newAvatarBindPlusPVFTestSource(), materials)
slots := map[string]dnfrepo.ItemStack{"0:93": {ItemID: 777, Count: 1}}
for _, material := range materials {
slots["1:"+strconv.Itoa(int(material.Slot))] = dnfrepo.ItemStack{ItemID: int64(material.ItemID), Count: 1}
}
service, repositories := bindPlusTestService(t, ctx, slots)
outcome, err := service.commitCurrentAvatarBindPlus(ctx, &gameSession{accountID: "dnf:1", selectedCharacterID: 19},
catalog, currentAvatarBindPlusRequest{RequestedItemID: 2004, AbilityNo: 4, Count: 8, Materials: materials, BodyLength: 144})
if err != nil {
t.Fatal(err)
}
if outcome.CubeRemain != 0 {
t.Fatalf("outcome=%+v", outcome)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
if _, remains := inventory.Slots["0:93"]; remains {
t.Fatalf("empty cube stack remains: %+v", inventory.Slots["0:93"])
}
}
func TestCurrentAvatarBindPlusRejectsAndRollsBack(t *testing.T) {
for _, test := range []struct {
name string
mutate func(slots map[string]dnfrepo.ItemStack)
requestedItem uint32
wantErr error
}{
{"no cube in bag", func(slots map[string]dnfrepo.ItemStack) { delete(slots, "0:93") }, 2004, errCurrentAvatarBindPlusCube},
{"cube of another set only", func(slots map[string]dnfrepo.ItemStack) { slots["0:93"] = dnfrepo.ItemStack{ItemID: 778, Count: 2} }, 2004, errCurrentAvatarBindPlusCube},
{"material item mismatch", func(slots map[string]dnfrepo.ItemStack) {
slots["1:22"] = dnfrepo.ItemStack{ItemID: 9999, Count: 1}
}, 2004, errCurrentAvatarBindPlusMaterial},
{"material slot empty", func(slots map[string]dnfrepo.ItemStack) { delete(slots, "1:25") }, 2004, errCurrentAvatarBindPlusMaterial},
{"material stacked twice", func(slots map[string]dnfrepo.ItemStack) {
slots["1:25"] = dnfrepo.ItemStack{ItemID: 3005, Count: 2}
}, 2004, errCurrentAvatarBindPlusMaterial},
{"unknown output", func(map[string]dnfrepo.ItemStack) {}, 9999, errCurrentAvatarBindPlusOutputInvalid},
} {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
materials := syntheticBindPlusMaterials()
catalog := newAvatarBindPlusCatalog(newAvatarBindPlusPVFTestSource(), materials)
slots := map[string]dnfrepo.ItemStack{"0:93": {ItemID: 777, Count: 2}}
for _, material := range materials {
slots["1:"+strconv.Itoa(int(material.Slot))] = dnfrepo.ItemStack{ItemID: int64(material.ItemID), Count: 1}
}
test.mutate(slots)
// 记下"动过之后"的现场:回滚必须把它原样还回来。
wantCube, wantCubeExists := slots["0:93"]
wantMaterial := slots["1:21"]
service, repositories := bindPlusTestService(t, ctx, slots)
_, err := service.commitCurrentAvatarBindPlus(ctx, &gameSession{accountID: "dnf:1", selectedCharacterID: 19},
catalog, currentAvatarBindPlusRequest{
RequestedItemID: test.requestedItem, AbilityNo: 4, Count: 8, Materials: materials, BodyLength: 144,
})
if !errors.Is(err, test.wantErr) {
t.Fatalf("error=%v want=%v", err, test.wantErr)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
if got, exists := inventory.Slots["0:93"]; exists != wantCubeExists || (exists && got.Count != wantCube.Count) {
t.Fatalf("cube not rolled back: exists=%t got=%+v wantExists=%t want=%+v", exists, got, wantCubeExists, wantCube)
}
if got, exists := inventory.Slots["1:21"]; !exists || got.ItemID != wantMaterial.ItemID {
t.Fatalf("material 21 not rolled back: %+v", got)
}
})
}
}