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