DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,869 @@
package quest
import (
"context"
"errors"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerPlanReadsQuestState(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "46",
AccountID: "acc-quest",
}); err != nil {
t.Fatalf("save character: %v", err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "46",
States: map[int64]dnfrepo.QuestState{
0x1234: {Status: "active", ProgressValue: 2},
},
}); err != nil {
t.Fatalf("save quest: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.Plan(ctx, NewFinishCommand(alignedRequest("acc-quest", 46), FinishQuestRequest{
QuestID: 0x1234,
RewardSelectIndex: 1,
HasRewardSelect: true,
Multiplier: 2,
}))
if err != nil {
t.Fatalf("Plan() error = %v", err)
}
if got.AccountID != "acc-quest" || got.CharacterID != "46" || !got.Known || got.Status != "active" || got.ProgressValue != 2 || got.RewardSelectIndex != 1 || got.Multiplier != 2 {
t.Fatalf("result = %+v", got)
}
}
func TestOwnerApplySetTriggerType1RecomputesSeekingItems(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "48",
AccountID: "acc-quest",
}); err != nil {
t.Fatalf("save character: %v", err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "48",
States: map[int64]dnfrepo.QuestState{
0x2222: {
Status: "active",
ProgressValue: 1,
Extra: map[string]string{
"seeking_item_ids": "9001",
},
},
},
}); err != nil {
t.Fatalf("save quest: %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "48",
Slots: map[string]dnfrepo.ItemStack{
"0:9": {ItemID: 9001, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.ApplySetTrigger(ctx, NewTriggerCommand(alignedRequest("acc-quest", 48), SetTriggerRequest{
QuestID: 0x2222,
TriggerType: 1,
IsIncrement: true,
}))
if err != nil {
t.Fatalf("ApplySetTrigger() error = %v", err)
}
if got.ProgressValue != 0 {
t.Fatalf("progress = %d, want 0 after held seeking item", got.ProgressValue)
}
if !got.StateChanged {
t.Fatalf("first trigger result = %+v, want durable state change", got)
}
loaded, ok, err := repos.Quest.Load(ctx, "48")
if err != nil || !ok {
t.Fatalf("load quest ok=%v err=%v", ok, err)
}
if got := loaded.States[0x2222].ProgressValue; got != 0 {
t.Fatalf("stored progress = %d, want 0", got)
}
replayed, err := owner.ApplySetTrigger(ctx, NewTriggerCommand(alignedRequest("acc-quest", 48), SetTriggerRequest{
QuestID: 0x2222,
TriggerType: 1,
IsIncrement: true,
}))
if err != nil {
t.Fatalf("ApplySetTrigger replay error = %v", err)
}
if replayed.StateChanged {
t.Fatalf("replay result = %+v, want no durable state change", replayed)
}
}
func TestOwnerApplySetTriggerSeekAndMeetNPCRecomputesAccountSharedItems(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "50",
AccountID: "acc-quest",
}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "50",
States: map[int64]dnfrepo.QuestState{
3145: {Status: "active", ProgressValue: int64(packTriggerChannels(304, 1))},
},
}); err != nil {
t.Fatal(err)
}
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "acc-quest",
Slots: map[string]dnfrepo.ItemStack{
"0:358": {ItemID: 3037, Count: 499},
},
}); err != nil {
t.Fatal(err)
}
catalog := testAcceptCatalog(t, "[type]\n`[seek n meet npc]`\n[int data]\n3037 500 126\n")
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := NewTriggerCommand(alignedRequest("acc-quest", 50), SetTriggerRequest{
QuestID: 3145, TriggerType: 0x10,
})
insufficient, err := owner.ApplySetTriggerWithCatalog(ctx, catalog, cmd)
if err != nil {
t.Fatal(err)
}
if insufficient.StateChanged || insufficient.ProgressValue != int64(packTriggerChannels(304, 1)) {
t.Fatalf("insufficient result=%+v", insufficient)
}
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "acc-quest",
Slots: map[string]dnfrepo.ItemStack{
"0:358": {ItemID: 3037, Count: 120019},
},
}); err != nil {
t.Fatal(err)
}
satisfied, err := owner.ApplySetTriggerWithCatalog(ctx, catalog, cmd)
if err != nil {
t.Fatal(err)
}
if !satisfied.StateChanged || satisfied.ProgressValue != int64(packTriggerChannels(0, 1)) {
t.Fatalf("satisfied result=%+v", satisfied)
}
stored, ok, err := repos.Quest.Load(ctx, "50")
if err != nil || !ok || stored.States[3145].ProgressValue != int64(packTriggerChannels(0, 1)) {
t.Fatalf("stored quest ok=%t err=%v state=%+v", ok, err, stored.States[3145])
}
}
func TestOwnerApplySetTriggerUpdatesOnlyRequestedPackedChannel(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "49",
AccountID: "acc-quest",
}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "49",
States: map[int64]dnfrepo.QuestState{
3193: {Status: "active", ProgressValue: int64(packTriggerChannels(1, 1))},
3203: {Status: "active", ProgressValue: int64(packTriggerChannels(4, 6, 7))},
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
apply := func(questID uint16, triggerType byte, increment bool, want int64) {
t.Helper()
result, err := owner.ApplySetTrigger(ctx, NewTriggerCommand(alignedRequest("acc-quest", 49), SetTriggerRequest{
QuestID: questID, TriggerType: triggerType, IsIncrement: increment,
}))
if err != nil {
t.Fatalf("ApplySetTrigger quest=%d type=%#x: %v", questID, triggerType, err)
}
if !result.StateChanged || result.ProgressValue != want {
t.Fatalf("ApplySetTrigger quest=%d type=%#x result=%+v want progress=%d", questID, triggerType, result, want)
}
}
apply(3193, 0x10, false, int64(packTriggerChannels(0, 1)))
apply(3193, 0x20, false, 0)
apply(3203, 0x40, false, int64(packTriggerChannels(4, 6, 6)))
apply(3203, 0x20, true, int64(packTriggerChannels(4, 7, 6)))
}
// Live incident 2026-09-12T13:32:33Z: quest 2900 held the PVF initial trigger
// 262657 = packTriggerChannels(1, 1, 1) and the client reported its objective
// with the scalar (no lane mask) op33 shape. Subtracting one from the packed
// u32 changed the durable value on every identical request, so the client kept
// re-triggering it: 3767 requests at ~30 Hz across seven logins while every
// town entry stayed frozen. The scalar shape must answer the satisfied
// objective once and then stay idempotent.
func TestOwnerApplySetTriggerScalarRemoveClearsPackedObjectiveWithoutCounterLoop(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "52",
AccountID: "acc-quest",
}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "52",
States: map[int64]dnfrepo.QuestState{
2900: {Status: "active", ProgressValue: int64(packTriggerChannels(1, 1, 1))},
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
apply := func() PlanResult {
t.Helper()
result, applyErr := owner.ApplySetTrigger(ctx, NewTriggerCommand(alignedRequest("acc-quest", 52), SetTriggerRequest{QuestID: 2900}))
if applyErr != nil {
t.Fatalf("ApplySetTrigger quest=2900: %v", applyErr)
}
return result
}
if first := apply(); !first.StateChanged || first.ProgressValue != 0 {
t.Fatalf("scalar remove result=%+v want state changed to 0", first)
}
if replay := apply(); replay.StateChanged || replay.ProgressValue != 0 {
t.Fatalf("replayed scalar remove result=%+v want unchanged zero", replay)
}
stored, found, err := repos.Quest.Load(ctx, "52")
if err != nil || !found || stored.States[2900].ProgressValue != 0 {
t.Fatalf("persisted scalar-remove state found=%t err=%v state=%+v", found, err, stored.States[2900])
}
}
// 婚约任务 20737 的真实 PVF 形状:[type] `[condition under clear]` + [sub type] 22 +
// [int data] `-1 -1 5`,即"跟心上人组 2 人队通关普通地下城 5 次"。
//
// 这种计数任务的进度由**服务端**在通关结算里权威地减 1(clear_map.go 的
// decrementClearTriggerChannel),而客户端每通关一次还会发一条标量 op33
// ("目标已满足")。标量通知必须原样回权威值、不再动进度,否则一次通关会被减两次
// 甚至直接清零 —— 实测就是这样"通关 1 次就完成",客户端还显示 5/5。
//
// 对照:任务 2900 是 [hunt enemy],仍然保持"标量即满足 → 清零"的旧语义
// (见 TestOwnerApplySetTriggerScalarRemoveClearsPackedObjectiveWithoutCounterLoop)。
func TestOwnerApplySetTriggerScalarKeepsConditionUnderClearCounter(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "61",
AccountID: "acc-quest",
}); err != nil {
t.Fatal(err)
}
// 接取后的权威进度:5 次待通关。
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "61",
States: map[int64]dnfrepo.QuestState{
20737: {Status: "active", ProgressValue: int64(packTriggerChannels(5))},
},
}); err != nil {
t.Fatal(err)
}
source := catalogTestSource{
DefaultList: "20737 `wedding.qst`\n",
"n_quest/wedding.qst": questCatalogTestDefinition("[wedding]", 30, 99, "[all]",
"[type]\n`[condition under clear]`\n[sub type]\n22\n[int data]\n-1 -1 5\n"),
}
index, err := dnfpvf.Build(ctx, source, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(ctx, index)
if err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplySetTriggerWithCatalog(ctx, catalog,
NewTriggerCommand(alignedRequest("acc-quest", 61), SetTriggerRequest{QuestID: 20737}))
if err != nil {
t.Fatal(err)
}
if result.ProgressValue != 5 || result.StateChanged {
t.Fatalf("标量 op33 动了 [condition under clear] 的计数器: %+v", result)
}
stored, found, err := repos.Quest.Load(ctx, "61")
if err != nil || !found || stored.States[20737].ProgressValue != 5 {
t.Fatalf("持久化的计数器被改写: found=%t err=%v state=%+v", found, err, stored.States[20737])
}
}
func TestOwnerApplySetTriggerRaidPhaseClearLimitsEachPhaseToOncePerChinaDay(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "51",
AccountID: "acc-quest",
}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "51",
States: map[int64]dnfrepo.QuestState{
12830: {Status: "active", ProgressValue: int64(packTriggerChannels(5, 5))},
},
}); err != nil {
t.Fatal(err)
}
catalog := &Catalog{byID: map[int64]Definition{
12830: {ID: 12830, Type: "[raid phase clear]", IntData: []int64{0, 0, 5, -1, 1, 0, 5, -1}},
}}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
apply := func(triggerType byte) PlanResult {
t.Helper()
result, applyErr := owner.ApplySetTriggerWithCatalog(ctx, catalog, NewTriggerCommand(alignedRequest("acc-quest", 51), SetTriggerRequest{
QuestID: 12830, TriggerType: triggerType,
}))
if applyErr != nil {
t.Fatal(applyErr)
}
return result
}
if first := apply(0x10); !first.StateChanged || first.ProgressValue != int64(packTriggerChannels(4, 5)) {
t.Fatalf("first phase-one clear=%+v", first)
}
if duplicate := apply(0x10); duplicate.StateChanged || duplicate.ProgressValue != int64(packTriggerChannels(4, 5)) {
t.Fatalf("same-day phase-one replay=%+v", duplicate)
}
if secondPhase := apply(0x20); !secondPhase.StateChanged || secondPhase.ProgressValue != int64(packTriggerChannels(4, 4)) {
t.Fatalf("same-day phase-two clear=%+v", secondPhase)
}
if unknown := apply(0x40); unknown.StateChanged || unknown.ProgressValue != int64(packTriggerChannels(4, 4)) {
t.Fatalf("undeclared phase channel=%+v", unknown)
}
stored, ok, err := repos.Quest.Load(ctx, "51")
if err != nil || !ok {
t.Fatalf("load stored quest ok=%t err=%v", ok, err)
}
state := stored.States[12830]
today := raidPhaseClearCalendarDay(time.Now())
if state.Extra["raid_phase_clear_day_0"] != today || state.Extra["raid_phase_clear_day_1"] != today {
t.Fatalf("daily markers=%+v want day=%s", state.Extra, today)
}
state.Extra["raid_phase_clear_day_0"] = "2000-01-01"
stored.States[12830] = state
if err := repos.Quest.Save(ctx, stored); err != nil {
t.Fatal(err)
}
if nextDay := apply(0x10); !nextDay.StateChanged || nextDay.ProgressValue != int64(packTriggerChannels(3, 4)) {
t.Fatalf("next-day phase-one clear=%+v", nextDay)
}
}
func TestRaidPhaseClearCalendarDayRollsAtSixBeijing(t *testing.T) {
before := time.Date(2026, 8, 19, 5, 59, 59, 0, raidPhaseClearCalendar)
after := time.Date(2026, 8, 19, 6, 0, 0, 0, raidPhaseClearCalendar)
if got := raidPhaseClearCalendarDay(before); got != "2026-08-18" {
t.Fatalf("05:59 service day=%s want 2026-08-18", got)
}
if got := raidPhaseClearCalendarDay(after); got != "2026-08-19" {
t.Fatalf("06:00 service day=%s want 2026-08-19", got)
}
}
func TestPackedTriggerChannelArithmeticSaturatesAndPreservesReservedBits(t *testing.T) {
const reserved = uint32(0xf8000000)
start := reserved | packTriggerChannels(511, 6, 7)
if got := incrementPackedTriggerChannel(start, 0); got != start {
t.Fatalf("saturated packed trigger=%#08x want unchanged %#08x", got, start)
}
if got := decrementPackedTriggerChannel(reserved|packTriggerChannels(0, 6, 7), 0); got != reserved|packTriggerChannels(0, 6, 7) {
t.Fatalf("zero packed trigger=%#08x want unchanged", got)
}
want := reserved | packTriggerChannels(1, 7, 7)
if got := incrementPackedTriggerChannel(reserved|packTriggerChannels(1, 6, 7), 1); got != want {
t.Fatalf("packed trigger increment=%#08x want=%#08x", got, want)
}
}
func TestOwnerPlanRejectsMissingCharacter(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
_, err = owner.Plan(ctx, NewQuestIDCommand(alignedRequest("acc-quest", 47), "accept_quest", QuestIDRequest{QuestID: 1}))
if !errors.Is(err, ErrCharacterNotFound) {
t.Fatalf("Plan() error = %v, want ErrCharacterNotFound", err)
}
}
func TestOwnerApplyAcceptPersistsThenReturnsIdempotentResult(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "17",
AccountID: "acc-quest",
Job: "2",
Level: 1,
}); err != nil {
t.Fatalf("save character: %v", err)
}
catalog := testAcceptCatalog(t, "[type]\n`[clear map]`\n[int data]\n76126\n")
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := NewQuestIDCommand(alignedRequest("acc-quest", 17), "accept_quest", QuestIDRequest{QuestID: 3145})
first, err := owner.ApplyAccept(ctx, catalog, CharacterEligibility{Level: 1, Job: 2}, cmd)
if err != nil {
t.Fatalf("ApplyAccept first error = %v", err)
}
if first.QuestID != 3145 || first.InitTrigger != 1 || first.Idempotent {
t.Fatalf("first result = %+v", first)
}
stored, ok, err := repos.Quest.Load(ctx, "17")
if err != nil || !ok {
t.Fatalf("load stored quest ok=%t err=%v", ok, err)
}
state := stored.States[3145]
if state.Status != "active" || state.ProgressValue != 1 || state.Extra["pvf_path"] == "" || normalizeQuestTag(state.Extra["quest_type"]) != "clear map" {
t.Fatalf("stored state = %+v", state)
}
second, err := owner.ApplyAccept(ctx, catalog, CharacterEligibility{Level: 1, Job: 2}, cmd)
if err != nil {
t.Fatalf("ApplyAccept retry error = %v", err)
}
if !second.Idempotent || second.InitTrigger != 1 {
t.Fatalf("retry result = %+v", second)
}
}
func TestOwnerSeekingAcceptPersistsPVFRequirementsAndReconcilesExistingState(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "17", AccountID: "acc-quest", Job: "2", Level: 1,
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "17", Slots: map[string]dnfrepo.ItemStack{
"0:9": {ItemID: 4821, Count: 1},
"0:10": {ItemID: 4822, Count: 1},
"0:11": {ItemID: 4823, Count: 1},
"0:12": {ItemID: 4824, Count: 1},
}}); err != nil {
t.Fatal(err)
}
catalog := testAcceptCatalog(t, "[grade]\n`[side]`\n[type]\n`[seeking]`\n[int data]\n4821 1 4822 1 4823 1 4824 1\n")
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
command := NewQuestIDCommand(alignedRequest("acc-quest", 17), "accept_quest", QuestIDRequest{QuestID: 3145})
if _, err := owner.ApplyAccept(ctx, catalog, CharacterEligibility{Level: 1, Job: 2}, command); err != nil {
t.Fatal(err)
}
stored, found, err := repos.Quest.Load(ctx, "17")
if err != nil || !found {
t.Fatalf("load accepted seeking quest found=%t err=%v", found, err)
}
state := stored.States[3145]
if state.Extra["seeking_item_ids"] != "4821,4822,4823,4824" ||
state.Extra["seeking_item_counts"] != "1,1,1,1" {
t.Fatalf("accepted seeking requirements=%+v", state.Extra)
}
// Simulate a pre-fix accepted record which has no cached keys. The native
// type-1 held-item probe must still resolve the PVF requirements and open
// the exact terminal op34 path.
delete(state.Extra, "seeking_item_ids")
delete(state.Extra, "seeking_item_counts")
stored.States[3145] = state
if err := repos.Quest.Save(ctx, stored); err != nil {
t.Fatal(err)
}
result, err := owner.ApplySetTriggerWithCatalog(ctx, catalog, NewTriggerCommand(
alignedRequest("acc-quest", 17),
SetTriggerRequest{QuestID: 3145, TriggerType: 1},
))
if err != nil {
t.Fatal(err)
}
if !result.StateChanged || result.ProgressValue != 0 {
t.Fatalf("seeking PVF fallback result=%+v", result)
}
}
func TestOwnerApplyAcceptActivatesNoRewardMainSubQuests(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "acc-quest",
Job: "2",
Level: 3,
}); err != nil {
t.Fatalf("save character: %v", err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3145: {Status: "completed"},
},
}); err != nil {
t.Fatalf("save quest: %v", err)
}
source := catalogTestSource{
DefaultList: "3145 `pre.qst`\n3146 `parent.qst`\n3157 `hunt.qst`\n3054 `clear.qst`\n",
"n_quest/pre.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]",
"[type]\n`[clear map]`\n[int data]\n76126\n"),
"n_quest/parent.qst": questCatalogTestDefinition("[epic]", 3, 99, "[gunner]",
"[type]\n`[quest clear]`\n[pre required quest]\n3145\n[int data]\n3157 3054\n"),
"n_quest/hunt.qst": questCatalogTestDefinition("[sub]", 3, 99, "[gunner]",
"[type]\n`[hunt enemy]`\n[main quest]\n3146\n[int data]\n3 -1 13099 3 1\n"),
"n_quest/clear.qst": questCatalogTestDefinition("[sub]", 1, 99, "[gunner]",
"[type]\n`[clear map]`\n[main quest]\n3146\n[int data]\n76136\n"),
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyAccept(ctx, catalog, CharacterEligibility{Level: 3, Job: 2}, NewQuestIDCommand(alignedRequest("acc-quest", 19), "accept_quest", QuestIDRequest{QuestID: 3146}))
if err != nil {
t.Fatalf("ApplyAccept parent error = %v", err)
}
if result.QuestID != 3146 || result.InitTrigger != 2 || result.Idempotent {
t.Fatalf("parent accept result = %+v", result)
}
stored, ok, err := repos.Quest.Load(ctx, "19")
if err != nil || !ok {
t.Fatalf("load stored quest ok=%t err=%v", ok, err)
}
for _, check := range []struct {
id int64
trigger int64
linked bool
}{
{id: 3146, trigger: 2},
{id: 3157, trigger: 1, linked: true},
{id: 3054, trigger: 1, linked: true},
} {
state := stored.States[check.id]
if state.Status != "active" || state.ProgressValue != check.trigger {
t.Fatalf("quest %d state = %+v", check.id, state)
}
if check.linked && (state.Extra["main_quest_id"] != "3146" || state.Extra["auto_activated_by_main_quest"] != "true") {
t.Fatalf("quest %d linked extra = %+v", check.id, state.Extra)
}
}
}
func TestOwnerApplyActiveQuestClearLinkedSubQuestActivationRepairsExistingParent(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "acc-quest",
Job: "2",
Level: 3,
}); err != nil {
t.Fatalf("save character: %v", err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3146: {Status: "active", ProgressValue: 2},
},
}); err != nil {
t.Fatalf("save quest: %v", err)
}
source := catalogTestSource{
DefaultList: "3146 `parent.qst`\n3157 `hunt.qst`\n3054 `clear.qst`\n",
"n_quest/parent.qst": questCatalogTestDefinition("[epic]", 3, 99, "[gunner]",
"[type]\n`[quest clear]`\n[int data]\n3157 3054\n"),
"n_quest/hunt.qst": questCatalogTestDefinition("[sub]", 3, 99, "[gunner]",
"[type]\n`[hunt enemy]`\n[main quest]\n3146\n[int data]\n3 -1 13099 3 1\n"),
"n_quest/clear.qst": questCatalogTestDefinition("[sub]", 1, 99, "[gunner]",
"[type]\n`[clear map]`\n[main quest]\n3146\n[int data]\n76136\n"),
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
activatedAt := time.Date(2026, 7, 17, 14, 20, 0, 0, time.UTC)
result, err := owner.ApplyActiveQuestClearLinkedSubQuestActivation(ctx, catalog, "19", activatedAt)
if err != nil {
t.Fatalf("ApplyActiveQuestClearLinkedSubQuestActivation error = %v", err)
}
if result.Idempotent || len(result.Activations) != 2 || len(result.ChangedFields) != 1 || result.ChangedFields[0] != dnfrepo.QuestFieldStates {
t.Fatalf("activation result=%+v", result)
}
stored, ok, err := repos.Quest.Load(ctx, "19")
if err != nil || !ok {
t.Fatalf("load stored quest ok=%t err=%v", ok, err)
}
for _, questID := range []int64{3157, 3054} {
state := stored.States[questID]
if state.Status != "active" || state.ProgressValue != 1 ||
state.Extra["main_quest_id"] != "3146" ||
state.Extra["auto_activated_by_main_quest"] != "true" ||
state.Extra["auto_activation_reason"] != "active_quest_clear_parent_reconcile" {
t.Fatalf("linked quest %d state=%+v", questID, state)
}
}
replay, err := owner.ApplyActiveQuestClearLinkedSubQuestActivation(ctx, catalog, "19", activatedAt.Add(time.Second))
if err != nil {
t.Fatalf("replay activation error = %v", err)
}
if !replay.Idempotent || len(replay.Activations) != 0 || len(replay.ChangedFields) != 0 {
t.Fatalf("replay result=%+v", replay)
}
}
func TestOwnerApplyAcceptEventItemQuestRequiresAllocatorWithoutMutation(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "18", AccountID: "acc-quest"}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "18", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
catalog := testAcceptCatalog(t, "[type]\n`[clear map]`\n[int data]\n76126\n[depend give item]\n1001 1\n")
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := NewQuestIDCommand(alignedRequest("acc-quest", 18), "accept_quest", QuestIDRequest{QuestID: 3145})
if _, err := owner.ApplyAccept(ctx, catalog, CharacterEligibility{Level: 1, Job: 2}, cmd); !errors.Is(err, ErrAcceptItemAllocatorRequired) {
t.Fatalf("ApplyAccept error = %v", err)
}
if _, ok, err := repos.Quest.Load(ctx, "18"); err != nil || ok {
t.Fatalf("event-item rejection mutated quest record ok=%t err=%v", ok, err)
}
}
func TestOwnerApplyAcceptWithItemsAtomicallyCommitsQuestAndInventory(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "18", AccountID: "acc-quest"}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "18", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
catalog := testAcceptCatalog(t, "[type]\n`[clear map]`\n[int data]\n76126\n[depend give item]\n1001 2\n")
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
allocate := func(record *dnfrepo.InventoryRecord, request FinishItemGrantRequest) (FinishCommittedItem, error) {
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
const key = "0:65"
stack := record.Slots[key]
stack.ItemID = request.ItemID
stack.Count += request.Count
stack.RawEntry = []byte{1}
record.Slots[key] = stack
return FinishCommittedItem{
SlotKey: key, SlotIndex: 65, ItemID: request.ItemID,
Delta: request.Count, PostCount: stack.Count, CountOrSeed: uint32(request.Count), RawEntry: []byte{1},
}, nil
}
cmd := NewQuestIDCommand(alignedRequest("acc-quest", 18), "accept_quest", QuestIDRequest{QuestID: 3145})
result, err := owner.ApplyAcceptWithItems(ctx, catalog, CharacterEligibility{Level: 1, Job: 2}, cmd, allocate)
if err != nil {
t.Fatal(err)
}
if result.Idempotent || result.QuestID != 3145 || result.InitTrigger != 1 || len(result.EventItems) != 1 ||
result.EventItems[0].ItemID != 1001 || result.EventItems[0].Delta != 2 {
t.Fatalf("accept result=%+v", result)
}
questRecord, found, err := repos.Quest.Load(ctx, "18")
if err != nil || !found || questRecord.States[3145].Status != "active" {
t.Fatalf("quest record=%+v found=%t err=%v", questRecord, found, err)
}
inventory, found, err := repos.Inventory.Load(ctx, "18")
if err != nil || !found || inventory.Slots["0:65"].ItemID != 1001 || inventory.Slots["0:65"].Count != 2 {
t.Fatalf("inventory=%+v found=%t err=%v", inventory, found, err)
}
replay, err := owner.ApplyAcceptWithItems(ctx, catalog, CharacterEligibility{Level: 1, Job: 2}, cmd, allocate)
if err != nil || !replay.Idempotent || len(replay.EventItems) != 0 {
t.Fatalf("idempotent replay=%+v err=%v", replay, err)
}
inventory, _, _ = repos.Inventory.Load(ctx, "18")
if inventory.Slots["0:65"].Count != 2 {
t.Fatalf("idempotent replay granted twice: %+v", inventory.Slots)
}
}
func TestOwnerApplyAcceptWithItemsRollsBackBothAggregates(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "18", AccountID: "acc-quest"}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "18", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
catalog := testAcceptCatalog(t, "[type]\n`[clear map]`\n[int data]\n76126\n[depend give item]\n1001 1 1002 1\n")
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
forced := errors.New("forced second event-item allocation failure")
allocate := func(record *dnfrepo.InventoryRecord, request FinishItemGrantRequest) (FinishCommittedItem, error) {
if request.ItemID == 1002 {
return FinishCommittedItem{}, forced
}
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
record.Slots["0:65"] = dnfrepo.ItemStack{ItemID: request.ItemID, Count: request.Count}
return FinishCommittedItem{SlotKey: "0:65", SlotIndex: 65, ItemID: request.ItemID, Delta: request.Count, PostCount: request.Count, CountOrSeed: uint32(request.Count), RawEntry: []byte{1}}, nil
}
cmd := NewQuestIDCommand(alignedRequest("acc-quest", 18), "accept_quest", QuestIDRequest{QuestID: 3145})
if _, err := owner.ApplyAcceptWithItems(ctx, catalog, CharacterEligibility{Level: 1, Job: 2}, cmd, allocate); !errors.Is(err, forced) {
t.Fatalf("ApplyAcceptWithItems error=%v", err)
}
if _, found, err := repos.Quest.Load(ctx, "18"); err != nil || found {
t.Fatalf("rolled-back quest found=%t err=%v", found, err)
}
inventory, found, err := repos.Inventory.Load(ctx, "18")
if err != nil || !found || len(inventory.Slots) != 0 {
t.Fatalf("rolled-back inventory=%+v found=%t err=%v", inventory, found, err)
}
}
func TestOwnerApplyGiveUpPersistsActiveQuestRemoval(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "18", AccountID: "acc-quest"}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "18",
States: map[int64]dnfrepo.QuestState{
3144: {Status: "completed", ProgressValue: 1},
3145: {Status: "active", ProgressValue: 7},
3146: {Status: "active", ProgressValue: 2},
},
Progress: map[int64]dnfrepo.QuestState{
3145: {Status: "active", ProgressValue: 7},
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyGiveUp(
ctx,
testAcceptCatalog(t, "[type]\n`[disjoint item]`\n[int data]\n1\n"),
NewQuestIDCommand(alignedRequest("acc-quest", 18), "giveup_quest", QuestIDRequest{QuestID: 3145}),
)
if err != nil {
t.Fatalf("ApplyGiveUp() error = %v", err)
}
if result.AccountID != "acc-quest" || result.CharacterID != "18" || result.QuestID != 3145 {
t.Fatalf("ApplyGiveUp() result = %+v", result)
}
stored, ok, err := repos.Quest.Load(ctx, "18")
if err != nil || !ok {
t.Fatalf("load stored quest ok=%t err=%v", ok, err)
}
if _, exists := stored.States[3145]; exists {
t.Fatalf("abandoned quest remains in states: %+v", stored.States[3145])
}
if _, exists := stored.Progress[3145]; exists {
t.Fatalf("abandoned quest remains in progress: %+v", stored.Progress[3145])
}
if stored.States[3144].Status != "completed" || stored.States[3146].Status != "active" {
t.Fatalf("unrelated quest states changed: %+v", stored.States)
}
if _, err := owner.ApplyGiveUp(
ctx,
testAcceptCatalog(t, "[type]\n`[disjoint item]`\n[int data]\n1\n"),
NewQuestIDCommand(alignedRequest("acc-quest", 18), "giveup_quest", QuestIDRequest{QuestID: 3145}),
); !errors.Is(err, ErrQuestNotActive) {
t.Fatalf("ApplyGiveUp() replay error = %v, want %v", err, ErrQuestNotActive)
}
}
// 放弃任务的判据从这里搬到了 give_up_reclaim_test.go:
// `[cant giveup]` 仍然是唯一的拒绝理由,收集类任务改为"允许放弃 + 回收任务专属道具"。
func testAcceptCatalog(t *testing.T, extra string) *Catalog {
t.Helper()
source := catalogTestSource{
DefaultList: "3145 `accept.qst`\n",
"n_quest/accept.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]", extra),
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
return catalog
}
func alignedRequest(accountID string, selectedCharacterID uint16) alignedcmd.Request {
return alignedcmd.Request{AccountID: accountID, SelectedCharacterID: selectedCharacterID}
}