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,617 @@
package quest
import (
"context"
"reflect"
"testing"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestPlanClearMapCompletionUsesPVFTargetAndPreservesPendingReward(t *testing.T) {
catalog := clearMapTestCatalog(t)
completedAt := time.Date(2026, 7, 15, 20, 30, 0, 0, time.UTC)
record := dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3145: {Status: "active", ProgressValue: 1, Extra: map[string]string{"kept": "yes"}},
3146: {Status: "active", ProgressValue: 7},
},
}
plan, err := catalog.PlanClearMapCompletion(record, ClearMapCompletionInput{
DungeonID: 3, MapID: 76126, CompletionKey: "run-17/op117/430", CompletedAt: completedAt,
})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 3145 || plan.Completions[0].PreviousTrigger != 1 || plan.Completions[0].Idempotent {
t.Fatalf("completions = %+v", plan.Completions)
}
if !reflect.DeepEqual(plan.ChangedFields, []dnfrepo.QuestField{dnfrepo.QuestFieldStates}) {
t.Fatalf("changed fields = %v", plan.ChangedFields)
}
state := plan.Record.States[3145]
if state.Status != "active" || state.ProgressValue != 0 || state.Extra["reward_state"] != "pending" || state.Extra["completion_key"] != "run-17/op117/430" || state.Extra["kept"] != "yes" {
t.Fatalf("completed state = %+v", state)
}
if other := plan.Record.States[3146]; other.ProgressValue != 7 {
t.Fatalf("unrelated quest changed = %+v", other)
}
if original := record.States[3145]; original.ProgressValue != 1 || original.Extra["reward_state"] != "" {
t.Fatalf("input record mutated = %+v", original)
}
}
func TestPlanClearMapCompletionReplayIsIdempotent(t *testing.T) {
catalog := clearMapTestCatalog(t)
completedAt := time.Date(2026, 7, 15, 20, 30, 0, 0, time.UTC)
input := ClearMapCompletionInput{MapID: 76126, CompletionKey: "run-17/op117/430", CompletedAt: completedAt}
first, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{3145: {Status: "active", ProgressValue: 1}},
}, input)
if err != nil {
t.Fatal(err)
}
second, err := catalog.PlanClearMapCompletion(first.Record, input)
if err != nil {
t.Fatal(err)
}
if len(second.Completions) != 1 || !second.Completions[0].Idempotent || len(second.ChangedFields) != 0 {
t.Fatalf("replay plan = %+v", second)
}
if !reflect.DeepEqual(first.Record, second.Record) {
t.Fatalf("replay changed record\nfirst=%+v\nsecond=%+v", first.Record, second.Record)
}
}
func TestPlanClearMapCompletionMatchesDungeonIDAndProgressField(t *testing.T) {
catalog := clearMapTestCatalog(t)
plan, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
Progress: map[int64]dnfrepo.QuestState{3147: {Status: "accepted", ProgressValue: 2}},
}, ClearMapCompletionInput{DungeonID: 3, CompletionKey: "run-dungeon-target", CompletedAt: time.Now()})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].Field != dnfrepo.QuestFieldProgress || plan.Record.Progress[3147].ProgressValue != 0 || !reflect.DeepEqual(plan.ChangedFields, []dnfrepo.QuestField{dnfrepo.QuestFieldProgress}) {
t.Fatalf("plan = %+v", plan)
}
}
func TestPlanConditionUnderClearSubtype6DecrementsPackedTarget(t *testing.T) {
source := catalogTestSource{
DefaultList: "500 `ordinary.qst`\n",
"n_quest/ordinary.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]",
"[type]\n`[condition under clear]`\n[sub type]\n6\n[int data]\n144 1 2\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)
}
plan, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{500: {Status: "active", ProgressValue: 2}},
}, ClearMapCompletionInput{
DungeonID: 144, Difficulty: 1, CompletionKey: "ordinary/run-1", CompletedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 {
t.Fatalf("ordinary completion count=%d plan=%+v", len(plan.Completions), plan)
}
completion := plan.Completions[0]
if completion.NewTrigger != 1 || completion.Completed || plan.Record.States[500].ProgressValue != 1 || plan.Record.States[500].Extra["reward_state"] != "" {
t.Fatalf("ordinary partial plan = %+v", plan)
}
}
// 婚约任务 20737 的真实 PVF:[type] `[condition under clear]` + [sub type] 22 +
// [int data] `-1 -1 5`,即"跟心上人组 2 人队通关普通地下城 5 次"。
//
// 这个用例钉住三件事:
// 1. 初始 trigger 是 5(不是 1)—— subtype 22 没有自己的分支时会落到 stride-4 的
// default 解析,算出 1,于是"一次通关就完成";
// 2. 每次"夫妻二人通关"只减 1,减到 0 才算完成;
// 3. 不是这对夫妻通关时完全不匹配(-1 -1 之外还有 WeddingCouple 这道门)。
func TestPlanWeddingConditionUnderClearSubtype22CountsFiveCoupleClears(t *testing.T) {
const questID = 20737
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(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)
}
definition, found := catalog.Find(questID)
if !found {
t.Fatal("wedding quest not in catalog")
}
if got := definitionInitialTrigger(definition, nil); got != 5 {
t.Fatalf("婚约任务初始 trigger=%d,应为 5(5 次通关)", got)
}
// 第 1 次夫妻通关:5 -> 4,且不能算完成。
first, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{questID: {Status: "active", ProgressValue: 5}},
}, ClearMapCompletionInput{
WeddingCouple: true, DungeonID: 61, Difficulty: 2,
CompletionKey: "wedding/run-1", CompletedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if len(first.Completions) != 1 {
t.Fatalf("夫妻通关未匹配到婚约任务: %+v", first)
}
if got := first.Completions[0]; got.NewTrigger != 4 || got.Completed {
t.Fatalf("第 1 次通关 NewTrigger=%d completed=%v,应为 4/false", got.NewTrigger, got.Completed)
}
if stored := first.Record.States[questID].ProgressValue; stored != 4 {
t.Fatalf("持久化进度=%d,应为 4", stored)
}
// 第 5 次:1 -> 0,此时才算完成。
last, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{questID: {Status: "active", ProgressValue: 1}},
}, ClearMapCompletionInput{
WeddingCouple: true, DungeonID: 61, Difficulty: 2,
CompletionKey: "wedding/run-5", CompletedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if len(last.Completions) != 1 || !last.Completions[0].Completed || last.Completions[0].NewTrigger != 0 {
t.Fatalf("第 5 次通关应当完成: %+v", last.Completions)
}
// 同样一次通关,但队伍不是这对夫妻 -> 不该匹配。
notCouple, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{questID: {Status: "active", ProgressValue: 5}},
}, ClearMapCompletionInput{
WeddingCouple: false, DungeonID: 61, Difficulty: 2,
CompletionKey: "wedding/run-solo", CompletedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if len(notCouple.Completions) != 0 {
t.Fatalf("非夫妻通关不应计入婚约任务: %+v", notCouple.Completions)
}
}
func TestConditionUnderClearPartialNoRewardSubQuestStaysActive(t *testing.T) {
source := catalogTestSource{
DefaultList: "503 `partial_sub.qst`\n",
"n_quest/partial_sub.qst": questCatalogTestDefinition("[sub]", 1, 99, "[all]",
"[type]\n`[condition under clear]`\n[sub type]\n6\n[int data]\n144 -1 2\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)
}
plan, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{503: {Status: "active", ProgressValue: 2}},
}, ClearMapCompletionInput{
DungeonID: 144, Difficulty: 0, CompletionKey: "partial-sub/run-1", CompletedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
state := plan.Record.States[503]
if len(plan.Completions) != 1 || plan.Completions[0].Completed || state.Status != "active" || state.ProgressValue != 1 || state.Extra["reward_state"] != "" {
t.Fatalf("partial no-reward subquest plan = %+v", plan)
}
}
func TestConditionUnderClearSubtype6LegacyTwoFieldInitializesAndCompletesOnce(t *testing.T) {
source := catalogTestSource{
DefaultList: "501 `legacy.qst`\n",
"n_quest/legacy.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]",
"[type]\n`[condition under clear]`\n[sub type]\n6\n[int data]\n144 -1\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)
}
definition, found := catalog.Find(501)
if !found || definitionInitialTrigger(definition, nil) != 1 {
t.Fatalf("legacy definition=%+v found=%t trigger=%d", definition, found, definitionInitialTrigger(definition, nil))
}
plan, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19", States: map[int64]dnfrepo.QuestState{501: {Status: "active", ProgressValue: 1}},
}, ClearMapCompletionInput{DungeonID: 144, Difficulty: 0, CompletionKey: "legacy/run-1", CompletedAt: time.Now()})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || !plan.Completions[0].Completed || plan.Record.States[501].ProgressValue != 0 {
t.Fatalf("legacy plan = %+v", plan)
}
}
func TestClearADungeonSubtype6CompletesAnyAuthoredAlternative(t *testing.T) {
source := catalogTestSource{
DefaultList: "1680 `blackhall/epic_70_blackhole_01.qst`\n",
"n_quest/blackhall/epic_70_blackhole_01.qst": questCatalogTestDefinition("[side]", 70, 99, "[all]",
"[type]\n`[clear a dungeon]`\n[sub type]\n6\n[int data]\n7165 -1 7166 -1 7179 -1\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)
}
definition, found := catalog.Find(1680)
if !found || definitionInitialTrigger(definition, nil) != 1 {
t.Fatalf("clear-a-dungeon definition=%+v found=%t trigger=%d", definition, found, definitionInitialTrigger(definition, nil))
}
for _, dungeonID := range []int64{7165, 7179} {
plan, planErr := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{1680: {Status: "active", ProgressValue: 1}},
}, ClearMapCompletionInput{
DungeonID: dungeonID, Difficulty: 0,
CompletionKey: "blackhall/run", CompletedAt: time.Now(),
})
if planErr != nil {
t.Fatal(planErr)
}
state := plan.Record.States[1680]
if len(plan.Completions) != 1 || !plan.Completions[0].Completed || state.ProgressValue != 0 || state.Extra["reward_state"] != "pending" {
t.Fatalf("dungeon %d clear-a-dungeon plan = %+v", dungeonID, plan)
}
}
miss, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{1680: {Status: "active", ProgressValue: 1}},
}, ClearMapCompletionInput{DungeonID: 7180, Difficulty: 0, CompletionKey: "blackhall/miss", CompletedAt: time.Now()})
if err != nil {
t.Fatal(err)
}
if len(miss.Completions) != 0 || miss.Record.States[1680].ProgressValue != 1 {
t.Fatalf("unlisted dungeon changed clear-a-dungeon quest = %+v", miss)
}
}
func TestConditionUnderClearSubtype11UsesOneTriggerAndRequiresAllRooms(t *testing.T) {
source := catalogTestSource{
DefaultList: "502 `all_rooms.qst`\n",
"n_quest/all_rooms.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]",
"[type]\n`[condition under clear]`\n[sub type]\n11\n[int data]\n85 -1 8\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)
}
definition, found := catalog.Find(502)
if !found || definitionInitialTrigger(definition, nil) != 1 {
t.Fatalf("all-room definition=%+v found=%t trigger=%d", definition, found, definitionInitialTrigger(definition, nil))
}
base := dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{502: {Status: "active", ProgressValue: 1}}}
input := ClearMapCompletionInput{DungeonID: 85, Difficulty: 0, CompletionKey: "all-rooms/run-1", CompletedAt: time.Now()}
miss, err := catalog.PlanClearMapCompletion(base, input)
if err != nil {
t.Fatal(err)
}
if len(miss.Completions) != 0 {
t.Fatalf("incomplete all-room plan = %+v", miss)
}
input.AllRoomsCleared = true
hit, err := catalog.PlanClearMapCompletion(base, input)
if err != nil {
t.Fatal(err)
}
if len(hit.Completions) != 1 || !hit.Completions[0].Completed || hit.Record.States[502].ProgressValue != 0 {
t.Fatalf("complete all-room plan = %+v", hit)
}
}
func TestPlanConditionUnderClearSubtype23RequiresHellAndRegion(t *testing.T) {
catalog := conditionUnderClearHellPartyTestCatalog(t)
base := dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{5782: {Status: "active", ProgressValue: 1}}}
input := ClearMapCompletionInput{
DungeonID: 86, MapID: 20317, WorldMapID: 15, Difficulty: 0, HellPartyComplete: true,
CompletionKey: "hell-party/train/run-1", CompletedAt: time.Now(),
}
plan, err := catalog.PlanClearMapCompletion(base, input)
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || !plan.Completions[0].Completed || plan.Record.States[5782].ProgressValue != 0 {
t.Fatalf("hell-party plan = %+v", plan)
}
for _, mutate := range []func(*ClearMapCompletionInput){
func(value *ClearMapCompletionInput) { value.HellPartyComplete = false },
func(value *ClearMapCompletionInput) { value.WorldMapID = 17 },
} {
candidate := input
mutate(&candidate)
rejected, rejectErr := catalog.PlanClearMapCompletion(base, candidate)
if rejectErr != nil {
t.Fatal(rejectErr)
}
if len(rejected.Completions) != 0 || rejected.Record.States[5782].ProgressValue != 1 {
t.Fatalf("rejected hell-party plan = %+v", rejected)
}
}
}
func TestPlanConditionUnderClearSubtype23DecrementsRequiredCount(t *testing.T) {
source := catalogTestSource{
DefaultList: "5783 `train_twice.qst`\n",
"n_quest/train_twice.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]",
"[type]\n`[condition under clear]`\n[sub type]\n23\n[int data]\n-2 15 -1 2\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)
}
definition, found := catalog.Find(5783)
if !found || definitionInitialTrigger(definition, nil) != 2 {
t.Fatalf("regional hell definition=%+v found=%t trigger=%d", definition, found, definitionInitialTrigger(definition, nil))
}
plan, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19", States: map[int64]dnfrepo.QuestState{5783: {Status: "active", ProgressValue: 2}},
}, ClearMapCompletionInput{DungeonID: 86, WorldMapID: 15, Difficulty: 0, HellPartyComplete: true, CompletionKey: "hell/run-1", CompletedAt: time.Now()})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].Completed || plan.Completions[0].NewTrigger != 1 || plan.Record.States[5783].ProgressValue != 1 {
t.Fatalf("regional hell partial plan = %+v", plan)
}
replay, err := catalog.PlanClearMapCompletion(plan.Record, ClearMapCompletionInput{
DungeonID: 86, WorldMapID: 15, Difficulty: 0, HellPartyComplete: true,
CompletionKey: "hell/run-1", CompletedAt: time.Now().Add(time.Second),
})
if err != nil {
t.Fatal(err)
}
if len(replay.Completions) != 1 || !replay.Completions[0].Idempotent || replay.Completions[0].NewTrigger != 1 || len(replay.ChangedFields) != 0 || replay.Record.States[5783].ProgressValue != 1 {
t.Fatalf("regional hell replay plan = %+v", replay)
}
next, err := catalog.PlanClearMapCompletion(plan.Record, ClearMapCompletionInput{
DungeonID: 86, WorldMapID: 15, Difficulty: 0, HellPartyComplete: true,
CompletionKey: "hell/run-2", CompletedAt: time.Now().Add(2 * time.Second),
})
if err != nil {
t.Fatal(err)
}
if len(next.Completions) != 1 || !next.Completions[0].Completed || next.Completions[0].NewTrigger != 0 || next.Record.States[5783].ProgressValue != 0 {
t.Fatalf("regional hell next-run plan = %+v", next)
}
}
func conditionUnderClearHellPartyTestCatalog(t *testing.T) *Catalog {
t.Helper()
source := catalogTestSource{
DefaultList: "5782 `train.qst`\n",
"n_quest/train.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]",
"[type]\n`[condition under clear]`\n[sub type]\n23\n[int data]\n-2 15 -1 1\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)
}
return catalog
}
func TestPlanClearMapCompletionRetainsFinalLinkedChildForNativeParentFinish(t *testing.T) {
source := catalogTestSource{
DefaultList: "3054 `clear_sub.qst`\n3146 `parent.qst`\n",
"n_quest/clear_sub.qst": questCatalogTestDefinition("[sub]", 1, 99, "[all]",
"[type]\n`[clear map]`\n[main quest]\n3146\n[int data]\n76136\n"),
"n_quest/parent.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]",
"[type]\n`[quest clear]`\n[int data]\n3157 3054\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)
}
completedAt := time.Date(2026, 7, 17, 12, 30, 0, 0, time.UTC)
plan, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3054: {Status: "active", ProgressValue: 1},
3157: {Status: "completed", ProgressValue: 0},
3146: {Status: "active", ProgressValue: 1},
},
}, ClearMapCompletionInput{MapID: 76136, CompletionKey: "run-3146/3054", CompletedAt: completedAt})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 3054 {
t.Fatalf("completions=%+v", plan.Completions)
}
child := plan.Record.States[3054]
if child.Status != "active" || child.ProgressValue != 0 ||
child.Extra["reward_state"] != "pending" ||
child.Extra["completion_key"] != "run-3146/3054" ||
child.Extra["auto_completed"] != "" {
t.Fatalf("terminal linked child=%+v", child)
}
parent := plan.Record.States[3146]
if parent.Status != "active" || parent.ProgressValue != 1 {
t.Fatalf("quest-clear parent=%+v", parent)
}
if !reflect.DeepEqual(plan.ChangedFields, []dnfrepo.QuestField{dnfrepo.QuestFieldStates}) {
t.Fatalf("changed fields=%v", plan.ChangedFields)
}
}
func TestPlanClearMapCompletionDerivesMissingNoRewardSubFromActiveQuestClearParent(t *testing.T) {
source := catalogTestSource{
DefaultList: "3054 `clear_sub.qst`\n3146 `parent.qst`\n",
"n_quest/clear_sub.qst": questCatalogTestDefinition("[sub]", 1, 99, "[all]",
"[type]\n`[clear map]`\n[main quest]\n3146\n[int data]\n76136\n"),
"n_quest/parent.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]",
"[type]\n`[quest clear]`\n[int data]\n3157 3054\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)
}
completedAt := time.Date(2026, 7, 17, 13, 20, 0, 0, time.UTC)
plan, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3157: {Status: "completed", ProgressValue: 0},
3146: {Status: "active", ProgressValue: 2},
},
}, ClearMapCompletionInput{MapID: 76136, CompletionKey: "run-3146/stale-parent-only/3054", CompletedAt: completedAt})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 3054 || plan.Completions[0].PreviousTrigger != 1 {
t.Fatalf("completions=%+v", plan.Completions)
}
child := plan.Record.States[3054]
if child.Status != "active" || child.ProgressValue != 0 ||
child.Extra["reward_state"] != "pending" ||
child.Extra["main_quest_id"] != "3146" ||
child.Extra["auto_activated_by_main_quest"] != "true" ||
child.Extra["completion_key"] != "run-3146/stale-parent-only/3054" ||
child.Extra["auto_completed"] != "" {
t.Fatalf("derived child=%+v", child)
}
if parent := plan.Record.States[3146]; parent.Status != "active" || parent.ProgressValue != 1 {
t.Fatalf("quest-clear parent=%+v", parent)
}
if !reflect.DeepEqual(plan.ChangedFields, []dnfrepo.QuestField{dnfrepo.QuestFieldStates}) {
t.Fatalf("changed fields=%v", plan.ChangedFields)
}
}
func TestPlanClearMapCompletionReconcilesAllMissingNoRewardSubTasksBeforeCompletion(t *testing.T) {
source := catalogTestSource{
DefaultList: "3054 `clear_sub.qst`\n3157 `hunt_sub.qst`\n3146 `parent.qst`\n",
"n_quest/clear_sub.qst": questCatalogTestDefinition("[sub]", 1, 99, "[all]",
"[type]\n`[clear map]`\n[main quest]\n3146\n[int data]\n76136\n"),
"n_quest/hunt_sub.qst": questCatalogTestDefinition("[sub]", 1, 99, "[all]",
"[type]\n`[hunt enemy]`\n[main quest]\n3146\n[int data]\n3 -1 13099 3 1\n"),
"n_quest/parent.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]",
"[type]\n`[quest clear]`\n[int data]\n3157 3054\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)
}
completedAt := time.Date(2026, 7, 17, 14, 5, 0, 0, time.UTC)
plan, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3146: {Status: "active", ProgressValue: 2},
},
}, ClearMapCompletionInput{MapID: 76136, CompletionKey: "run-3146/only-parent/3054", CompletedAt: completedAt})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 3054 {
t.Fatalf("completions=%+v", plan.Completions)
}
clearChild := plan.Record.States[3054]
if clearChild.Status != "completed" || clearChild.ProgressValue != 0 ||
clearChild.Extra["auto_activated_by_main_quest"] != "true" ||
clearChild.Extra["completion_key"] != "run-3146/only-parent/3054" {
t.Fatalf("clear child=%+v", clearChild)
}
huntChild := plan.Record.States[3157]
if huntChild.Status != "active" || huntChild.ProgressValue != 1 ||
huntChild.Extra["auto_activated_by_main_quest"] != "true" ||
huntChild.Extra["auto_activation_reason"] != "active_quest_clear_parent_reconcile" {
t.Fatalf("hunt child=%+v", huntChild)
}
if parent := plan.Record.States[3146]; parent.Status != "active" || parent.ProgressValue != 1 {
t.Fatalf("quest-clear parent=%+v", parent)
}
if !reflect.DeepEqual(plan.ChangedFields, []dnfrepo.QuestField{dnfrepo.QuestFieldStates}) {
t.Fatalf("changed fields=%v", plan.ChangedFields)
}
}
func TestPlanClearMapCompletionRejectsNegativeTriggerAsCompletion(t *testing.T) {
catalog := clearMapTestCatalog(t)
record := dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{3145: {Status: "active", ProgressValue: -1}},
}
plan, err := catalog.PlanClearMapCompletion(record, ClearMapCompletionInput{
MapID: 76126, CompletionKey: "corrupt-trigger", CompletedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 0 || len(plan.ChangedFields) != 0 || plan.Record.States[3145].ProgressValue != -1 {
t.Fatalf("negative trigger plan = %+v", plan)
}
}
func clearMapTestCatalog(t *testing.T) *Catalog {
t.Helper()
source := catalogTestSource{
DefaultList: "3145 `clear_map.qst`\n3146 `other.qst`\n3147 `dungeon.qst`\n",
"n_quest/clear_map.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]", "[type]\n`[clear map]`\n[int data]\n76126\n"),
"n_quest/other.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]", "[type]\n`[clear map]`\n[int data]\n99999\n"),
"n_quest/dungeon.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]", "[type]\n`[clear map]`\n[int data]\n3\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)
}
return catalog
}