Files
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

1904 lines
78 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 (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"os"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfexpertjob "longheng.io/server/internal/modules/dnf/expertjob"
"longheng.io/server/internal/modules/dnf/premium"
dnfprofession "longheng.io/server/internal/modules/dnf/profession"
"longheng.io/server/internal/modules/dnf/progression"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
"longheng.io/server/internal/modules/dnf/worldmap"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestBuildCurrentFinishQuestSuccessBodyMatchesCurrentEXEExactItemRow(t *testing.T) {
raw := make([]byte, currentItemListEntryWireSize)
binary.LittleEndian.PutUint16(raw[0:2], 65)
binary.LittleEndian.PutUint32(raw[2:6], 10403)
binary.LittleEndian.PutUint32(raw[6:10], 7)
raw[0x0a] = 0x08
binary.LittleEndian.PutUint16(raw[0x0b:0x0d], 0x1234)
binary.LittleEndian.PutUint32(raw[0x0e:0x12], 0x55667788)
raw[0x12] = 0xaa
raw[0x13] = 0xbb
body, err := buildCurrentFinishQuestSuccessBody(dnfquest.FinishCommitResult{
AtomicCommitted: true,
QuestID: 3145,
CompletionKey: "run-19/op117/426",
Source: "quest_pvf:n_quest/finish.qst",
ExperienceDelta: 1780,
Items: []dnfquest.FinishCommittedItem{{
SlotKey: "0:65", SlotIndex: 65, ItemID: 10403,
Delta: 1, PostCount: 7, CountOrSeed: 1, RawEntry: raw,
}},
})
if err != nil {
t.Fatal(err)
}
want := []byte{
0x49, 0x0c, // questID
0x00, // completionType
0xf4, 0x06, 0x00, 0x00, // EXP; current reader has no intervening gold field
0x00, // consumedCount
0x00, // chainType
0x01, // insertedCount
0x41, 0x00,
0xa3, 0x28, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00,
0x08,
0x34, 0x12,
0x88, 0x77, 0x66, 0x55,
0xaa, 0xbb,
}
if !bytes.Equal(body, want) {
t.Fatalf("finish ACK payload=%x want=%x", body, want)
}
if len(body) != 29 {
t.Fatalf("finish ACK payload length=%d want=29", len(body))
}
}
func TestBuildCurrentFinishQuestSuccessBodyCarriesConsumedMaterials(t *testing.T) {
body, err := buildCurrentFinishQuestSuccessBody(dnfquest.FinishCommitResult{
AtomicCommitted: true,
QuestID: 3145,
CompletionKey: "quest-finish/19/3145/submit-material",
Source: "quest_pvf:n_quest/submit_material.qst",
ExperienceDelta: 100,
ConsumedItems: []dnfquest.FinishConsumedItem{{
SlotKey: "0:8", SlotIndex: 8, ItemID: 9001, Delta: 30, PostCount: 0,
}},
})
if err != nil {
t.Fatal(err)
}
want := []byte{
0x49, 0x0c, // questID
0x00, // completionType
0x64, 0x00, 0x00, 0x00, // EXP
0x01, // consumedCount
0x00, // consumed updateType
0x08, 0x00, // slot
0x1e, 0x00, 0x00, 0x00, // consumed stack count
0x00, // chainType
0x00, // insertedCount
}
if !bytes.Equal(body, want) || len(body) != 17 {
t.Fatalf("finish consumed ACK payload=%x len=%d want=%x", body, len(body), want)
}
}
func TestBuildCurrentFinishQuestSuccessBodyMatchesCurrentEXEProfessionChain(t *testing.T) {
body, err := buildCurrentFinishQuestSuccessBody(dnfquest.FinishCommitResult{
AtomicCommitted: true,
QuestID: 100,
CompletionKey: "quest-finish/19/100/1",
Source: "quest_pvf:n_quest/change.qst",
ExperienceDelta: 30,
HasProfession: true,
Profession: dnfprofession.Transition{
Kind: dnfprofession.KindClassChange, ChainType: 1, GrowNumber: 2,
PreviousGrowType: 0, NewGrowType: 2, FirstGrowType: 2,
},
ProfessionGrants: []dnfprofession.Grant{
{SkillID: 1, Level: 1},
{SkillID: 2, Level: 3},
{SkillID: 197, Level: 1},
},
// Each tree list carries that tree's own destination slot, which is why
// the two lists are sent separately.
PostCommitSkill: dnfrepo.SkillRecord{
CharacterID: "19",
Skills: map[int64]dnfrepo.SkillState{
1: {Level: 1, Enabled: true},
2: {Level: 3, Enabled: true},
197: {Level: 1, Enabled: true},
},
Layouts: map[int]dnfrepo.SkillLayout{
0: {0: 1, 3: 2, 105: 197},
1: {2: 1, 4: 2, 106: 197},
},
},
})
if err != nil {
t.Fatal(err)
}
want := []byte{
0x64, 0x00, // questID
0x00, // completionType
0x1e, 0x00, 0x00, 0x00, // EXP
0x00, // consumedCount
0x01, // chainType=grow type
0x02, // growNumber
0x03, // tree0 inline grant count
0x00, 0x01, 0x00, 0x01, // tree0 slot=0, skill=1, level=1
0x03, 0x02, 0x00, 0x03, // tree0 slot=3, skill=2, level=3
0x69, 0xc5, 0x00, 0x01, // tree0 slot=105, skill=197, level=1 (group cell, not the bar)
0x03, // tree1 inline grant count
0x02, 0x01, 0x00, 0x01, // tree1 slot=2, skill=1, level=1
0x04, 0x02, 0x00, 0x03, // tree1 slot=4, skill=2, level=3
0x6a, 0xc5, 0x00, 0x01, // tree1 slot=106, skill=197, level=1
}
if !bytes.Equal(body, want) || len(body) != 36 {
t.Fatalf("profession ACK payload=%x len=%d want=%x", body, len(body), want)
}
}
func TestBuildCurrentFinishQuestSuccessBodyMatchesCurrentEXEExpertJobChain(t *testing.T) {
body, err := buildCurrentFinishQuestSuccessBody(dnfquest.FinishCommitResult{
AtomicCommitted: true,
QuestID: 2710,
CompletionKey: "quest-finish/19/2710/1",
Source: "quest_pvf:n_quest/Expertjob/normal_60_Expert_job_disjointer_2.qst",
ExperienceDelta: 300,
HasExpertJob: true,
ExpertJobType: 3,
})
if err != nil {
t.Fatal(err)
}
want := []byte{
0x96, 0x0a, // questID
0x00, // completionType
0x2c, 0x01, 0x00, 0x00, // EXP
0x00, // consumedCount
0x14, // chainType=expert job
0x03, // expertJobType=disjointer
0x00, // tree0 inline grant count
0x00, // tree1 inline grant count
}
if !bytes.Equal(body, want) || len(body) != 12 {
t.Fatalf("expert-job ACK payload=%x len=%d want=%x", body, len(body), want)
}
}
func TestRealPVFCurrentExpertJobQuestFinishPublishesTypedStateAfterAck(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify expert-job quest finish projection")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
expertCatalog, err := dnfexpertjob.Load(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
questID uint16
jobType byte
}{
{name: "enchanter", questID: 2702, jobType: dnfexpertjob.EnchanterType},
{name: "alchemist", questID: 2708, jobType: dnfexpertjob.AlchemistType},
{name: "disjointer", questID: 2710, jobType: dnfexpertjob.DisjointerType},
{name: "doll-controller", questID: 2712, jobType: dnfexpertjob.DollControllerType},
} {
t.Run(test.name, func(t *testing.T) {
repositories := finishBridgeSeed(t)
acceptedAt := time.Date(2026, 8, 2, 13, 0, int(test.jobType), 0, time.UTC)
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
int64(test.questID): {Status: "active", ProgressValue: 0, UpdatedAt: acceptedAt},
},
UpdatedAt: acceptedAt,
}); err != nil {
t.Fatal(err)
}
catalog := finishBridgeExpertJobQuestCatalog(t, test.questID, test.jobType)
connection := &bufferConn{}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: catalog,
expertJobCatalog: expertCatalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "finish-expert-job-" + test.name, selectedCharacterID: 19}
request := dnfquest.FinishQuestRequest{
QuestID: test.questID,
RewardSelectIndex: ^uint16(0),
Multiplier: 1,
Reserved: dnfquest.CurrentFinishQuestObservedTailMarker,
}
if err := service.handleCurrentFinishQuestWithResources(
session,
request,
repositories,
catalog,
finishBridgeProgression(t),
finishBridgeAllocator(nil),
); err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) < 6 ||
packets[0].Header.Classification != dnfproto.DefaultChannelClassification ||
packets[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishQuest) ||
packets[1].Header.Classification != 0 ||
packets[1].Header.MsgID != currentExpertJobInfoNotification {
t.Fatalf("expert-job finish order=%v", finishBridgePacketIDs(packets))
}
assertCurrentExpertJobQuestInfoBody(t, packets[1].Body, expertCatalog, test.jobType)
for _, packet := range packets[2:] {
if packet.Header.Classification == 0 &&
(packet.Header.MsgID == 2 || packet.Header.MsgID == 754) {
t.Fatalf("expert-job finish emitted incompatible actor carrier: %v", finishBridgePacketIDs(packets))
}
}
character, found, err := repositories.Character.Load(context.Background(), "19")
if err != nil || !found || character.Stats["expert_job_type"] != int64(test.jobType) {
t.Fatalf("committed expert job character=%+v found=%t err=%v", character, found, err)
}
})
}
}
func assertCurrentExpertJobQuestInfoBody(t *testing.T, body []byte, catalog *dnfexpertjob.Catalog, jobType byte) {
t.Helper()
if len(body) < 8 {
t.Fatalf("expert-job op205 body has no EJXP projection trailer: %x", body)
}
trailerOffset := len(body) - 8
if magic := binary.LittleEndian.Uint32(body[trailerOffset : trailerOffset+4]); magic != currentExpertJobProjectionTrailerMagic {
t.Fatalf("expert-job op205 trailer magic=%#x want=%#x body=%x", magic, currentExpertJobProjectionTrailerMagic, body)
}
if experience := binary.LittleEndian.Uint32(body[trailerOffset+4:]); experience != 0 {
t.Fatalf("expert-job quest transition trailer experience=%d want=0 body=%x", experience, body)
}
body = body[:trailerOffset]
if len(body) < 2 || body[0] != 0 || body[1] != jobType {
t.Fatalf("expert-job op205 body=%x type=%d", body, jobType)
}
if jobType == dnfexpertjob.DisjointerType {
config, ok := catalog.Disjointer()
if !ok || len(body) != 10 || binary.LittleEndian.Uint32(body[2:6]) != 1 ||
int64(binary.LittleEndian.Uint32(body[6:10])) != config.InitialEndurance {
t.Fatalf("disjointer op205 body=%x config=%+v found=%t", body, config, ok)
}
return
}
config, ok := catalog.Config(jobType)
if !ok || len(body) < 3 {
t.Fatalf("recipe expert-job op205 body=%x config=%+v found=%t", body, config, ok)
}
recipes := config.AutoRecipeIDs(0)
offset := 3
if int(body[2]) != len(recipes) || len(body) < offset+4*len(recipes) {
t.Fatalf("expert-job op205 recipes body=%x want=%v", body, recipes)
}
for index, recipeID := range recipes {
if int64(binary.LittleEndian.Uint32(body[offset:offset+4])) != recipeID {
t.Fatalf("expert-job op205 recipe[%d]=%d want=%d body=%x", index, binary.LittleEndian.Uint32(body[offset:offset+4]), recipeID, body)
}
offset += 4
}
if jobType != dnfexpertjob.EnchanterType {
if offset != len(body) {
t.Fatalf("expert-job type=%d op205 trailing body=%x", jobType, body[offset:])
}
return
}
enchanter, ok := catalog.Enchanter()
if !ok || len(body) <= offset {
t.Fatalf("enchanter op205 body=%x config=%+v found=%t", body, enchanter, ok)
}
qualifications := enchanter.Qualifications(0)
qualificationCount := int(body[offset])
offset++
if qualificationCount != len(qualifications) || len(body) != offset+qualificationCount+8 ||
!bytes.Equal(body[offset:offset+qualificationCount], qualifications) {
t.Fatalf("enchanter op205 qualifications body=%x want=%v", body, qualifications)
}
offset += qualificationCount
if int(binary.LittleEndian.Uint32(body[offset:offset+4])) != config.Level(0) ||
int64(binary.LittleEndian.Uint32(body[offset+4:offset+8])) != enchanter.InitialEndurance {
t.Fatalf("enchanter op205 level/endurance body=%x", body)
}
}
func TestBuildCurrentFinishQuestSuccessBodyUsesOrdinaryChainForSlotExpansion(t *testing.T) {
body, err := buildCurrentFinishQuestSuccessBody(dnfquest.FinishCommitResult{
AtomicCommitted: true,
QuestID: 2636,
CompletionKey: "quest-finish/19/2636/1",
Source: "quest_pvf:n_quest/new_quest/MetroCenter/system_90_earring_02.qst",
ExperienceDelta: 300,
HasSlotExpansion: true,
SlotExpansionIndex: 2,
SlotExpansionBit: dnfquest.ExEquipSlotEarring,
})
if err != nil {
t.Fatal(err)
}
want := []byte{
0x4c, 0x0a, // questID
0x00, // completionType
0x2c, 0x01, 0x00, 0x00, // EXP
0x00, // consumedCount
0x00, // ordinary chainType; slot-expansion-only mode1 carries the committed bitset
0x00, // item count
}
if !bytes.Equal(body, want) || len(body) != 10 {
t.Fatalf("slot-expansion ACK payload=%x len=%d want=%x", body, len(body), want)
}
}
// TestBuildCurrentFinishQuestSuccessBodyAllowsGoldDebitOnSlotExpansionChain 是**现场故障**的回归。
//
// 2026-10-03 开耳环槽位任务(2636,[slot expansion],[int data] = `0 1000000 3034 1000 ...`):
// 金币扣了、槽位开了,但这条链过去要求 `len(result.Currency) == 0`,于是把一笔完全正常的
// 结算判成 ACK 形状非法 —— 日志里两条挨着的记录就是它:
//
// game-upper-finish-quest-blocked q=2636 reason=committed_result_ack_shape_invalid
// game-upper-finish-quest-blocked q=2636 reason=pvf_db_atomic_finish_rejected error=... receipt is corrupt
//
// 第一条:完成回执发不出去 → 玩家要小退才看见"完成";
// 第二条:客户端没收到回执、任务仍显示可交,重试走进重放路径,而回执根本没落库 → 报损坏。
//
// 现在只放行"**只**有扣款"的货币条目;正 Delta(真奖励)仍然被这三条链拒绝。
func TestBuildCurrentFinishQuestSuccessBodyAllowsGoldDebitOnSlotExpansionChain(t *testing.T) {
base := func() dnfquest.FinishCommitResult {
return dnfquest.FinishCommitResult{
AtomicCommitted: true,
QuestID: 2636,
CompletionKey: "quest-finish/19/2636/1",
Source: "quest_pvf:n_quest/new_quest/MetroCenter/system_90_earring_02.qst",
ExperienceDelta: 300,
HasSlotExpansion: true,
SlotExpansionIndex: 2,
SlotExpansionBit: dnfquest.ExEquipSlotEarring,
}
}
// 钱包行由 op14 另行下发,完成包体本身与"没有金币"时逐字节一致。
debit := base()
debit.Currency = []dnfquest.FinishCommittedCurrency{{Name: "gold", Delta: -1000000, PostValue: 1000000}}
body, err := buildCurrentFinishQuestSuccessBody(debit)
if err != nil {
t.Fatalf("槽位扩展 + 金币扣款必须能编出完成包(这正是现场被打回的那一条): %v", err)
}
plain, err := buildCurrentFinishQuestSuccessBody(base())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(body, plain) {
t.Fatalf("扣款不该改变完成包体: debit=%x plain=%x", body, plain)
}
if len(body) != 10 {
t.Fatalf("slot-expansion ACK len=%d want 10 body=%x", len(body), body)
}
// 奖励(正 Delta)仍然必须被这条链拒绝:那是本次刻意没有放开的语义。
reward := base()
reward.Currency = []dnfquest.FinishCommittedCurrency{{Name: "gold", Delta: 500, PostValue: 1500}}
if _, err := buildCurrentFinishQuestSuccessBody(reward); !errors.Is(err, errCurrentFinishQuestAckShape) {
t.Fatalf("槽位扩展链不该接受金币奖励: %v", err)
}
// 混合条目(既有扣又有发)同样按"不是纯扣款"拒绝。
mixed := base()
mixed.Currency = []dnfquest.FinishCommittedCurrency{
{Name: "gold", Delta: -1000000, PostValue: 0},
{Name: "gold", Delta: 500, PostValue: 500},
}
if _, err := buildCurrentFinishQuestSuccessBody(mixed); !errors.Is(err, errCurrentFinishQuestAckShape) {
t.Fatalf("混合货币条目不该被当成纯扣款: %v", err)
}
}
func TestCurrentFinishQuestProfessionCommitPublishesCurrentEXEStateOrder(t *testing.T) {
ctx := context.Background()
repositories := finishBridgeSeed(t)
character, _, _ := repositories.Character.Load(ctx, "19")
character.Name = "profession"
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
acceptedAt := time.Date(2026, 7, 17, 2, 0, 0, 0, time.UTC)
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
100: {Status: "active", ProgressValue: 0, UpdatedAt: acceptedAt},
}}); err != nil {
t.Fatal(err)
}
skill, _, _ := repositories.Skill.Load(ctx, "19")
skill.Skills = map[int64]dnfrepo.SkillState{99: {Level: 2, Enabled: true}}
if err := repositories.Skill.Save(ctx, skill); err != nil {
t.Fatal(err)
}
catalog := finishBridgeProfessionCatalog(t)
profiles, skillCatalog := finishBridgeProfessionResources(t)
connection := &bufferConn{}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "finish-profession",
selectedCharacterID: 19,
townSceneReadyCharacterID: 19,
townActorOwnerChannel: currentSceneObjectContext,
}
request := dnfquest.FinishQuestRequest{QuestID: 100, Multiplier: 1, Reserved: dnfquest.CurrentFinishQuestObservedTailMarker}
if err := service.handleCurrentFinishQuestWithResources(
session, request, repositories, catalog, finishBridgeProgression(t), nil,
currentQuestProfessionResources{Profiles: profiles, SkillCatalog: skillCatalog},
); err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) < 4 {
t.Fatalf("profession post-commit packet count=%d ids=%v", len(packets), finishBridgePacketIDs(packets))
}
wantACK := []byte{
1, 0x64, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02,
0x03, 0x00, 0x01, 0x00, 0x01, 0x36, 0x02, 0x00, 0x01, 0x37, 0xc5, 0x00, 0x01,
0x03, 0x00, 0x01, 0x00, 0x01, 0x36, 0x02, 0x00, 0x01, 0x37, 0xc5, 0x00, 0x01,
}
indexBodyMode := func(mode byte) int {
for index, packet := range packets {
if packet.Header.Classification == 0 && packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) && len(packet.Body) != 0 && packet.Body[0] == mode {
return index
}
}
return -1
}
indexMessage := func(msgID uint16) int {
for index, packet := range packets {
if packet.Header.Classification == 0 && packet.Header.MsgID == msgID {
return index
}
}
return -1
}
mode0, mode1, mode3 := indexBodyMode(0), indexBodyMode(1), indexBodyMode(3)
characterIndex := indexMessage(currentDungeonCharacterStateMsgID)
clearQuestIndex := indexMessage(currentClearQuestListMsgID)
activeIndex := indexMessage(currentActiveQuestSnapshotMsgID)
acceptableIndex := indexMessage(currentAcceptableQuestListMsgID)
ackIndex := -1
for index, packet := range packets {
if packet.Header.Classification == dnfproto.DefaultChannelClassification &&
packet.Header.MsgID == uint16(dnfenum.CmdPacketFinishQuest) {
ackIndex = index
break
}
}
if ackIndex != 0 {
t.Fatalf("profession ACK missing ids=%v", finishBridgePacketIDs(packets))
}
if packets[ackIndex].Header.Classification != dnfproto.DefaultChannelClassification || !bytes.Equal(packets[ackIndex].Body, wantACK) {
t.Fatalf("profession ACK index=%d header=%+v body=%x want=%x", ackIndex, packets[ackIndex].Header, packets[ackIndex].Body, wantACK)
}
skillIndices := make([]int, 0, 1)
for index, packet := range packets {
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSkillInfoMsgID {
skillIndices = append(skillIndices, index)
}
}
if !(len(skillIndices) == 0 && mode0 > ackIndex && mode1 == -1 && mode3 == -1 &&
characterIndex > mode0 && activeIndex > characterIndex &&
clearQuestIndex > activeIndex && acceptableIndex > clearQuestIndex) {
t.Fatalf("profession order ack=%d skills=%v mode0=%d mode1=%d mode3=%d character=%d clear=%d active=%d acceptable=%d ids=%v", ackIndex, skillIndices, mode0, mode1, mode3, characterIndex, clearQuestIndex, activeIndex, acceptableIndex, finishBridgePacketIDs(packets))
}
assertFinishBridgeClearQuestProjection(t, packets[clearQuestIndex], 100)
persistedCharacter, _, _ := repositories.Character.Load(ctx, "19")
persistedSkill, _, _ := repositories.Skill.Load(ctx, "19")
persistedQuest, _, _ := repositories.Quest.Load(ctx, "19")
if persistedCharacter.Stats["grow_type"] != 2 || persistedSkill.Skills[1].Level != 1 || persistedSkill.Skills[2].Level != 1 || persistedQuest.States[100].Status != "completed" {
t.Fatalf("profession commit character=%+v skill=%+v quest=%+v", persistedCharacter, persistedSkill, persistedQuest.States[100])
}
}
func TestBuildCurrentFinishQuestSuccessBodyRejectsUncommittedOrMismatchedReceipt(t *testing.T) {
raw := make([]byte, currentItemListEntryWireSize)
binary.LittleEndian.PutUint16(raw[0:2], 66)
binary.LittleEndian.PutUint32(raw[2:6], 10403)
base := dnfquest.FinishCommitResult{
AtomicCommitted: true, QuestID: 3145, CompletionKey: "key", Source: "pvf",
Items: []dnfquest.FinishCommittedItem{{SlotIndex: 65, ItemID: 10403, CountOrSeed: 1, RawEntry: raw}},
}
if _, err := buildCurrentFinishQuestSuccessBody(base); !errors.Is(err, errCurrentFinishQuestAckShape) {
t.Fatalf("mismatched raw error=%v", err)
}
base.Items = nil
base.AtomicCommitted = false
if _, err := buildCurrentFinishQuestSuccessBody(base); !errors.Is(err, errCurrentFinishQuestAckShape) {
t.Fatalf("uncommitted result error=%v", err)
}
base.AtomicCommitted = true
base.Currency = []dnfquest.FinishCommittedCurrency{{Name: "gold", Delta: 1, PostValue: 1}}
body, err := buildCurrentFinishQuestSuccessBody(base)
if err != nil {
t.Fatalf("currency must not alter current finish ACK shape: %v", err)
}
if want := []byte{0x49, 0x0c, 0x00, 0, 0, 0, 0, 0x00, 0x00, 0x00}; !bytes.Equal(body, want) {
t.Fatalf("currency-only ACK payload=%x want=%x", body, want)
}
}
// TestBuildCurrentFinishQuestSuccessBodyAcceptsGoldDebit 是"任务交付扣金币"在 ACK 这一层的回归。
//
// 服主口径 2026-10-03:PVF [int data] 里 **ID 0 = 金币**,完成任务要扣(耳环槽位任务 100 万)。
// 金币体只带 **PostValue**(余额快照),不带 Delta,所以扣款后 Delta 是负数 —— 合法。
// 早先这里判 `Delta < 0` 直接回 errCurrentFinishQuestAckShape,会让扣金币的任务在发 ACK
// 这一步整笔失败(任务做完但客户端收不到完成包)。
//
// 注意:完成包**不**带金币余额 —— 钱包行由独立通道刷新(见 PostActionRefreshSelectedMainWallets)。
// 所以这条用例只钉两件事:扣款能编进包、且包体形状与"发金币"完全一致。
func TestBuildCurrentFinishQuestSuccessBodyAcceptsGoldDebit(t *testing.T) {
gain := dnfquest.FinishCommitResult{
AtomicCommitted: true, QuestID: 3145, CompletionKey: "key", Source: "pvf",
Currency: []dnfquest.FinishCommittedCurrency{{Name: "gold", Delta: 1, PostValue: 1}},
}
debit := dnfquest.FinishCommitResult{
AtomicCommitted: true, QuestID: 3145, CompletionKey: "key", Source: "pvf",
// 交 100 万:余额从 2,000,000 扣到 1,000,000。
Currency: []dnfquest.FinishCommittedCurrency{{Name: "gold", Delta: -1000000, PostValue: 1000000}},
}
gainBody, err := buildCurrentFinishQuestSuccessBody(gain)
if err != nil {
t.Fatalf("发金币基线不该失败: %v", err)
}
debitBody, err := buildCurrentFinishQuestSuccessBody(debit)
if err != nil {
t.Fatalf("金币扣款必须能编进完成包(这正是旧代码拒绝的那一条): %v", err)
}
if !bytes.Equal(gainBody, debitBody) {
t.Fatalf("扣款与发钱的包体必须同形状: gain=%x debit=%x", gainBody, debitBody)
}
}
func TestCurrentQuestFinishItemAllocatorUsesDeltaInAckAndPostCountInInventory(t *testing.T) {
catalog := &pvfDungeonDropCatalog{
source: finishBridgePVFSource{"stackable/test.stk": "[stack limit]\n999\n"},
itemRefs: map[uint32]dungeonDropItemReference{
10403: {kind: dungeonDropItemStackable, path: "stackable/test.stk"},
},
itemCache: map[uint32]dungeonDropItemDefinition{
10403: {ItemID: 10403, Kind: dungeonDropItemStackable, PVFPath: "stackable/test.stk", StackLimit: 999, SlotStart: 65, SlotEnd: 120},
},
}
record := dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{}}
allocate := currentQuestFinishItemAllocator(catalog, 24)
first, err := allocate(&record, dnfquest.FinishItemGrantRequest{QuestID: 3145, CompletionKey: "key", Source: "pvf", ItemID: 10403, Count: 2})
if err != nil {
t.Fatal(err)
}
second, err := allocate(&record, dnfquest.FinishItemGrantRequest{QuestID: 3145, CompletionKey: "key", Source: "pvf", ItemID: 10403, Count: 3})
if err != nil {
t.Fatal(err)
}
if first.CountOrSeed != 2 || first.PostCount != 2 || second.CountOrSeed != 3 || second.PostCount != 5 {
t.Fatalf("allocator receipts first=%+v second=%+v", first, second)
}
stack := record.Slots["0:65"]
if len(record.Slots) != 1 || stack.Count != 5 || binary.LittleEndian.Uint32(stack.RawEntry[6:10]) != 5 {
t.Fatalf("post inventory=%+v raw=%x", record.Slots, stack.RawEntry)
}
if stack.Extra["last_grant_completion_key"] != "key" || stack.Extra["last_grant_quest_id"] != "3145" {
t.Fatalf("allocator provenance=%+v", stack.Extra)
}
}
func TestCurrentQuestFinishItemAllocatorInstantiatesEquipmentUsablePeriod(t *testing.T) {
catalog := &pvfDungeonDropCatalog{
source: finishBridgePVFSource{"equipment/trial.equ": "[usable period]\n2\n"},
itemRefs: map[uint32]dungeonDropItemReference{
100300883: {kind: dungeonDropItemEquipment, path: "equipment/trial.equ"},
},
itemCache: map[uint32]dungeonDropItemDefinition{
100300883: {
ItemID: 100300883, Kind: dungeonDropItemEquipment,
PVFPath: "equipment/trial.equ", SlotStart: 9, SlotEnd: 64,
UsablePeriodDays: 2,
},
},
}
record := dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{}}
before := time.Now().UTC().Add(2 * 24 * time.Hour)
receipt, err := currentQuestFinishItemAllocator(catalog, 24)(&record, dnfquest.FinishItemGrantRequest{
QuestID: 3272, CompletionKey: "quest-finish/19/3272", Source: "pvf",
ItemID: 100300883, Count: 1,
})
if err != nil {
t.Fatal(err)
}
after := time.Now().UTC().Add(2 * 24 * time.Hour)
stack := record.Slots[receipt.SlotKey]
if stack.ExpireAt.Before(before.Add(-time.Second)) || stack.ExpireAt.After(after.Add(time.Second)) ||
stack.Extra["usable_period_days"] != "2" || stack.Extra["expiration_source"] != "runtime_pvf_usable_period_grant" ||
len(stack.RawEntry) != currentItemListEntryWireSize ||
binary.LittleEndian.Uint32(stack.RawEntry[currentItemListExpireTimeOffset:currentItemListExpireTimeOffset+4]) != uint32(stack.ExpireAt.Unix()) {
t.Fatalf("trial equipment receipt=%+v stack=%+v raw=%x", receipt, stack, stack.RawEntry)
}
}
func TestCurrentFinishQuestLiveTenByteBodyCommitsAtomicOwner(t *testing.T) {
repositories := finishBridgeSeed(t)
connection := &bufferConn{}
catalog := finishBridgeCatalog(t)
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "finish-quest-live-body",
selectedCharacterID: 19,
initialTownRouteCharacterID: 19,
initialTownRouteStage: currentInitialTownRoutePlayerStateSent,
}
request, err := dnfquest.DecodeFinishQuestRequest([]byte{0x22, 0x00, 0x49, 0x0C, 0xFF, 0xFF, 0x01, 0x00, 0xFF, 0xFF})
if err != nil {
t.Fatal(err)
}
if err := service.handleCurrentFinishQuestWithResources(
session,
request,
repositories,
catalog,
finishBridgeProgression(t),
finishBridgeAllocator(nil),
); err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
assertFinishBridgePostCommitOrder(t, packets, 3145, true)
assertFinishBridgeNoPersonalInfoSnapshots(t, packets)
if packets[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishQuest) ||
packets[0].Header.Classification != dnfproto.DefaultChannelClassification ||
len(packets[0].Body) != 30 || packets[0].Body[0] != 1 ||
binary.LittleEndian.Uint16(packets[0].Body[1:3]) != 3145 ||
packets[0].Body[3] != 0 ||
binary.LittleEndian.Uint32(packets[0].Body[4:8]) != 100 ||
packets[0].Body[8] != 0 || packets[0].Body[9] != 0 || packets[0].Body[10] != 1 ||
binary.LittleEndian.Uint32(packets[0].Body[13:17]) != 10403 ||
binary.LittleEndian.Uint32(packets[0].Body[17:21]) != 2 {
t.Fatalf("finish ACK=%+v body=%x", packets[0].Header, packets[0].Body)
}
assertFinishBridgeSuccessorSnapshots(t, packets, 3145, 3146)
questRecord, found, err := repositories.Quest.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("load quest found=%t err=%v", found, err)
}
if state := questRecord.States[3145]; state.Status != "completed" || state.Extra["reward_state"] != "granted" {
t.Fatalf("live body did not commit quest atomically: %+v", state)
}
if state, exists := questRecord.States[3146]; exists && state.Status == "active" {
t.Fatalf("successor quest became active before manual op31: %+v", state)
}
start := connection.write.Len()
if err := service.handleCurrentAcceptQuest(session, []byte{0x1f, 0x00, 0x4a, 0x0c}); err != nil {
t.Fatal(err)
}
acceptPackets := splitAllFinishBridgePackets(t, connection.write.Bytes()[start:])
if len(acceptPackets) < 2 || acceptPackets[0].Header.MsgID != uint16(dnfenum.CmdPacketAcceptQuest) ||
acceptPackets[0].Body[0] != 1 {
t.Fatalf("post-finish next quest accept packets=%v", finishBridgePacketIDs(acceptPackets))
}
questRecord, found, err = repositories.Quest.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("reload quest found=%t err=%v", found, err)
}
if state := questRecord.States[3146]; state.Status != "active" || state.ProgressValue != 2 {
t.Fatalf("next main quest was not accepted after finish: %+v", state)
}
}
func TestCurrentFinishQuestLevelChangeDoesNotPublishStoryDigest(t *testing.T) {
ctx := context.Background()
repositories := finishBridgeSeed(t)
character, found, err := repositories.Character.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load character found=%t err=%v", found, err)
}
character.Stats[dnfrepo.CharacterStoryDigestLastLevelStatKey] = 1
character.Stats[dnfrepo.CharacterStoryDigestMigrationVersionStatKey] = int64(dnfrepo.CurrentCharacterStoryDigestMigrationVersion)
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: finishBridgeCatalog(t),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "finish-level-story-digest-dungeon-settlement",
selectedCharacterID: 19,
}
request := dnfquest.FinishQuestRequest{
QuestID: 3145,
RewardSelectIndex: ^uint16(0),
Multiplier: 1,
Reserved: dnfquest.CurrentFinishQuestObservedTailMarker,
}
if err := service.handleCurrentFinishQuestWithResources(
session,
request,
repositories,
finishBridgeCatalog(t),
finishBridgeProgression(t),
finishBridgeAllocator(nil),
); err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
acceptableIndex := -1
for index, packet := range packets {
if packet.Header.Classification != 0 {
continue
}
switch packet.Header.MsgID {
case currentAcceptableQuestListMsgID:
acceptableIndex = index
case currentStoryDigestLastLevelMsgID:
t.Fatalf("等级变化下发了章节过场 op1378 body=%x;章节过场只应由城镇区域开放任务触发", packet.Body)
}
}
if acceptableIndex < 0 {
t.Fatalf("acceptable quest list missing, packets=%v", finishBridgePacketIDs(packets))
}
}
func TestCurrentFinishQuestStoryDigestSkipsDuplicateTriggersButNotDungeonSettlement(t *testing.T) {
character := dnfrepo.CharacterRecord{
CharacterID: "19",
Level: 20,
Stats: map[string]int64{
dnfrepo.CharacterStoryDigestLastLevelStatKey: 10,
dnfrepo.CharacterStoryDigestMigrationVersionStatKey: int64(dnfrepo.CurrentCharacterStoryDigestMigrationVersion),
},
}
// 章节过场动画只由「城镇区域开放任务」触发:任务交付本身不再下发 op1378,
// 无论等级是否变化、无论任务是否属于章节、也无论从哪个场景交付。
tests := []struct {
name string
result dnfquest.FinishCommitResult
}{
{
name: "same level",
result: dnfquest.FinishCommitResult{PreviousLevel: 20, NewLevel: 20, PostCommitCharacter: character},
},
{
name: "durable replay",
result: dnfquest.FinishCommitResult{Replayed: true, PreviousLevel: 19, NewLevel: 20, PostCommitCharacter: character},
},
{
name: "story already accepted",
result: func() dnfquest.FinishCommitResult {
accepted := character
accepted.Stats = map[string]int64{dnfrepo.CharacterStoryDigestLastLevelStatKey: 20}
return dnfquest.FinishCommitResult{PreviousLevel: 19, NewLevel: 20, PostCommitCharacter: accepted}
}(),
},
{
name: "dungeon settlement has no town actor",
result: dnfquest.FinishCommitResult{PreviousLevel: 19, NewLevel: 20, PostCommitCharacter: character},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
connection := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: connection, connID: "finish-story-digest-skip", selectedCharacterID: 19}
if err := service.sendCurrentFinishQuestStoryDigestAfterSnapshots(session, test.result); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("任务交付下发了章节过场 op1378=%x", connection.write.Bytes())
}
})
}
}
func TestCurrentFinishQuestHellPartyFinalObjectiveCommitsPermissionAndRefreshesQuestState(t *testing.T) {
ctx := context.Background()
repositories := finishBridgeSeed(t)
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
2002: {
Status: "active",
ProgressValue: 0,
Extra: map[string]string{
"pvf_path": "n_quest/hell_hunt.qst",
"quest_type": "[hunt enemy]",
},
},
},
}); err != nil {
t.Fatal(err)
}
catalog := finishBridgeHellPartyCatalog(t)
if plan, err := catalog.PlanFinishReward(dnfquest.CharacterEligibility{Level: 1, Job: 2}, 2002, ^uint16(0), false); err != nil || !plan.HasHellPartyUnlock {
t.Fatalf("hell-party finish plan=%+v err=%v", plan, err)
}
// The bridge assertion below must be able to distinguish an op34 framing
// regression from an invalid synthetic settlement fixture.
verificationRepos := finishBridgeSeed(t)
if err := verificationRepos.Quest.Save(ctx, dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
2002: {Status: "active", ProgressValue: 0, Extra: map[string]string{"pvf_path": "n_quest/hell_hunt.qst", "quest_type": "[hunt enemy]"}},
}}); err != nil {
t.Fatal(err)
}
verificationOwner, err := dnfquest.NewOwner(verificationRepos)
if err != nil {
t.Fatal(err)
}
if _, err := verificationOwner.ApplyFinishSettlement(ctx, catalog, dnfquest.FinishCommitInput{
AccountID: "dnf:1", CharacterID: "19", QuestID: 2002, Multiplier: 1,
CommittedAt: time.Now().UTC(), Progression: finishBridgeProgression(t),
HellPartyUnlocks: []dnfquest.HellPartyUnlock{{AreaID: 32, QuestID: 2002}},
}); err != nil {
t.Fatalf("hell-party atomic settlement fixture rejected: %v", err)
}
table, resolver := finishBridgeHellPartyWorldMap(t, 32, 2002)
connection := &bufferConn{}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: catalog,
worldMapTable: table,
worldMapResolver: resolver,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "finish-hell-party-final", selectedCharacterID: 19}
if unlocks, err := service.currentQuestHellPartyUnlocks(2002); err != nil || len(unlocks) != 1 || unlocks[0] != (dnfquest.HellPartyUnlock{AreaID: 32, QuestID: 2002}) {
t.Fatalf("hell-party PVF area mapping unlocks=%+v err=%v", unlocks, err)
}
request := dnfquest.FinishQuestRequest{
QuestID: 2002,
RewardSelectIndex: ^uint16(0),
Multiplier: 1,
Reserved: dnfquest.CurrentFinishQuestObservedTailMarker,
}
if err := service.handleCurrentFinishQuestWithResources(
session, request, repositories, catalog, finishBridgeProgression(t), finishBridgeAllocator(nil),
); err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) == 0 || packets[0].Header.Classification != dnfproto.DefaultChannelClassification ||
packets[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishQuest) ||
len(packets[0].Body) < 3 || packets[0].Body[0] != 1 ||
binary.LittleEndian.Uint16(packets[0].Body[1:3]) != 2002 {
t.Fatalf("hell-party finish ACK packets=%v", finishBridgePacketIDs(packets))
}
hasSnapshot := func(msgID uint16) bool {
for _, packet := range packets[1:] {
if packet.Header.Classification == 0 && packet.Header.MsgID == msgID {
return true
}
}
return false
}
if !hasSnapshot(currentActiveQuestSnapshotMsgID) || !hasSnapshot(currentClearQuestListMsgID) || !hasSnapshot(currentAcceptableQuestListMsgID) {
t.Fatalf("hell-party final quest missed post-commit quest snapshots: %v", finishBridgePacketIDs(packets))
}
permission, found, err := repositories.HellPartyPermission.Load(ctx, "19")
if err != nil || !found || len(permission.Entries) != 1 ||
permission.Entries[0].AreaID != 32 || permission.Entries[0].QuestID != 2002 {
t.Fatalf("hell-party permission found=%t err=%v record=%+v", found, err, permission)
}
questRecord, found, err := repositories.Quest.Load(ctx, "19")
if err != nil || !found || questRecord.States[2002].Status != "completed" ||
questRecord.States[2002].Extra["reward_state"] != "granted" {
t.Fatalf("hell-party final quest found=%t err=%v state=%+v", found, err, questRecord.States[2002])
}
}
func TestCurrentFinishQuestSlotExpansionPublishesMode1WithoutPanelOpeningMode3(t *testing.T) {
ctx := context.Background()
repositories := finishBridgeSeed(t)
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
2636: {
Status: "active",
ProgressValue: 0,
UpdatedAt: time.Date(2026, 7, 23, 3, 0, 0, 0, time.UTC),
},
},
}); err != nil {
t.Fatal(err)
}
catalog := finishBridgeSlotExpansionCatalog(t)
connection := &bufferConn{}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: connection,
connID: "finish-slot-expansion",
selectedCharacterID: 19,
connectionTownActorOwnerChannel: 253,
townActorOwnerChannel: 253,
}
request := dnfquest.FinishQuestRequest{
QuestID: 2636,
Multiplier: 1,
Reserved: dnfquest.CurrentFinishQuestObservedTailMarker,
}
if err := service.handleCurrentFinishQuestWithResources(
session,
request,
repositories,
catalog,
finishBridgeProgression(t),
nil,
); err != nil {
t.Fatal(err)
}
character, found, err := repositories.Character.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load character found=%t err=%v", found, err)
}
if got := character.Stats["ex_equip_slot_stat"]; got != int64(dnfquest.ExEquipSlotEarring) {
t.Fatalf("persisted ex_equip_slot_stat=%d want=%d", got, dnfquest.ExEquipSlotEarring)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
assertFinishBridgePostCommitOrder(t, packets, 2636, true)
var finishACK []byte
for _, packet := range packets {
if packet.Header.Classification == dnfproto.DefaultChannelClassification &&
packet.Header.MsgID == uint16(dnfenum.CmdPacketFinishQuest) {
finishACK = packet.Body
break
}
}
wantFinishACK := []byte{1, 0x4c, 0x0a, 0, 100, 0, 0, 0, 0, 0, 0}
if !bytes.Equal(finishACK, wantFinishACK) {
t.Fatalf("slot expansion finish ACK=%x want=%x", finishACK, wantFinishACK)
}
mode1Index, mode3Index := -1, -1
for index, packet := range packets {
if packet.Header.Classification != 0 ||
packet.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
len(packet.Body) == 0 {
continue
}
switch packet.Body[0] {
case 1:
mode1Index = index
case 3:
mode3Index = index
}
}
if mode1Index < 0 || mode3Index >= 0 {
t.Fatalf("slot expansion mode1=%d mode3=%d ids=%v", mode1Index, mode3Index, finishBridgePacketIDs(packets))
}
if len(packets[mode1Index].Body) < 5 || packets[mode1Index].Body[4] != 253 {
t.Fatalf("slot expansion mode1 owner=%x want=fd", packets[mode1Index].Body[:minInt(len(packets[mode1Index].Body), 5)])
}
adventureLevel := uint32(service.currentAccountAdventureGroupSummaryForPacket(ctx, session, character, true).ManageLevel)
wantMode1 := service.buildCurrentSelectedUserInfoMode1BodyWithAdventureLevelInContext(
ctx,
session,
nil,
character,
true,
19,
adventureLevel,
253,
)
if !bytes.Equal(packets[mode1Index].Body, wantMode1) {
t.Fatalf("slot expansion mode1 is not committed character snapshot")
}
}
func TestCurrentFinishQuestWithoutLevelOrSPDeltaSkipsFullCharacterPackets(t *testing.T) {
ctx := context.Background()
repositories := finishBridgeSeed(t)
character, found, err := repositories.Character.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load character found=%t err=%v", found, err)
}
skill, found, err := repositories.Skill.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load skill found=%t err=%v", found, err)
}
connection := &bufferConn{}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: finishBridgeCatalog(t),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "finish-no-character-delta", selectedCharacterID: 19}
if err := service.sendCurrentFinishQuestPostCommitSnapshots(session, dnfquest.FinishCommitResult{
AtomicCommitted: true,
CharacterID: "19",
QuestID: 8581,
CompletionKey: "quest-finish/19/8581/no-character-delta",
PreviousLevel: character.Level,
NewLevel: character.Level,
PostCommitCharacter: character,
PostCommitSkill: skill,
PostCommitQuest: dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
8581: {Status: "completed"},
},
},
}); err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) != 3 ||
packets[0].Header.Classification != 0 || packets[0].Header.MsgID != currentActiveQuestSnapshotMsgID ||
packets[1].Header.Classification != 0 || packets[1].Header.MsgID != currentClearQuestListMsgID ||
packets[2].Header.Classification != 0 || packets[2].Header.MsgID != currentAcceptableQuestListMsgID {
t.Fatalf("no-delta finish packets=%v", finishBridgePacketIDs(packets))
}
assertFinishBridgeClearQuestProjection(t, packets[1], 8581)
assertFinishBridgeNoPersonalInfoSnapshots(t, packets)
for _, packet := range packets {
if packet.Header.MsgID == currentDungeonCharacterStateMsgID || packet.Header.MsgID == currentSkillInfoMsgID {
t.Fatalf("no-delta finish sent full character/skill packet ids=%v", finishBridgePacketIDs(packets))
}
}
}
func TestCurrentFinishQuestSameLevelExperienceRefreshesAbsoluteCharacterState(t *testing.T) {
ctx := context.Background()
repositories := finishBridgeSeed(t)
character, found, err := repositories.Character.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load character found=%t err=%v", found, err)
}
skill, found, err := repositories.Skill.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load skill found=%t err=%v", found, err)
}
character.Level = 19
character.Stats["exp"] = 1107550
skill.Points = dnfrepo.SkillPointState{TotalSP: 810, RemainingSP: 185, SyncedLevel: 19}
connection := &bufferConn{}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: finishBridgeCatalog(t),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "finish-same-level-experience", selectedCharacterID: 19}
if err := service.sendCurrentFinishQuestPostCommitSnapshots(session, dnfquest.FinishCommitResult{
AtomicCommitted: true,
CharacterID: "19",
QuestID: 8581,
CompletionKey: "quest-finish/19/8581/same-level-experience",
ExperienceDelta: 85900,
PreviousLevel: character.Level,
NewLevel: character.Level,
PostCommitCharacter: character,
PostCommitSkill: skill,
PostCommitQuest: dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
8581: {Status: "completed"},
},
},
}); err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) != 4 ||
packets[0].Header.Classification != 0 || packets[0].Header.MsgID != currentDungeonCharacterStateMsgID ||
packets[1].Header.Classification != 0 || packets[1].Header.MsgID != currentActiveQuestSnapshotMsgID ||
packets[2].Header.Classification != 0 || packets[2].Header.MsgID != currentClearQuestListMsgID ||
packets[3].Header.Classification != 0 || packets[3].Header.MsgID != currentAcceptableQuestListMsgID {
t.Fatalf("same-level experience finish packets=%v", finishBridgePacketIDs(packets))
}
characterBody := packets[0].Body
if len(characterBody) != currentFinishLoadingCharacterStateBodySize ||
characterBody[0] != byte(character.Level) ||
binary.LittleEndian.Uint32(characterBody[1:5]) != 1107550 {
t.Fatalf("same-level experience character snapshot=%x", characterBody)
}
for _, packet := range packets {
if packet.Header.MsgID == currentSkillInfoMsgID {
t.Fatalf("same-level experience unnecessarily refreshed skill tree ids=%v", finishBridgePacketIDs(packets))
}
}
assertFinishBridgeClearQuestProjection(t, packets[2], 8581)
assertFinishBridgeNoPersonalInfoSnapshots(t, packets)
}
func TestCurrentFinishQuestPostCommitRefreshesConsumedMaterialDelete(t *testing.T) {
connection := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: connection, connID: "finish-consumed-material-refresh", selectedCharacterID: 19}
err := service.sendCurrentFinishQuestRewardItemUpdate(session, dnfquest.FinishCommitResult{
QuestID: 600,
CompletionKey: "quest-finish/19/600/submit-material",
ConsumedItems: []dnfquest.FinishConsumedItem{{
SlotKey: "0:8", SlotIndex: 8, ItemID: 9001, Delta: 30, PostCount: 0,
}},
})
if err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) != 1 || packets[0].Header.Classification != 0 ||
packets[0].Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("consumed material refresh packets=%v", finishBridgePacketIDs(packets))
}
body := packets[0].Body
if len(body) != 1+2+currentItemListEntryWireSize || body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(body[1:3]) != 1 {
t.Fatalf("consumed material refresh body=%x", body)
}
row := body[3:]
if binary.LittleEndian.Uint16(row[0:2]) != 8 ||
binary.LittleEndian.Uint32(row[2:6]) != ^uint32(0) ||
binary.LittleEndian.Uint32(row[6:10]) != 0 {
t.Fatalf("consumed material delete row=%x", row[:10])
}
}
// TestCurrentFinishQuestPostCommitRefreshesGoldDebitWalletRow 覆盖"任务交付扣金币"的下发那一层。
//
// 服主口径 2026-10-03:[int data] 里 ID 0 = 金币,完成任务要扣。扣完之后客户端必须看到
// 钱包变了 —— 这条 op14 会把 result.Currency 里的金币写成钱包行(slot 0)。
// 早先 bridge 侧判 `Delta < 0` 就整包拒绝,扣款路径根本走不到这里。
func TestCurrentFinishQuestPostCommitRefreshesGoldDebitWalletRow(t *testing.T) {
connection := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: connection, connID: "finish-gold-debit-refresh", selectedCharacterID: 19}
err := service.sendCurrentFinishQuestRewardItemUpdate(session, dnfquest.FinishCommitResult{
QuestID: 600,
CompletionKey: "quest-finish/19/600/gold-cost",
// 交 100 万:余额 2,000,000 -> 1,000,000。
Currency: []dnfquest.FinishCommittedCurrency{{Name: "gold", Delta: -1000000, PostValue: 1000000}},
})
if err != nil {
t.Fatalf("扣金币必须能下发: %v", err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) != 1 || packets[0].Header.Classification != 0 ||
packets[0].Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("gold debit refresh packets=%v", finishBridgePacketIDs(packets))
}
body := packets[0].Body
if len(body) != 1+2+currentItemListEntryWireSize || body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(body[1:3]) != 1 {
t.Fatalf("gold debit refresh body=%x", body)
}
row := body[3:]
// 钱包行:slot 0,金币余额在 id 字段位置(与 currentGoldWalletItemListEntry 同形状)。
if binary.LittleEndian.Uint16(row[0:2]) != 0 {
t.Fatalf("钱包行必须是 slot 0: row=%x", row[:10])
}
if got := binary.LittleEndian.Uint32(row[6:10]); got != 1000000 {
t.Fatalf("钱包行余额=%d want 1000000 row=%x", got, row[:10])
}
}
func TestCurrentFinishQuestPostCommitRefreshesRemainingAccountSharedMaterial(t *testing.T) {
connection := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: connection, connID: "finish-shared-material-refresh", selectedCharacterID: 19}
err := service.sendCurrentFinishQuestRewardItemUpdate(session, dnfquest.FinishCommitResult{
QuestID: 602,
CompletionKey: "quest-finish/19/602/account-shared-material",
ConsumedItems: []dnfquest.FinishConsumedItem{{
SlotKey: "0:358", SlotIndex: 358, ItemID: 3037, Delta: 100, PostCount: 30,
}},
PostCommitAccountInventory: dnfrepo.AccountInventoryRecord{
AccountID: "acc",
Slots: map[string]dnfrepo.ItemStack{
"0:358": {ItemID: 3037, Count: 30},
},
},
})
if err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) != 1 || packets[0].Header.Classification != 0 ||
packets[0].Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("shared material refresh packets=%v", finishBridgePacketIDs(packets))
}
body := packets[0].Body
if len(body) != 1+2+currentItemListEntryWireSize || body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(body[1:3]) != 1 {
t.Fatalf("shared material refresh body=%x", body)
}
row := body[3:]
if binary.LittleEndian.Uint16(row[0:2]) != 358 ||
binary.LittleEndian.Uint32(row[2:6]) != 3037 ||
binary.LittleEndian.Uint32(row[6:10]) != 30 {
t.Fatalf("shared material refresh row=%x", row[:10])
}
}
func TestCurrentFinishQuestRejectsNonObservedTailBeforeAtomicOwner(t *testing.T) {
repositories := finishBridgeSeed(t)
connection := &bufferConn{}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "finish-quest-invalid-tail", selectedCharacterID: 19}
if err := service.handleCurrentFinishQuest(
session,
[]byte{0x22, 0x00, 0x49, 0x0C, 0xFF, 0xFF, 0x01, 0x00, 0xFF, 0x00},
); err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) != 1 || packets[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishQuest) ||
packets[0].Header.Classification != dnfproto.DefaultChannelClassification ||
!bytes.Equal(packets[0].Body, []byte{0, 22}) {
t.Fatalf("invalid-tail failure packets=%v", finishBridgePacketIDs(packets))
}
questRecord, found, err := repositories.Quest.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("load quest found=%t err=%v", found, err)
}
if state := questRecord.States[3145]; state.Status != "active" || state.Extra["reward_state"] != "pending" {
t.Fatalf("invalid tail reached atomic owner: %+v", state)
}
}
func TestCurrentFinishQuestCommitReplayDoesNotDuplicateAndPublishesSnapshotsInOrder(t *testing.T) {
repositories := finishBridgeSeed(t)
connection := &bufferConn{}
catalog := finishBridgeCatalog(t)
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "finish-quest-test", selectedCharacterID: 19}
request := dnfquest.FinishQuestRequest{
QuestID: 3145,
RewardSelectIndex: ^uint16(0),
Multiplier: 1,
Reserved: dnfquest.CurrentFinishQuestObservedTailMarker,
}
allocator := finishBridgeAllocator(nil)
// First finish: full receipt and post-commit flow.
start := connection.write.Len()
if err := service.handleCurrentFinishQuestWithResources(session, request, repositories, catalog, finishBridgeProgression(t), allocator); err != nil {
t.Fatalf("first finish: %v", err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes()[start:])
assertFinishBridgePostCommitOrder(t, packets, 3145, true)
assertFinishBridgeNoPersonalInfoSnapshots(t, packets)
if packets[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishQuest) || packets[0].Header.Classification != dnfproto.DefaultChannelClassification || len(packets[0].Body) < 2 || packets[0].Body[0] != 1 {
t.Fatalf("first ACK=%+v body=%x", packets[0].Header, packets[0].Body)
}
// Same-session duplicate (double click): suppressed so the client handler
// never sees the receipt twice.
start = connection.write.Len()
if err := service.handleCurrentFinishQuestWithResources(session, request, repositories, catalog, finishBridgeProgression(t), allocator); err != nil {
t.Fatalf("duplicate finish: %v", err)
}
if got := connection.write.Bytes()[start:]; len(got) != 0 {
t.Fatalf("same-session duplicate sent %d bytes: %x", len(got), got)
}
// Reconnect (fresh session): the durable replay receipt is served again.
reconnect := &gameSession{conn: connection, connID: "finish-quest-reconnect", selectedCharacterID: 19}
start = connection.write.Len()
if err := service.handleCurrentFinishQuestWithResources(reconnect, request, repositories, catalog, finishBridgeProgression(t), allocator); err != nil {
t.Fatalf("reconnect finish: %v", err)
}
packets = splitAllFinishBridgePackets(t, connection.write.Bytes()[start:])
if len(packets) == 0 || packets[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishQuest) || len(packets[0].Body) < 2 || packets[0].Body[0] != 1 {
t.Fatalf("reconnect replay receipt missing: %+v", packets)
}
// The first durable replay is also a terminal receipt. A retry in that same
// new session must be suppressed just like a live double click.
start = connection.write.Len()
if err := service.handleCurrentFinishQuestWithResources(reconnect, request, repositories, catalog, finishBridgeProgression(t), allocator); err != nil {
t.Fatalf("reconnect duplicate finish: %v", err)
}
if got := connection.write.Bytes()[start:]; len(got) != 0 {
t.Fatalf("same-session reconnect replay sent %d bytes: %x", len(got), got)
}
inventory, found, err := repositories.Inventory.Load(context.Background(), "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if stack := inventory.Slots["0:65"]; len(inventory.Slots) != 1 || stack.Count != 2 {
t.Fatalf("duplicate click duplicated reward inventory=%+v", inventory.Slots)
}
character, _, _ := repositories.Character.Load(context.Background(), "19")
skill, _, _ := repositories.Skill.Load(context.Background(), "19")
if character.Level != 2 || character.Stats["exp"] != 100 || character.Stats["gold"] != 10 || skill.Points.TotalSP != 30 || skill.Points.SyncedLevel != 2 {
t.Fatalf("post commit character=%+v skill=%+v", character, skill.Points)
}
}
func TestCurrentFinishQuestReplayKeyIsCharacterScoped(t *testing.T) {
session := &gameSession{}
characterA := newCurrentFinishQuestReplayKey(19, dnfquest.FinishQuestRequest{QuestID: 3145})
characterB := newCurrentFinishQuestReplayKey(20, dnfquest.FinishQuestRequest{QuestID: 3145})
session.markCurrentFinishQuestAnswered(characterA)
if !session.currentFinishQuestAnswered(characterA) {
t.Fatal("first character finish receipt was not recorded")
}
if session.currentFinishQuestAnswered(characterB) {
t.Fatal("finish receipt leaked across selected characters with the same quest ID")
}
}
func TestCurrentFinishQuestAckWriteFailureLeavesReplayRetryable(t *testing.T) {
repositories := finishBridgeSeed(t)
wantErr := errors.New("finish ACK write failed")
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: finishBridgeCatalog(t),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: &failNthDungeonWriteConn{failAt: 1, err: wantErr},
connID: "finish-quest-ack-write-retry",
selectedCharacterID: 19,
}
request := dnfquest.FinishQuestRequest{
QuestID: 3145,
RewardSelectIndex: ^uint16(0),
Multiplier: 1,
Reserved: dnfquest.CurrentFinishQuestObservedTailMarker,
}
catalog := finishBridgeCatalog(t)
if err := service.handleCurrentFinishQuestWithResources(session, request, repositories, catalog, finishBridgeProgression(t), finishBridgeAllocator(nil)); !errors.Is(err, wantErr) {
t.Fatalf("first finish error=%v want=%v", err, wantErr)
}
replayKey := newCurrentFinishQuestReplayKey(19, request)
if session.currentFinishQuestAnswered(replayKey) {
t.Fatal("failed finish ACK was incorrectly marked as delivered")
}
connection := &bufferConn{}
session.conn = connection
if err := service.handleCurrentFinishQuestWithResources(session, request, repositories, catalog, finishBridgeProgression(t), finishBridgeAllocator(nil)); err != nil {
t.Fatalf("durable replay retry: %v", err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) == 0 || packets[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishQuest) || len(packets[0].Body) < 2 || packets[0].Body[0] != 1 {
t.Fatalf("durable replay ACK missing after write retry: %+v", packets)
}
if !session.currentFinishQuestAnswered(replayKey) {
t.Fatal("successful durable replay ACK was not marked as delivered")
}
}
func TestCurrentFinishQuestAppliesActiveGrowthContractPVFExperienceBonus(t *testing.T) {
repositories := finishBridgeSeed(t)
if err := repositories.Account.Save(context.Background(), dnfrepo.AccountRecord{
AccountID: defaultAccountPrefix + "1",
Metadata: map[string]string{
premium.MetadataKey(premium.TypeBonusExp): fmt.Sprintf("%d", time.Now().Add(time.Hour).Unix()),
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
catalog := finishBridgeCatalog(t)
service := &Service{
options: options{accountID: defaultAccountPrefix + "1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
premiumCatalog: &currentPremiumCatalog{
effectsByType: map[int64]currentPremiumEffectInfo{
premium.TypeBonusExp: {BonusExperiencePercent: 20},
},
},
}
session := &gameSession{conn: connection, connID: "finish-quest-growth-contract", selectedCharacterID: 19}
request := dnfquest.FinishQuestRequest{
QuestID: 3145,
RewardSelectIndex: ^uint16(0),
Multiplier: 1,
Reserved: dnfquest.CurrentFinishQuestObservedTailMarker,
}
if err := service.handleCurrentFinishQuestWithResources(
session,
request,
repositories,
catalog,
finishBridgeProgression(t),
finishBridgeAllocator(nil),
); err != nil {
t.Fatal(err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes())
if len(packets) == 0 || packets[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishQuest) ||
len(packets[0].Body) < 8 || binary.LittleEndian.Uint32(packets[0].Body[4:8]) != 120 {
t.Fatalf("growth-contract finish ACK=%+v", packets)
}
character, found, err := repositories.Character.Load(context.Background(), "19")
if err != nil || !found || character.Stats["exp"] != 120 {
t.Fatalf("growth-contract committed character=%+v found=%t err=%v", character, found, err)
}
}
func TestCurrentFinishQuestTransactionFailureEmitsZeroAck(t *testing.T) {
repositories := finishBridgeSeed(t)
wantErr := errors.New("settlement commit rejected")
repositories.CharacterSettlement = finishBridgeFailingSettlement{err: wantErr}
connection := &bufferConn{}
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
session := &gameSession{conn: connection, selectedCharacterID: 19}
request := dnfquest.FinishQuestRequest{
QuestID: 3145,
RewardSelectIndex: ^uint16(0),
Multiplier: 1,
Reserved: dnfquest.CurrentFinishQuestObservedTailMarker,
}
if err := service.handleCurrentFinishQuestWithResources(session, request, repositories, finishBridgeCatalog(t), finishBridgeProgression(t), finishBridgeAllocator(nil)); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("failed transaction emitted packet(s)=%x", connection.write.Bytes())
}
questRecord, _, _ := repositories.Quest.Load(context.Background(), "19")
if state := questRecord.States[3145]; state.Status != "active" || state.Extra["reward_state"] != "pending" {
t.Fatalf("failed transaction mutated quest=%+v", state)
}
}
func assertFinishBridgePostCommitOrder(t *testing.T, packets []dnfproto.ChannelPacket, completedQuestID uint16, levelChanged bool) {
t.Helper()
if len(packets) < 5 {
t.Fatalf("post-commit packet count=%d", len(packets))
}
index := func(class byte, msg uint16) int {
for i, packet := range packets {
if packet.Header.Classification == class && packet.Header.MsgID == msg {
return i
}
}
return -1
}
ack := index(dnfproto.DefaultChannelClassification, uint16(dnfenum.CmdPacketFinishQuest))
item := index(0, uint16(dnfenum.CmdPacketWalkoutPartyMember))
acceptable := index(0, currentAcceptableQuestListMsgID)
active := index(0, currentActiveQuestSnapshotMsgID)
clearQuest := index(0, currentClearQuestListMsgID)
character := index(0, currentDungeonCharacterStateMsgID)
skill := index(0, currentSkillInfoMsgID)
mode := func(value byte) int {
for i, packet := range packets {
if packet.Header.Classification == 0 &&
packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) &&
len(packet.Body) != 0 && packet.Body[0] == value {
return i
}
}
return -1
}
mode1, mode3 := mode(1), mode(3)
stateStart := ack
if item >= 0 {
if item <= ack {
t.Fatalf("post-commit item order ack=%d item=%d packets=%v", ack, item, finishBridgePacketIDs(packets))
}
stateStart = item
}
if mode3 >= 0 {
t.Fatalf("post-commit unexpectedly sent panel-opening mode3=%d packets=%v", mode3, finishBridgePacketIDs(packets))
}
if mode1 >= 0 {
if mode1 <= stateStart {
t.Fatalf("post-commit mode1 order ack=%d item=%d mode1=%d packets=%v", ack, item, mode1, finishBridgePacketIDs(packets))
}
stateStart = mode1
}
questRefreshInOrder := active > skill && clearQuest > active && acceptable > clearQuest
if !(ack == 0 && character > stateStart && skill > character && questRefreshInOrder) {
t.Fatalf("post-commit order level_changed=%t ack=%d item=%d mode1=%d mode3=%d clear=%d acceptable=%d active=%d character=%d skill=%d packets=%v",
levelChanged, ack, item, mode1, mode3, clearQuest, acceptable, active, character, skill, finishBridgePacketIDs(packets))
}
assertFinishBridgeClearQuestProjection(t, packets[clearQuest], completedQuestID)
if item >= 0 {
if len(packets[item].Body) != 3+2*currentItemListEntryWireSize || packets[item].Body[0] != 0 ||
binary.LittleEndian.Uint16(packets[item].Body[1:3]) != 2 ||
binary.LittleEndian.Uint32(packets[item].Body[5:9]) != 0 ||
binary.LittleEndian.Uint32(packets[item].Body[9:13]) != 10 ||
binary.LittleEndian.Uint32(packets[item].Body[3+currentItemListEntryWireSize+2:3+currentItemListEntryWireSize+6]) != 10403 {
t.Fatalf("quest reward op14 body=%x", packets[item].Body)
}
}
if len(packets[character].Body) != currentFinishLoadingCharacterStateBodySize || packets[character].Body[0] != 2 || binary.LittleEndian.Uint32(packets[character].Body[1:5]) != 100 {
t.Fatalf("character snapshot body=%x", packets[character].Body)
}
if len(packets[skill].Body) < 4 || int(binary.LittleEndian.Uint32(packets[skill].Body[:4])) != len(packets[skill].Body)-4 {
t.Fatalf("skill snapshot body=%x", packets[skill].Body)
}
}
func assertFinishBridgeClearQuestProjection(t *testing.T, packet dnfproto.ChannelPacket, completedQuestID uint16) {
t.Helper()
if packet.Header.Classification != 0 || packet.Header.MsgID != currentClearQuestListMsgID {
t.Fatalf("clear-quest packet=%d/%d want=0/%d", packet.Header.Classification, packet.Header.MsgID, currentClearQuestListMsgID)
}
// splitAllFinishBridgePackets parses this mixed stream with the ordinary
// 13-byte header. op356 uses the proved fixed16 header, so its two reserved
// bytes and protected-body marker remain at the start of Body.
if len(packet.Body) < 4 || packet.Body[0] != 0 || packet.Body[1] != 0 || packet.Body[2] != 1 {
t.Fatalf("clear-quest fixed16 suffix/body=%x", packet.Body)
}
plain, err := zlibDecompress(packet.Body[3:])
if err != nil {
t.Fatalf("decompress committed clear-quest projection: %v", err)
}
if len(plain) != 4+currentClearQuestListPayloadSize ||
binary.LittleEndian.Uint32(plain[:4]) != currentClearQuestListPayloadSize {
t.Fatalf("committed clear-quest projection len=%d prefix=%x", len(plain), plain[:minInt(len(plain), 4)])
}
if int(completedQuestID) >= currentClearQuestListPayloadSize {
t.Fatalf("committed quest %d exceeds clear-quest payload", completedQuestID)
}
if plain[4+int(completedQuestID)] != 1 {
t.Fatalf("committed quest %d clear-quest flag=%d", completedQuestID, plain[4+int(completedQuestID)])
}
}
func assertFinishBridgeNoPersonalInfoSnapshots(t *testing.T, packets []dnfproto.ChannelPacket) {
t.Helper()
for _, packet := range packets {
if packet.Header.Classification == 0 &&
packet.Header.MsgID == uint16(dnfenum.CmdPacketSetUDPIPPort) &&
len(packet.Body) != 0 &&
(packet.Body[0] == 1 || packet.Body[0] == 3) {
t.Fatalf("ordinary finish pushed personal-info mode=%d ids=%v", packet.Body[0], finishBridgePacketIDs(packets))
}
}
}
func assertFinishBridgeSuccessorSnapshots(t *testing.T, packets []dnfproto.ChannelPacket, completedQuestID, successorQuestID uint16) {
t.Helper()
activeIndex := -1
acceptableIndex := -1
activeCount := 0
acceptableCount := 0
for index, packet := range packets {
if packet.Header.Classification != 0 {
continue
}
switch packet.Header.MsgID {
case currentActiveQuestSnapshotMsgID:
activeCount++
activeIndex = index
case currentAcceptableQuestListMsgID:
acceptableCount++
acceptableIndex = index
}
}
if activeCount != 1 || acceptableCount != 1 {
t.Fatalf("post-finish quest snapshot counts active=%d acceptable=%d packets=%v", activeCount, acceptableCount, finishBridgePacketIDs(packets))
}
activeBody := packets[activeIndex].Body
if len(activeBody) < 4 {
t.Fatalf("active snapshot body too short: %x", activeBody)
}
rowCount := int(binary.LittleEndian.Uint32(activeBody[:4]))
if len(activeBody) != 4+rowCount*6 {
t.Fatalf("active snapshot body len=%d count=%d body=%x", len(activeBody), rowCount, activeBody)
}
for offset := 4; offset < len(activeBody); offset += 6 {
questID := binary.LittleEndian.Uint16(activeBody[offset : offset+2])
if questID == completedQuestID {
t.Fatalf("completed quest %d remained in active snapshot: %x", completedQuestID, activeBody)
}
if questID == successorQuestID {
t.Fatalf("successor quest %d was auto-activated before manual op31: %x", successorQuestID, activeBody)
}
}
acceptableBody := packets[acceptableIndex].Body
if len(acceptableBody) < 4 || int(binary.LittleEndian.Uint32(acceptableBody[:4])) != len(acceptableBody)-4 {
t.Fatalf("acceptable snapshot protobuf length mismatch: %x", acceptableBody)
}
_, messages := consumeCurrentSkillInfoFields(t, acceptableBody[4:])
if len(messages[4]) != 1 {
t.Fatalf("acceptable snapshot field4 rows=%d body=%x", len(messages[4]), acceptableBody)
}
questIDs := consumePackedQuestIDs(t, messages[4][0])
foundCompleted := false
foundSuccessor := false
for _, questID := range questIDs {
if questID == int32(completedQuestID) {
foundCompleted = true
}
if questID == int32(successorQuestID) {
foundSuccessor = true
}
}
if foundCompleted {
t.Fatalf("completed task-manual definition %d leaked into op21: %v", completedQuestID, questIDs)
}
if !foundSuccessor {
t.Fatalf("successor quest %d missing from task-manual snapshot: %v", successorQuestID, questIDs)
}
}
func splitAllFinishBridgePackets(t *testing.T, data []byte) []dnfproto.ChannelPacket {
t.Helper()
packets := make([]dnfproto.ChannelPacket, 0, 12)
for len(data) > 0 {
packet, rest := splitGameServerUpperPacket(t, data)
packets = append(packets, packet)
data = rest
}
return packets
}
func finishBridgePacketIDs(packets []dnfproto.ChannelPacket) []string {
out := make([]string, 0, len(packets))
for _, packet := range packets {
out = append(out, fmt.Sprintf("%d/%d", packet.Header.Classification, packet.Header.MsgID))
}
return out
}
func finishBridgeSeed(t *testing.T) dnfrepo.Group {
t.Helper()
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, save := range []func() error{
func() error {
return repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: defaultAccountPrefix + "1", Job: "2", Level: 1, Stats: map[string]int64{"exp": 0, "grow_type": 0}})
},
func() error {
return repositories.Quest.Save(ctx, dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{3145: {Status: "active", ProgressValue: 0, Extra: map[string]string{"completion_key": "run-19/op117/426", "completion_kind": "clear_map", "reward_state": "pending"}}}})
},
func() error {
return repositories.Skill.Save(ctx, dnfrepo.SkillRecord{CharacterID: "19", Points: dnfrepo.SkillPointState{SyncedLevel: 1}, Layouts: map[int]dnfrepo.SkillLayout{currentSkillInfoTreeIndex: {}}})
},
func() error {
return repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{}})
},
func() error { return repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "19"}) },
} {
if err := save(); err != nil {
t.Fatal(err)
}
}
return repositories
}
func finishBridgeCatalog(t *testing.T) *dnfquest.Catalog {
t.Helper()
source := finishBridgePVFSource{
dnfquest.DefaultList: "3145 `finish.qst`\n3146 `next.qst`\n",
"n_quest/finish.qst": "[grade]\n`[epic]`\n[type]\n`[clear map]`\n[difficulty]\n`N`\n[reward type]\n`[item]`\n[reward int data]\n0 1 10403 2\n[level]\n1 99\n[job]\n`[gunner]`\n[exposed by npc]\n1\n[int data]\n76126 0 0 1\n",
"n_quest/next.qst": "[grade]\n`[epic]`\n[type]\n`[quest clear]`\n[difficulty]\n`N`\n[reward type]\n`[item]`\n[reward int data]\n10404 1\n[level]\n2 99\n[job]\n`[gunner]`\n[exposed by npc]\n1\n[pre required quest]\n3145\n[int data]\n3157 3054\n",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfquest.Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
return catalog
}
func finishBridgeHellPartyCatalog(t *testing.T) *dnfquest.Catalog {
t.Helper()
source := finishBridgePVFSource{
dnfquest.DefaultList: "2002 `hell_hunt.qst`\n",
"n_quest/hell_hunt.qst": "[grade]\n`[side]`\n[type]\n`[hunt enemy]`\n" +
"[int data]\n192 1 70034 1 1 193 1 70300 1 1 194 1 70307 1 1\n" +
"[reward type]\n`[hell challenge]`\n[reward int data]\n1\n" +
"[difficulty]\n`N`\n[level]\n1 99\n[job]\n`[all]`\n[exposed by npc]\n1\n",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfquest.Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
return catalog
}
func finishBridgeHellPartyWorldMap(t *testing.T, areaID, questID int64) (*worldmap.Table, *worldmap.Resolver) {
t.Helper()
source := finishBridgePVFSource{
worldmap.DefaultWorldMapList: fmt.Sprintf("%d `hell.wdm`\n", areaID),
"worldmap/hell.wdm": fmt.Sprintf("[name]\n`Hell Party Area`\n[hell dungeon]\n1\n[hell quest]\n%d\n[/hell quest]\n", questID),
}
table, err := worldmap.LoadSource(context.Background(), source, worldmap.Options{SkipMaps: true, SkipDungeons: true})
if err != nil {
t.Fatal(err)
}
resolver, err := worldmap.NewResolver(table)
if err != nil {
t.Fatal(err)
}
return table, resolver
}
func finishBridgeExpertJobQuestCatalog(t *testing.T, questID uint16, jobType byte) *dnfquest.Catalog {
t.Helper()
source := finishBridgePVFSource{
dnfquest.DefaultList: fmt.Sprintf("%d `expert.qst`\n", questID),
"n_quest/expert.qst": fmt.Sprintf("[grade]\n`[side]`\n[type]\n`[seeking]`\n[difficulty]\n`N`\n[reward type]\n`[expert job]`\n[reward int data]\n%d\n[level]\n1 99\n[job]\n`[all]`\n[exposed by npc]\n1\n", jobType),
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfquest.Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
return catalog
}
func finishBridgeProfessionCatalog(t *testing.T) *dnfquest.Catalog {
t.Helper()
source := finishBridgePVFSource{
dnfquest.DefaultList: "100 `change.qst`\n",
"n_quest/change.qst": "[grade]\n`[epic]`\n[type]\n`[meet npc]`\n[difficulty]\n`N`\n[reward type]\n`[grow type]`\n[reward int data]\n2\n[level]\n1 99\n[job]\n`[gunner]`\n[exposed by npc]\n1\n[job change quest]\n1\n",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfquest.Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
return catalog
}
func finishBridgeSlotExpansionCatalog(t *testing.T) *dnfquest.Catalog {
t.Helper()
source := finishBridgePVFSource{
dnfquest.DefaultList: "2636 `earring.qst`\n",
"n_quest/earring.qst": "[grade]\n`[side]`\n[type]\n`[meet npc]`\n[difficulty]\n`N`\n[reward type]\n`[slot expansion]`\n[reward int data]\n2\n[level]\n1 99\n[job]\n`[all]`\n[exposed by npc]\n1\n",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfquest.Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
return catalog
}
func finishBridgeProfessionResources(t *testing.T) (*dnfprofession.Profiles, *dnfskill.Table) {
t.Helper()
source := finishBridgePVFSource{
dnfprofession.DefaultCharacterList: "2 `Gunner/Gunner.chr`\n",
"character/Gunner/Gunner.chr": "[initial value]\n[skill]\n1 1\n[/skill]\n[growtype 2]\n[skill]\n3 1\n[/skill]\n[growtype 3]\n[skill]\n2 1\n197 1\n[/skill]\n[awakening 1]\n[awakening skill]\n4 1\n[/awakening skill]\n",
dnfskill.DefaultList: "2 `GunnerSkill.lst`\n",
"skill/GunnerSkill.lst": "1 `Gunner/a.skl` 2 `Gunner/b.skl` 3 `Gunner/c.skl` 4 `Gunner/d.skl` 197 `Gunner/mastery.skl`\n",
"skill/Gunner/a.skl": "[name]\n`a`\n[type]\n`[active]`\n",
"skill/Gunner/b.skl": "[name]\n`b`\n[type]\n`[passive]`\n",
"skill/Gunner/c.skl": "[name]\n`c`\n[type]\n`[active]`\n",
"skill/Gunner/d.skl": "[name]\n`d`\n[type]\n`[passive]`\n",
"skill/Gunner/mastery.skl": "[name]\n`mastery`\n[type]\n`[passive]`\n",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatal(err)
}
profiles, err := dnfprofession.LoadProfiles(context.Background(), source, catalog)
if err != nil {
t.Fatal(err)
}
return profiles, catalog
}
func finishBridgeProgression(t *testing.T) *progression.Tables {
t.Helper()
tables, err := progression.Load(context.Background(), finishBridgePVFSource{
progression.ExperienceTablePath: "100 250 500\n",
progression.SkillPointTablePath: "[sp table]\n1 0\n2 30\n3 30\n4 40\n[/sp table]\n[tp table]\n50 1\n[/tp table]\n",
progression.QuestParameterPath: "[difficulty]\n`N` 100\n[/difficulty]\n[exp reward table]\n100 -1\n200 -1\n300 -1\n[gold reward table]\n0 10\n[green level penalty]\n80\n[grey level penalty]\n30\n",
})
if err != nil {
t.Fatal(err)
}
return tables
}
func finishBridgeAllocator(fail error) dnfquest.FinishItemAllocator {
return func(record *dnfrepo.InventoryRecord, request dnfquest.FinishItemGrantRequest) (dnfquest.FinishCommittedItem, error) {
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
const slot = uint16(65)
const key = "0:65"
postCount := request.Count
if current, ok := record.Slots[key]; ok {
postCount += current.Count
}
raw := make([]byte, currentItemListEntryWireSize)
binary.LittleEndian.PutUint16(raw[0:2], slot)
binary.LittleEndian.PutUint32(raw[2:6], uint32(request.ItemID))
binary.LittleEndian.PutUint32(raw[6:10], uint32(postCount))
record.Slots[key] = dnfrepo.ItemStack{ItemID: request.ItemID, Count: postCount, RawEntry: append([]byte(nil), raw...)}
if fail != nil {
return dnfquest.FinishCommittedItem{}, fail
}
return dnfquest.FinishCommittedItem{SlotKey: key, SlotIndex: slot, ItemID: request.ItemID, Delta: request.Count, PostCount: postCount, CountOrSeed: uint32(request.Count), RawEntry: raw}, nil
}
}
type finishBridgePVFSource map[string]string
func (s finishBridgePVFSource) ReadText(path string) (string, error) {
text, ok := s[path]
if !ok {
return "", fmt.Errorf("missing %s", path)
}
return text, nil
}
type finishBridgeFailingSettlement struct{ err error }
func (f finishBridgeFailingSettlement) WithinCharacterSettlement(context.Context, string, func(dnfrepo.Group) error) error {
return f.err
}
var _ dnfrepo.CharacterSettlementUnitOfWork = finishBridgeFailingSettlement{}