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