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

613 lines
25 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"
"fmt"
"path/filepath"
"strconv"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfexpertjob "longheng.io/server/internal/modules/dnf/expertjob"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// 【op416 副职业提取器批量提取 2026-09-25】回归闸门。
//
// 这一组用例回答三个问题:
// 1. 6 字节单件请求永远不被当成批量信封(老客户端 / 单件提取行为不变);
// 2. 批量信封的字段与校验边界(哨兵、长度、重复槽、提取器槽混入、职业类型、上限);
// 3. 端到端:一个信封在真机 Script.pvf 上跑完,只回**一个** ACK、只重建**一次**容器,
// 而且与"逐件发 N 次单件请求"得到相同的仓库状态与材料总量。
// ---------------------------------------------------------------------------
// 1) 解析层
// ---------------------------------------------------------------------------
// 单件 6 字节请求必须原样落到原生路径。这里连"targetSlot 恰好是 0xFFFF"这种
// 最容易与哨兵混淆的形状也钉住:长度不足 11 就永远不是信封。
func TestParseCurrentExpertJobExtractionBatchKeepsSingleRequestsNative(t *testing.T) {
single := []byte{2, 9, 0, 0, 10, 0}
batch, isBatch, err := parseCurrentExpertJobExtractionBatchRequest(single)
if err != nil || isBatch || batch.Slots != nil {
t.Fatalf("six-byte single request misread as batch: batch=%+v is_batch=%t err=%v", batch, isBatch, err)
}
parsed, err := parseCurrentExpertJobExtractionRequest(single)
if err != nil || parsed.ExtractorType != 2 || parsed.ExtractorSlot != 9 || parsed.TargetList != 0 || parsed.TargetSlot != 10 {
t.Fatalf("single request no longer parses: %+v err=%v", parsed, err)
}
// 单件请求的 targetSlot 位置写 -1:单件解析器本来就拒绝(TargetSlot<0),
// 而批量解析器因为长度不足同样不认——两边都不会误判。
negative := []byte{2, 9, 0, 0, 0xFF, 0xFF}
if _, isBatch, err := parseCurrentExpertJobExtractionBatchRequest(negative); err != nil || isBatch {
t.Fatalf("negative target slot misread as batch: is_batch=%t err=%v", isBatch, err)
}
if _, err := parseCurrentExpertJobExtractionRequest(negative); err == nil {
t.Fatal("negative single target slot accepted")
}
}
func TestParseCurrentExpertJobExtractionBatchAcceptsHookEnvelope(t *testing.T) {
body := currentExpertJobExtractionBatchTestBody(2, 240, 0, []int16{65, 66, 67})
batch, isBatch, err := parseCurrentExpertJobExtractionBatchRequest(body)
if err != nil || !isBatch {
t.Fatalf("envelope rejected: batch=%+v is_batch=%t err=%v", batch, isBatch, err)
}
if batch.ExtractorType != 2 || batch.ExtractorSlot != 240 || batch.TargetList != 0 ||
len(batch.Slots) != 3 || batch.Slots[0] != 65 || batch.Slots[1] != 66 || batch.Slots[2] != 67 {
t.Fatalf("envelope fields=%+v", batch)
}
if len(body) != currentExpertJobExtractionBatchHeaderSize+6 {
t.Fatalf("envelope length=%d", len(body))
}
// 三个职业(附魔师 1 / 炼金师 2 / 控偶师 4)必须被同一个解析器接受:
// 它们共用 op416 与同一条客户端循环,差别只有这一个字节。
for _, extractorType := range []byte{1, 2, 4} {
if _, isBatch, err := parseCurrentExpertJobExtractionBatchRequest(
currentExpertJobExtractionBatchTestBody(extractorType, 240, 0, []int16{65, 66})); err != nil || !isBatch {
t.Fatalf("extractor type %d rejected: is_batch=%t err=%v", extractorType, isBatch, err)
}
}
}
func TestParseCurrentExpertJobExtractionBatchRejectsBrokenEnvelopes(t *testing.T) {
cases := []struct {
name string
body []byte
}{
{name: "count_zero", body: currentExpertJobExtractionBatchTestBody(2, 240, 0, nil)},
{name: "unsupported_profession_type", body: currentExpertJobExtractionBatchTestBody(3, 240, 0, []int16{65, 66})},
{name: "target_list_not_main", body: currentExpertJobExtractionBatchTestBody(2, 240, 2, []int16{65, 66})},
{name: "extractor_slot_inside_selection", body: currentExpertJobExtractionBatchTestBody(2, 65, 0, []int16{65, 66})},
{name: "duplicate_slot", body: currentExpertJobExtractionBatchTestBody(2, 240, 0, []int16{65, 65})},
{name: "negative_slot", body: currentExpertJobExtractionBatchTestBody(2, 240, 0, []int16{-1, 66})},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
if _, isBatch, err := parseCurrentExpertJobExtractionBatchRequest(test.body); err == nil || !isBatch {
t.Fatalf("is_batch=%t err=%v body=%x", isBatch, err, test.body)
}
})
}
// 长度与 count 不匹配、count 超过上限:仍然是"批量但坏了"(is_batch=true),
// 调用方据此回失败码,而不是掉进单件路径去解析一个半截信封。
truncated := currentExpertJobExtractionBatchTestBody(2, 240, 0, []int16{65, 66})
if _, isBatch, err := parseCurrentExpertJobExtractionBatchRequest(truncated[:len(truncated)-2]); err == nil || !isBatch {
t.Fatalf("truncated envelope is_batch=%t err=%v", isBatch, err)
}
over := currentExpertJobExtractionBatchTestBody(2, 240, 0, []int16{65, 66})
over[10] = currentExpertJobExtractionBatchMaxItems + 1
if _, isBatch, err := parseCurrentExpertJobExtractionBatchRequest(over); err == nil || !isBatch {
t.Fatalf("over-limit envelope is_batch=%t err=%v", isBatch, err)
}
// 标记写错 = 不是信封(避免把随机 6 字节以上的旧形状误判成批量)。
wrongMarker := currentExpertJobExtractionBatchTestBody(2, 240, 0, []int16{65, 66})
wrongMarker[7] = 'Z'
if _, isBatch, err := parseCurrentExpertJobExtractionBatchRequest(wrongMarker); err != nil || isBatch {
t.Fatalf("wrong marker is_batch=%t err=%v", isBatch, err)
}
}
// ---------------------------------------------------------------------------
// 2) 纯函数:行合并 + 合并回包
// ---------------------------------------------------------------------------
// 同一格的多行必须合成一行:客户端是按行把回包应用到容器行上的,同一槽出现多行时
// 它的语义取决于底层是置值还是累加(未取证)。op26 批量的合并口径与此一致。
func TestCurrentExpertJobMergeExtractionBatchRowsCollapsesSameSlotRows(t *testing.T) {
merged := currentExpertJobMergeExtractionBatchRows([]currentExpertJobExtractionMaterial{
{Slot: 65, ItemID: 2610024, Count: 1},
{Slot: 65, ItemID: 2610024, Count: 2},
{Slot: 66, ItemID: 2610024, Count: 1},
{Slot: 358, ItemID: 3037, Count: 5},
{Slot: 65, ItemID: 3037, Count: 4},
})
want := []currentExpertJobExtractionMaterial{
{Slot: 65, ItemID: 2610024, Count: 3},
{Slot: 66, ItemID: 2610024, Count: 1},
{Slot: 358, ItemID: 3037, Count: 5},
{Slot: 65, ItemID: 3037, Count: 4},
}
if len(merged) != len(want) {
t.Fatalf("merged=%+v", merged)
}
for index := range want {
if merged[index] != want[index] {
t.Fatalf("merged[%d]=%+v want %+v (all=%+v)", index, merged[index], want[index], merged)
}
}
if got := currentExpertJobMergeExtractionBatchRows(nil); len(got) != 0 {
t.Fatalf("nil rows merged into %+v", got)
}
if rows := []currentExpertJobExtractionMaterial{{Slot: 65, ItemID: 2610024, Count: 1}}; len(currentExpertJobMergeExtractionBatchRows(rows)) != 1 {
t.Fatal("single row changed by merge")
}
}
func TestBuildCurrentExpertJobExtractionSuccessBodyCarriesMergedRows(t *testing.T) {
body := buildCurrentExpertJobExtractionSuccessBody(currentExpertJobExtractionResult{
JobType: 2,
TargetList: 0,
TargetSlot: 65,
Materials: []currentExpertJobExtractionMaterial{
{Slot: 65, ItemID: 2610024, Count: 2},
{Slot: 358, ItemID: 3037, Count: 7},
},
})
// u8 flag=1 | u8 targetList | u16 targetSlot | u8 rowCount | rows{u16 slot,u32 itemID,u32 count}
want := []byte{1, 0, 65, 0, 2}
want = binary.LittleEndian.AppendUint16(want, 65)
want = binary.LittleEndian.AppendUint32(want, 2610024)
want = binary.LittleEndian.AppendUint32(want, 2)
want = binary.LittleEndian.AppendUint16(want, 358)
want = binary.LittleEndian.AppendUint32(want, 3037)
want = binary.LittleEndian.AppendUint32(want, 7)
if len(body) != len(want) {
t.Fatalf("body=%x want=%x", body, want)
}
for index := range want {
if body[index] != want[index] {
t.Fatalf("body[%d]=%02x want %02x (body=%x)", index, body[index], want[index], body)
}
}
}
// ---------------------------------------------------------------------------
// 3) 端到端(真机 Script.pvf)
// ---------------------------------------------------------------------------
// 一个批量信封必须只回一个 ACK、只重建一次容器,并且与"逐件发 N 次单件请求"
// 得到相同的仓库状态与材料总量。
//
// 为了让"相同"是可判定的:测试在真机数据里挑一件**产物与随机数无关**的装备
// (炼金提取器 + 稀有度 0/1 装备:产物恒为基础材料行),只有经验增益在
// [min,max] 之间随机,因此对经验只断言区间,对产物与槽位断言精确相等。
func TestRealScriptPVFExtractionBatchSendsOneAckAndMatchesPerItemRequests(t *testing.T) {
items, expert, archive := currentExtractionBatchRealPVFAssets(t)
extractorID, equipmentID := currentExtractionBatchRealPVFProbe(t, expert, archive)
batchFixture := newCurrentExtractionBatchFixture(t, items, expert, extractorID, equipmentID)
batchPackets := batchFixture.run(t, currentExpertJobExtractionBatchTestBody(2, 240, 0, []int16{65, 66}))
batchSnapshot := batchFixture.snapshot(t)
singleFixture := newCurrentExtractionBatchFixture(t, items, expert, extractorID, equipmentID)
singlePackets := singleFixture.run(t, currentExpertJobExtractionTestBody(2, 240, 0, 65))
singlePackets = append(singlePackets, singleFixture.run(t, currentExpertJobExtractionTestBody(2, 240, 0, 66))...)
singleSnapshot := singleFixture.snapshot(t)
// ---- 包形状:批量 = 1 个 ACK + 1 次容器重建;逐件 = 2 个 ACK + 2 次重建 ----
if got := countCurrentExtractionBatchAcks(batchPackets); got != 1 {
t.Fatalf("batch ACKs=%d want 1 packets=%s", got, describeCurrentExtractionBatchPackets(batchPackets))
}
if got := countCurrentExtractionBatchAcks(singlePackets); got != 2 {
t.Fatalf("single ACKs=%d want 2 packets=%s", got, describeCurrentExtractionBatchPackets(singlePackets))
}
batchRefreshes := countCurrentExtractionBatchRefreshes(batchPackets)
if batchRefreshes == 0 {
t.Fatalf("batch sent no container rebuild: %s", describeCurrentExtractionBatchPackets(batchPackets))
}
if got := countCurrentExtractionBatchRefreshes(singlePackets); got != 2*batchRefreshes {
t.Fatalf("refreshes batch=%d single=%d want %d", batchRefreshes, got, 2*batchRefreshes)
}
if len(batchPackets) >= len(singlePackets) {
t.Fatalf("batch packets=%d single packets=%d (batch must be strictly cheaper)",
len(batchPackets), len(singlePackets))
}
// ---- ACK 本体:只命名一个 targetSlot(第一个被消耗的槽)----
batchAck := firstCurrentExtractionBatchAck(t, batchPackets)
if batchAck.body[0] != 1 || batchAck.body[1] != 0 || binary.LittleEndian.Uint16(batchAck.body[2:4]) != 65 {
t.Fatalf("batch ACK head=%x", batchAck.body)
}
rowCount := int(batchAck.body[4])
if rowCount < 1 || len(batchAck.body) != 5+rowCount*10 {
t.Fatalf("batch ACK rows=%d body=%x", rowCount, batchAck.body)
}
ackMaterialTotal := int64(0)
for index := 0; index < rowCount; index++ {
offset := 5 + index*10
if itemID := binary.LittleEndian.Uint32(batchAck.body[offset+2 : offset+6]); itemID != batchSnapshot.materialItemID {
t.Fatalf("batch ACK row %d item=%d want %d body=%x", index, itemID, batchSnapshot.materialItemID, batchAck.body)
}
ackMaterialTotal += int64(binary.LittleEndian.Uint32(batchAck.body[offset+6 : offset+10]))
}
// ---- 仓库状态:两件都被消耗、提取器留着、材料总量一致且与 ACK 对得上 ----
for name, snapshot := range map[string]currentExtractionBatchSnapshot{"batch": batchSnapshot, "single": singleSnapshot} {
if !snapshot.extractorPresent {
t.Fatalf("%s: extractor was consumed: %+v", name, snapshot)
}
if snapshot.source65Present || snapshot.source66Present {
t.Fatalf("%s: consumed source survived: %+v", name, snapshot)
}
if snapshot.materialTotal != 2 {
t.Fatalf("%s: material total=%d want 2 snapshot=%+v", name, snapshot.materialTotal, snapshot)
}
if snapshot.materialRowCount != 1 {
t.Fatalf("%s: material rows=%d want 1 (same-slot rows must merge) snapshot=%+v", name, snapshot.materialRowCount, snapshot)
}
if snapshot.experienceGain < 0 || snapshot.experienceGain > 2 {
t.Fatalf("%s: experience gain=%d want 0..2", name, snapshot.experienceGain)
}
}
if ackMaterialTotal != batchSnapshot.materialTotal {
t.Fatalf("ACK material total=%d repository=%d", ackMaterialTotal, batchSnapshot.materialTotal)
}
if batchSnapshot.materialItemID != singleSnapshot.materialItemID ||
batchSnapshot.materialTotal != singleSnapshot.materialTotal ||
batchSnapshot.materialRowCount != singleSnapshot.materialRowCount {
t.Fatalf("batch snapshot=%+v single snapshot=%+v", batchSnapshot, singleSnapshot)
}
}
// 整批原子:选中集合里混进一个已经空掉的槽时,整批拒绝,已选中的那件也不能被消耗。
func TestRealScriptPVFExtractionBatchRollsBackWhenOneTargetIsStale(t *testing.T) {
items, expert, archive := currentExtractionBatchRealPVFAssets(t)
extractorID, equipmentID := currentExtractionBatchRealPVFProbe(t, expert, archive)
fixture := newCurrentExtractionBatchFixture(t, items, expert, extractorID, equipmentID)
packets := fixture.run(t, currentExpertJobExtractionBatchTestBody(2, 240, 0, []int16{65, 99}))
if len(packets) != 1 {
t.Fatalf("rejected batch packets=%s", describeCurrentExtractionBatchPackets(packets))
}
packet := packets[0]
if packet.msgID != uint16(dnfenum.CmdPacketExpertExtraction) || len(packet.body) != 2 ||
packet.body[0] != 0 || packet.body[1] != 13 {
t.Fatalf("failure ACK class=%d msg=%d body=%x", packet.classification, packet.msgID, packet.body)
}
snapshot := fixture.snapshot(t)
if !snapshot.source65Present {
t.Fatalf("rejected batch consumed the valid source: %+v", snapshot)
}
if snapshot.materialTotal != 0 || snapshot.experienceGain != 0 {
t.Fatalf("rejected batch mutated state: %+v", snapshot)
}
}
// ---------------------------------------------------------------------------
// 端到端夹具
// ---------------------------------------------------------------------------
type currentExtractionBatchSnapshot struct {
extractorPresent bool
source65Present bool
source66Present bool
materialTotal int64
materialRowCount int
materialItemID uint32
experienceGain int64
}
type currentExtractionBatchFixture struct {
service *Service
repositories dnfrepo.Group
session *gameSession
connection *bufferConn
baseExp int64
excluded map[int64]bool
}
type currentExtractionBatchPacket struct {
classification byte
msgID uint16
body []byte
}
func currentExtractionBatchRealPVFAssets(t *testing.T) (*pvfDungeonDropCatalog, *dnfexpertjob.Catalog, *platformpvf.Archive) {
t.Helper()
pvfPath := filepath.Clean(filepath.Join("..", "..", "..", "..", "runtime", "data", "dnf", "Script.pvf"))
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Skipf("runtime Script.pvf unavailable: %v", err)
}
items, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
expert, err := dnfexpertjob.Load(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
return items, expert, archive
}
// currentExtractionBatchRealPVFProbe 在真机数据里挑"1 级炼金师就能用"的提取器,
// 以及一件产物与随机数无关的可分解装备(稀有度 0/1、产物形态恒定)。
func currentExtractionBatchRealPVFProbe(t *testing.T, expert *dnfexpertjob.Catalog, archive *platformpvf.Archive) (int64, int64) {
t.Helper()
config, ok := expert.Config(dnfexpertjob.AlchemistType)
if !ok {
t.Fatal("active PVF has no alchemist config")
}
level := config.Level(0)
extractorID := int64(0)
for id, extractor := range config.Extractors {
if extractor.RequiredLevel > level {
continue
}
if extractorID == 0 || id < extractorID {
extractorID = id
}
}
if extractorID == 0 {
t.Fatalf("active PVF has no alchemist extractor usable at level %d", level)
}
text, err := archive.ReadText("equipment/equipment.lst")
if err != nil {
t.Fatalf("read equipment list: %v", err)
}
inspected := 0
for _, field := range strings.Fields(text) {
id, convErr := strconv.ParseInt(field, 10, 64)
if convErr != nil || id <= 0 || inspected >= 8000 {
continue
}
metadata, metadataErr := expert.Equipment(id)
if metadataErr != nil || metadata.Rarity > 1 || metadata.DisjointForbidden ||
metadata.AttachType == "trade delete" || metadata.SellGold <= 0 {
continue
}
inspected++
if !currentExtractionBatchPlanIsStable(expert, extractorID, metadata) {
continue
}
return extractorID, id
}
t.Skipf("no rng-stable extraction target found (extractor=%d inspected=%d)", extractorID, inspected)
return 0, 0
}
// 用同一件装备反复求解,产物形态必须恒定;只有经验增益允许在 [min,max] 之间抖动。
func currentExtractionBatchPlanIsStable(expert *dnfexpertjob.Catalog, extractorID int64, metadata dnfexpertjob.EquipmentMetadata) bool {
first := ""
for index := 0; index < 33; index++ {
probe := index
rng := func(n int) int {
if n <= 1 {
return 0
}
return probe % n
}
plan, err := expert.PlanExtraction(dnfexpertjob.AlchemistType, 0, extractorID, metadata, dnfexpertjob.RandomFunc(rng))
if err != nil {
return false
}
desc := fmt.Sprintf("%v", plan.Materials)
if first == "" {
first = desc
continue
}
if desc != first {
return false
}
}
return first != ""
}
func newCurrentExtractionBatchFixture(t *testing.T, items *pvfDungeonDropCatalog, expert *dnfexpertjob.Catalog, extractorID, equipmentID int64) *currentExtractionBatchFixture {
t.Helper()
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "account-1",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
const baseExp = int64(0)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Stats: map[string]int64{
"expert_job_type": int64(dnfexpertjob.AlchemistType),
"expert_job_exp": baseExp,
},
}); err != nil {
t.Fatal(err)
}
slots := map[string]dnfrepo.ItemStack{
currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, 240): {ItemID: extractorID, Count: 1},
currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, 65): {ItemID: equipmentID, Count: 1},
currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, 66): {ItemID: equipmentID, Count: 1},
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: slots}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "account-1", gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
pvfItemCatalog: items,
expertJobCatalog: expert,
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
return &currentExtractionBatchFixture{
service: service,
repositories: repositories,
session: &gameSession{conn: connection, selectedCharacterID: 19, accountID: "account-1"},
connection: connection,
baseExp: baseExp,
excluded: map[int64]bool{extractorID: true, equipmentID: true},
}
}
// run 走一次请求并把这一趟发出的所有包解析出来(缓冲区随之清空)。
func (f *currentExtractionBatchFixture) run(t *testing.T, body []byte) []currentExtractionBatchPacket {
t.Helper()
if err := f.service.handleCurrentExpertJobExtraction(f.session, body); err != nil {
t.Fatal(err)
}
packets := currentExtractionBatchSplitAll(t, f.connection.write.Bytes())
f.connection.write.Reset()
return packets
}
func (f *currentExtractionBatchFixture) snapshot(t *testing.T) currentExtractionBatchSnapshot {
t.Helper()
ctx := context.Background()
snapshot := currentExtractionBatchSnapshot{}
inventory, found, err := f.repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, err)
}
_, snapshot.source65Present = inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, 65)]
_, snapshot.source66Present = inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, 66)]
_, snapshot.extractorPresent = inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, 240)]
account, _, err := f.repositories.AccountInventory.Load(ctx, "account-1")
if err != nil {
t.Fatal(err)
}
// 产物 = 除提取器与两件装备以外的所有行;材料 id 取唯一那个。
totals := map[uint32]int64{}
rows := map[uint32]int{}
for _, source := range []map[string]dnfrepo.ItemStack{inventory.Slots, account.Slots} {
for _, stack := range source {
if stack.ItemID == 0 || stack.Count <= 0 || f.excluded[stack.ItemID] {
continue
}
totals[uint32(stack.ItemID)] += stack.Count
rows[uint32(stack.ItemID)]++
}
}
for itemID, total := range totals {
snapshot.materialItemID = itemID
snapshot.materialTotal += total
snapshot.materialRowCount += rows[itemID]
}
character, found, err := f.repositories.Character.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("character found=%t err=%v", found, err)
}
snapshot.experienceGain = character.Stats["expert_job_exp"] - f.baseExp
return snapshot
}
// ---------------------------------------------------------------------------
// 信封与包解析小工具
// ---------------------------------------------------------------------------
// 原生 6 字节单件请求:u8 职业类型 | i16 提取器槽 | u8 目标容器 | i16 目标槽。
func currentExpertJobExtractionTestBody(extractorType byte, extractorSlot int16, listType byte, targetSlot int16) []byte {
body := make([]byte, currentExpertJobExtractionRequestSize)
body[0] = extractorType
binary.LittleEndian.PutUint16(body[1:3], uint16(extractorSlot))
body[3] = listType
binary.LittleEndian.PutUint16(body[4:6], uint16(targetSlot))
return body
}
func currentExpertJobExtractionBatchTestBody(extractorType byte, extractorSlot int16, listType byte, slots []int16) []byte {
body := make([]byte, currentExpertJobExtractionBatchHeaderSize+len(slots)*2)
body[0] = extractorType
binary.LittleEndian.PutUint16(body[1:3], uint16(extractorSlot))
body[3] = listType
binary.LittleEndian.PutUint16(body[4:6], uint16(currentExpertJobExtractionBatchSentinel))
copy(body[6:10], currentExpertJobExtractionBatchMarker[:])
body[10] = byte(len(slots))
for index, slot := range slots {
offset := currentExpertJobExtractionBatchHeaderSize + index*2
binary.LittleEndian.PutUint16(body[offset:offset+2], uint16(slot))
}
return body
}
// 一个连接缓冲区里混着两种包头:op416/op205 走 13 字节普通包头,容器重建走
// 16 字节场景包头。这里按 opcode 选包头长度逐个切出来(长度字段两者一致)。
func currentExtractionBatchSplitAll(t *testing.T, data []byte) []currentExtractionBatchPacket {
t.Helper()
packets := []currentExtractionBatchPacket{}
for len(data) > 0 {
if len(data) < dnfproto.GameServerUpperHeaderSize {
t.Fatalf("trailing game upper bytes=%d data=%x", len(data), data)
}
total := int(binary.LittleEndian.Uint32(data[3:7]))
if total < dnfproto.GameServerUpperHeaderSize || total > len(data) {
t.Fatalf("game upper length=%d data=%d", total, len(data))
}
headerSize := dnfproto.GameServerUpperHeaderSize
msgID := binary.LittleEndian.Uint16(data[1:3])
if msgID == uint16(dnfenum.CmdPacketLeaveParty) {
headerSize = dnfproto.GameServerUpperHeaderSize16
}
if total < headerSize {
t.Fatalf("game upper packet too short for header: total=%d header=%d", total, headerSize)
}
packets = append(packets, currentExtractionBatchPacket{
classification: data[0],
msgID: msgID,
body: append([]byte(nil), data[headerSize:total]...),
})
data = data[total:]
}
return packets
}
func countCurrentExtractionBatchAcks(packets []currentExtractionBatchPacket) int {
count := 0
for _, packet := range packets {
if packet.msgID == uint16(dnfenum.CmdPacketExpertExtraction) {
count++
}
}
return count
}
func countCurrentExtractionBatchRefreshes(packets []currentExtractionBatchPacket) int {
count := 0
for _, packet := range packets {
if packet.msgID == uint16(dnfenum.CmdPacketLeaveParty) {
count++
}
}
return count
}
func firstCurrentExtractionBatchAck(t *testing.T, packets []currentExtractionBatchPacket) currentExtractionBatchPacket {
t.Helper()
for _, packet := range packets {
if packet.msgID == uint16(dnfenum.CmdPacketExpertExtraction) {
return packet
}
}
t.Fatalf("no extraction ACK in %s", describeCurrentExtractionBatchPackets(packets))
return currentExtractionBatchPacket{}
}
func describeCurrentExtractionBatchPackets(packets []currentExtractionBatchPacket) string {
parts := make([]string, 0, len(packets))
for _, packet := range packets {
parts = append(parts, fmt.Sprintf("class=%d msg=%d body=%d", packet.classification, packet.msgID, len(packet.body)))
}
return "[" + strings.Join(parts, " ") + "]"
}