DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,612 @@
|
||||
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 ¤tExtractionBatchFixture{
|
||||
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, " ") + "]"
|
||||
}
|
||||
Reference in new issue
Block a user