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,91 @@
package quest
import (
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
// AntonRaidAccessPrerequisiteQuestID is the ordinary Anton raid channel
// unlock quest. Once it is complete the legacy five-day mission below is
// no longer useful on the local single-player runtime.
AntonRaidAccessPrerequisiteQuestID int64 = 12829
// AntonRaidMissionQuestID is the legacy "[Anton Raid] Voracious Giant"
// team mission whose completion the 90CN client requires before it opens
// the Luke raid channel quest chain.
AntonRaidMissionQuestID int64 = 12830
)
// DefaultCompletionPlan records the bounded quest-state mutation used to
// retire a legacy progression requirement without granting its old selectable
// equipment reward.
type DefaultCompletionPlan struct {
Record dnfrepo.QuestRecord
QuestID int64
ChangedFields []dnfrepo.QuestField
}
// PlanAntonRaidMissionDefaultCompletion completes the legacy
// "[Anton Raid] Voracious Giant" mission once the character has unlocked the
// Anton raid channel. The 90CN client otherwise requires this five-day team
// mission before opening the Luke raid channel, while the local runtime offers
// a single-player Anton flow and does not own the original daily team counter.
//
// The migration is deliberately idempotent and does not grant the mission's
// selectable equipment reward.
func PlanAntonRaidMissionDefaultCompletion(
record dnfrepo.QuestRecord,
completedAt time.Time,
) DefaultCompletionPlan {
if !questCompletedInRecord(record, AntonRaidAccessPrerequisiteQuestID) ||
questCompletedInStates(record.States, AntonRaidMissionQuestID) {
return DefaultCompletionPlan{Record: record, QuestID: AntonRaidMissionQuestID}
}
completed := dnfrepo.CloneQuest(record)
if completed.States == nil {
completed.States = make(map[int64]dnfrepo.QuestState)
}
state := completed.States[AntonRaidMissionQuestID]
if completedAt.IsZero() {
completedAt = time.Now().UTC()
} else {
completedAt = completedAt.UTC()
}
if state.Extra == nil {
state.Extra = make(map[string]string)
}
state.Status = "completed"
state.TriggerType = 0
state.ProgressValue = 0
state.RewardSelectIndex = int64(^uint16(0))
state.Multiplier = 1
state.UpdatedAt = completedAt
state.Extra["auto_completed"] = "true"
state.Extra["auto_complete_reason"] = "local_single_player_raid_progression"
state.Extra["auto_completed_after_quest"] = "12829"
state.Extra["reward_state"] = "skipped_default_completion"
completed.States[AntonRaidMissionQuestID] = state
changedFields := []dnfrepo.QuestField{dnfrepo.QuestFieldStates}
if _, exists := completed.Progress[AntonRaidMissionQuestID]; exists {
delete(completed.Progress, AntonRaidMissionQuestID)
changedFields = append(changedFields, dnfrepo.QuestFieldProgress)
}
completed.UpdatedAt = completedAt
return DefaultCompletionPlan{
Record: completed,
QuestID: AntonRaidMissionQuestID,
ChangedFields: changedFields,
}
}
func questCompletedInRecord(record dnfrepo.QuestRecord, questID int64) bool {
return questCompletedInStates(record.States, questID) || questCompletedInStates(record.Progress, questID)
}
func questCompletedInStates(states map[int64]dnfrepo.QuestState, questID int64) bool {
state, exists := states[questID]
return exists && isCompletedQuestStatus(state.Status)
}
@@ -0,0 +1,72 @@
package quest
import (
"reflect"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestPlanAntonRaidMissionDefaultCompletionRequiresAntonAccessQuest(t *testing.T) {
record := dnfrepo.QuestRecord{
CharacterID: "3",
States: map[int64]dnfrepo.QuestState{
2903: {Status: "completed"},
},
}
plan := PlanAntonRaidMissionDefaultCompletion(record, time.Now())
if len(plan.ChangedFields) != 0 || !reflect.DeepEqual(plan.Record, record) {
t.Fatalf("unexpected default completion without prerequisite: %+v", plan)
}
}
func TestPlanAntonRaidMissionDefaultCompletionAddsCompletedStateWithoutReward(t *testing.T) {
completedAt := time.Date(2026, 8, 26, 4, 30, 0, 0, time.FixedZone("CST", 8*60*60))
record := dnfrepo.QuestRecord{
CharacterID: "3",
States: map[int64]dnfrepo.QuestState{
AntonRaidAccessPrerequisiteQuestID: {Status: "completed"},
},
}
plan := PlanAntonRaidMissionDefaultCompletion(record, completedAt)
if !reflect.DeepEqual(plan.ChangedFields, []dnfrepo.QuestField{dnfrepo.QuestFieldStates}) {
t.Fatalf("changed fields=%v", plan.ChangedFields)
}
state, exists := plan.Record.States[AntonRaidMissionQuestID]
if !exists || state.Status != "completed" || state.TriggerType != 0 || state.ProgressValue != 0 ||
state.RewardSelectIndex != int64(^uint16(0)) || state.Multiplier != 1 ||
state.Extra["reward_state"] != "skipped_default_completion" ||
state.Extra["auto_complete_reason"] != "local_single_player_raid_progression" ||
!state.UpdatedAt.Equal(completedAt.UTC()) {
t.Fatalf("completed mission state=%+v", state)
}
if _, mutated := record.States[AntonRaidMissionQuestID]; mutated {
t.Fatal("planning mutated the caller record")
}
}
func TestPlanAntonRaidMissionDefaultCompletionRepairsActiveProjectionAndIsIdempotent(t *testing.T) {
completedAt := time.Date(2026, 8, 26, 5, 0, 0, 0, time.UTC)
record := dnfrepo.QuestRecord{
CharacterID: "3",
States: map[int64]dnfrepo.QuestState{
AntonRaidAccessPrerequisiteQuestID: {Status: "completed"},
AntonRaidMissionQuestID: {Status: "active", ProgressValue: 7},
},
Progress: map[int64]dnfrepo.QuestState{
AntonRaidMissionQuestID: {Status: "active", ProgressValue: 7},
},
}
plan := PlanAntonRaidMissionDefaultCompletion(record, completedAt)
if !reflect.DeepEqual(plan.ChangedFields, []dnfrepo.QuestField{dnfrepo.QuestFieldStates, dnfrepo.QuestFieldProgress}) {
t.Fatalf("changed fields=%v", plan.ChangedFields)
}
if _, exists := plan.Record.Progress[AntonRaidMissionQuestID]; exists {
t.Fatalf("active progress remained: %+v", plan.Record.Progress)
}
idempotent := PlanAntonRaidMissionDefaultCompletion(plan.Record, completedAt.Add(time.Hour))
if len(idempotent.ChangedFields) != 0 || !reflect.DeepEqual(idempotent.Record, plan.Record) {
t.Fatalf("second plan was not idempotent: %+v", idempotent)
}
}
@@ -0,0 +1,986 @@
package quest
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/jobmap"
"longheng.io/server/internal/modules/dnf/profession"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrAutoCompleteMainUnavailable = errors.New("main quest auto-complete is unavailable")
ErrAutoCompleteLevelInvalid = errors.New("main quest auto-complete level is invalid")
ErrAutoCompleteTargetInvalid = errors.New("main quest auto-complete target is invalid")
ErrAutoCompleteTargetAmbiguous = errors.New("main quest auto-complete target is ambiguous")
ErrAutoCompleteClassRequired = errors.New("main quest auto-complete requires class change")
// ErrAutoCompleteAwakeningRequired reports that the story chain is standing
// on an awakening quest. A bulk pass never completes one - that run belongs
// to the player - so an empty result there is not "nothing left to do".
ErrAutoCompleteAwakeningRequired = errors.New("main quest auto-complete is blocked by an awakening quest")
// ErrAutoCompleteChoiceRequired reports that the chain is standing on a
// [question] branch quest. The answer decides which successors unlock, so
// it is the player's to give; the pass must not pick one.
ErrAutoCompleteChoiceRequired = errors.New("main quest auto-complete is blocked by a branch choice quest")
)
// AwakeningRequiredError names the blocking awakening quest so the caller can
// tell the player which one to run instead of reporting an empty result.
type AwakeningRequiredError struct {
QuestID int64
QuestName string
}
func (e *AwakeningRequiredError) Error() string {
return fmt.Sprintf("%s: quest=%d name=%q", ErrAutoCompleteAwakeningRequired, e.QuestID, e.QuestName)
}
func (e *AwakeningRequiredError) Is(target error) bool {
return target == ErrAutoCompleteAwakeningRequired
}
// ChoiceRequiredError names the branch quest waiting for the player's answer.
type ChoiceRequiredError struct {
QuestID int64
QuestName string
}
func (e *ChoiceRequiredError) Error() string {
return fmt.Sprintf("%s: quest=%d name=%q", ErrAutoCompleteChoiceRequired, e.QuestID, e.QuestName)
}
func (e *ChoiceRequiredError) Is(target error) bool {
return target == ErrAutoCompleteChoiceRequired
}
// The story chain above this level is gated on the first awakening quest group,
// and that group is per first grow type. A character that never took the class
// change therefore has exactly one legal next step - the awakening quest the
// bulk pass must not touch - so an empty bulk result there is reported as the
// missing class change rather than as an unexplained empty candidate set.
const autoCompleteMainFirstAwakeningLevel = 50
type AutoCompleteMainInput struct {
CharacterID string
Eligibility CharacterEligibility
CutoffLevel int
TargetQuestID int64
CompletedAt time.Time
// AllocateItem places carry-forward story items (see auto_complete_items.go)
// into the main inventory inside the same settlement transaction. It is only
// consulted when an outstanding consumer actually lacks such an item.
AllocateItem FinishItemAllocator
}
type AutoCompleteMainPlan struct {
Record dnfrepo.QuestRecord
EligibleQuestIDs []int64
ChangedQuestIDs []int64
ChangedLinkedSubIDs []int64
ActiveCompletedCount int
HighestCompletedQuest int64
ChangedFields []dnfrepo.QuestField
}
type AutoCompleteMainResult struct {
CharacterID string
CutoffLevel int
EligibleQuestIDs []int64
ChangedQuestIDs []int64
ChangedLinkedSubIDs []int64
ActiveCompletedCount int
HighestCompletedQuest int64
PostCommitQuest dnfrepo.QuestRecord
// PostCommitInventory and CarryForwardItems describe the story items the
// completion had to carry forward so a later epic can still be turned in.
// They are empty for the ordinary case where nothing outstanding needs one.
PostCommitInventory dnfrepo.InventoryRecord
CarryForwardItems []FinishCommittedItem
Idempotent bool
}
type AutoCompletedLinkedSubQuestCompletion struct {
ParentID int64
QuestID int64
Field dnfrepo.QuestField
Synthesized bool
}
type AutoCompletedLinkedSubQuestReconcilePlan struct {
Record dnfrepo.QuestRecord
Completions []AutoCompletedLinkedSubQuestCompletion
ChangedFields []dnfrepo.QuestField
}
// PlanAutoCompleteMainQuests closes past PVF main quests below the requested
// level. The current EXE's zero selector is the bulk action: it includes both
// active and not-yet-accepted eligible main quests. Quest prerequisites,
// selected-answer branches, job restrictions, and collision constraints remain
// authoritative; this planner never invents a missing branch answer or grants
// rewards. A quest-clear parent closes together with its PVF-bound children so
// the completed bitmap and its displayed counters agree; see
// autoCompleteClosableQuestClearChild for the two children it refuses.
func (c *Catalog) PlanAutoCompleteMainQuests(
record dnfrepo.QuestRecord,
character CharacterEligibility,
cutoffLevel int,
targetQuestID int64,
completedAt time.Time,
) (AutoCompleteMainPlan, error) {
if c == nil {
return AutoCompleteMainPlan{}, ErrCatalogEmpty
}
if cutoffLevel <= 0 || cutoffLevel >= character.Level || !definitionCharacterJobValid(character) {
return AutoCompleteMainPlan{}, ErrAutoCompleteLevelInvalid
}
if completedAt.IsZero() {
return AutoCompleteMainPlan{}, ErrAutoCompleteMainUnavailable
}
plan := AutoCompleteMainPlan{Record: dnfrepo.CloneQuest(record)}
completed, _ := questStateSets(plan.Record)
answers := questAnswerSelections(plan.Record)
eligibleMain := func(definition Definition) bool {
return c.autoCompleteMainEligible(definition, character, cutoffLevel)
}
prerequisitesMet := func(definition Definition, projectedCompleted map[int64]struct{}) bool {
return autoCompleteMainPrerequisitesMet(definition, projectedCompleted, answers)
}
if targetQuestID == 0 {
candidates := make([]int64, 0, 4)
candidateSet := make(map[int64]struct{})
projectedCompleted := make(map[int64]struct{}, len(completed))
for questID := range completed {
projectedCompleted[questID] = struct{}{}
}
for added := true; added; {
added = false
for _, definition := range c.ordered {
if _, wasCompleted := completed[definition.ID]; wasCompleted {
continue
}
if _, alreadySelected := candidateSet[definition.ID]; alreadySelected ||
!eligibleMain(definition) || !prerequisitesMet(definition, projectedCompleted) {
continue
}
state, _, stateKnown := mutableQuestState(&plan.Record, definition.ID)
if stateKnown && !isActiveQuestStatus(state.Status) {
continue
}
candidates = append(candidates, definition.ID)
candidateSet[definition.ID] = struct{}{}
projectedCompleted[definition.ID] = struct{}{}
added = true
}
}
if len(candidates) == 0 {
// An empty bulk result is rarely "nothing left to do": the story
// chain above the first awakening level is gated on the awakening
// quest group, and that group is the one thing a bulk pass must
// never complete for the player. Name the real cause so the caller
// can say what to run instead of reporting an empty result.
if pending, blocked := c.pendingAwakeningMainQuest(
plan.Record, completed, answers, character, cutoffLevel,
); blocked {
return AutoCompleteMainPlan{}, &AwakeningRequiredError{
QuestID: pending.ID, QuestName: strings.TrimSpace(pending.Name),
}
}
if pending, blocked := c.pendingChoiceMainQuest(
plan.Record, completed, answers, character, cutoffLevel,
); blocked {
return AutoCompleteMainPlan{}, &ChoiceRequiredError{
QuestID: pending.ID, QuestName: strings.TrimSpace(pending.Name),
}
}
if autoCompleteMainRequiresClassChange(character, cutoffLevel) {
return AutoCompleteMainPlan{}, ErrAutoCompleteClassRequired
}
return AutoCompleteMainPlan{}, ErrAutoCompleteTargetInvalid
}
for _, candidateID := range candidates {
definition, definitionKnown := c.Find(candidateID)
state, field, stateKnown := mutableQuestState(&plan.Record, candidateID)
if !definitionKnown || !eligibleMain(definition) {
return AutoCompleteMainPlan{}, ErrAutoCompleteTargetInvalid
}
if !stateKnown {
state = dnfrepo.QuestState{}
field = dnfrepo.QuestFieldStates
if plan.Record.States == nil {
plan.Record.States = make(map[int64]dnfrepo.QuestState)
}
} else if !isActiveQuestStatus(state.Status) {
return AutoCompleteMainPlan{}, ErrAutoCompleteTargetInvalid
}
state = autoCompleteMainQuestState(state, definition, cutoffLevel, completedAt)
if field == dnfrepo.QuestFieldStates {
plan.Record.States[candidateID] = state
} else {
plan.Record.Progress[candidateID] = state
}
plan.EligibleQuestIDs = append(plan.EligibleQuestIDs, candidateID)
plan.ChangedQuestIDs = append(plan.ChangedQuestIDs, candidateID)
plan.ChangedFields = mergeQuestChangedField(plan.ChangedFields, field)
plan.ActiveCompletedCount++
if candidateID > plan.HighestCompletedQuest {
plan.HighestCompletedQuest = candidateID
}
}
} else {
definition, definitionKnown := c.Find(targetQuestID)
if !definitionKnown || !eligibleMain(definition) || !prerequisitesMet(definition, completed) {
return AutoCompleteMainPlan{}, ErrAutoCompleteTargetInvalid
}
state, field, stateKnown := mutableQuestState(&plan.Record, targetQuestID)
if !stateKnown {
return AutoCompleteMainPlan{}, ErrAutoCompleteTargetInvalid
}
plan.EligibleQuestIDs = []int64{targetQuestID}
plan.HighestCompletedQuest = targetQuestID
if isCompletedQuestStatus(state.Status) {
return plan, nil
}
if !isActiveQuestStatus(state.Status) {
return AutoCompleteMainPlan{}, ErrAutoCompleteTargetInvalid
}
plan.ActiveCompletedCount = 1
state = autoCompleteMainQuestState(state, definition, cutoffLevel, completedAt)
if field == dnfrepo.QuestFieldStates {
plan.Record.States[targetQuestID] = state
} else {
plan.Record.Progress[targetQuestID] = state
}
plan.ChangedFields = mergeQuestChangedField(plan.ChangedFields, field)
plan.ChangedQuestIDs = []int64{targetQuestID}
}
// Close active no-reward linked children owned by the main quests in the
// auto-clear closure. Unaccepted children are left untouched so the
// operation never invents task rows that the character did not receive.
plannedParents := make(map[int64]struct{}, len(plan.ChangedQuestIDs))
for _, parentID := range plan.ChangedQuestIDs {
plannedParents[parentID] = struct{}{}
parent, parentKnown := c.Find(parentID)
if !parentKnown {
return AutoCompleteMainPlan{}, ErrAutoCompleteTargetInvalid
}
children, safe := c.autoCompletableQuestClearChildren(parent)
if !safe {
return AutoCompleteMainPlan{}, ErrAutoCompleteTargetInvalid
}
for _, child := range children {
completion, changed := c.completeAutoCompletedLinkedSubQuest(&plan.Record, parentID, child, completedAt)
if !changed {
continue
}
plan.ChangedFields = mergeQuestChangedField(plan.ChangedFields, completion.Field)
plan.ChangedLinkedSubIDs = appendQuestIDOnce(plan.ChangedLinkedSubIDs, completion.QuestID)
}
}
for _, child := range c.ordered {
if child.MainQuestID <= 0 || !shouldAutoCompleteNoRewardSubQuest(child) {
continue
}
if _, parentPlanned := plannedParents[child.MainQuestID]; !parentPlanned {
continue
}
state, field, known := mutableQuestState(&plan.Record, child.ID)
if !known || !isActiveQuestStatus(state.Status) {
continue
}
if !autoCompleteNoRewardSubQuestState(&state, child, completedAt) {
continue
}
switch field {
case dnfrepo.QuestFieldStates:
plan.Record.States[child.ID] = state
case dnfrepo.QuestFieldProgress:
plan.Record.Progress[child.ID] = state
}
plan.ChangedFields = mergeQuestChangedField(plan.ChangedFields, field)
plan.ChangedLinkedSubIDs = appendQuestIDOnce(plan.ChangedLinkedSubIDs, child.ID)
}
sort.Slice(plan.ChangedLinkedSubIDs, func(i, j int) bool { return plan.ChangedLinkedSubIDs[i] < plan.ChangedLinkedSubIDs[j] })
if len(plan.ChangedFields) != 0 {
plan.Record.UpdatedAt = completedAt
}
return plan, nil
}
// PlanAutoCompletedLinkedSubQuestReconciliation repairs rows produced by the
// older op1429 implementation, which could persist a completed quest-clear
// parent while leaving its unaccepted child objectives absent. Only the exact
// PVF-bound no-reward closure is materialized; ambiguous or reward-bearing
// graphs remain untouched.
func (c *Catalog) PlanAutoCompletedLinkedSubQuestReconciliation(
record dnfrepo.QuestRecord,
reconciledAt time.Time,
) (AutoCompletedLinkedSubQuestReconcilePlan, error) {
if c == nil {
return AutoCompletedLinkedSubQuestReconcilePlan{}, ErrCatalogEmpty
}
if reconciledAt.IsZero() {
return AutoCompletedLinkedSubQuestReconcilePlan{}, ErrAutoCompleteMainUnavailable
}
plan := AutoCompletedLinkedSubQuestReconcilePlan{Record: dnfrepo.CloneQuest(record)}
parentIDs := make([]int64, 0, len(record.States)+len(record.Progress))
seen := make(map[int64]struct{}, len(record.States)+len(record.Progress))
for questID := range record.Progress {
seen[questID] = struct{}{}
parentIDs = append(parentIDs, questID)
}
for questID := range record.States {
if _, exists := seen[questID]; !exists {
parentIDs = append(parentIDs, questID)
}
}
sort.Slice(parentIDs, func(i, j int) bool { return parentIDs[i] < parentIDs[j] })
for _, parentID := range parentIDs {
state, known := questStateFor(plan.Record, parentID)
if !known || !isCompletedQuestStatus(state.Status) || state.Extra["auto_completed"] != "true" ||
state.Extra["auto_complete_reason"] != "epic_below_character_level_no_reward" {
continue
}
parent, parentKnown := c.Find(parentID)
if !parentKnown {
continue
}
children, safe := c.autoCompletableQuestClearChildren(parent)
if !safe {
continue
}
for _, child := range children {
completion, changed := c.completeAutoCompletedLinkedSubQuest(&plan.Record, parentID, child, reconciledAt)
if !changed {
continue
}
plan.Completions = append(plan.Completions, completion)
plan.ChangedFields = mergeQuestChangedField(plan.ChangedFields, completion.Field)
}
}
if len(plan.ChangedFields) != 0 {
plan.Record.UpdatedAt = reconciledAt
}
return plan, nil
}
// isAwakeningQuest keeps the level-based no-reward shortcut away from both
// canonical story nodes and their profession-specific reward companions. A
// canonical first-awakening node has no profession reward of its own, so the
// generic epic filter must consult the strict collision index as well as the
// explicit PVF job-change/reward tags.
func (c *Catalog) isAwakeningQuest(definition Definition) bool {
if c == nil {
return false
}
if _, canonical := c.storyAwakeningByCanonical[definition.ID]; canonical {
return true
}
if _, companion := c.storyAwakeningCanonicalFor[definition.ID]; companion {
return true
}
return definition.JobChangeQuest > 0 || normalizeQuestTag(definition.RewardType) == "awakening type"
}
func (o *Owner) ApplyAutoCompleteMain(
ctx context.Context,
catalog *Catalog,
input AutoCompleteMainInput,
) (AutoCompleteMainResult, error) {
if o == nil || o.repositories.CharacterSettlement == nil || catalog == nil {
return AutoCompleteMainResult{}, ErrAutoCompleteMainUnavailable
}
input.CharacterID = strings.TrimSpace(input.CharacterID)
if input.CharacterID == "" || input.CompletedAt.IsZero() {
return AutoCompleteMainResult{}, ErrCharacterRequired
}
if input.CutoffLevel <= 0 || input.CutoffLevel >= input.Eligibility.Level {
return AutoCompleteMainResult{}, ErrAutoCompleteLevelInvalid
}
if ctx == nil {
ctx = context.Background()
}
var plan AutoCompleteMainPlan
var postInventory dnfrepo.InventoryRecord
var carryForwardItems []FinishCommittedItem
err := o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, input.CharacterID, func(tx dnfrepo.Group) error {
character, found, err := tx.Character.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found {
return ErrCharacterNotFound
}
if character.Level != input.Eligibility.Level || input.CutoffLevel >= character.Level {
return ErrAutoCompleteLevelInvalid
}
record, found, err := tx.Quest.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found {
record = dnfrepo.QuestRecord{CharacterID: input.CharacterID}
} else if strings.TrimSpace(record.CharacterID) != input.CharacterID {
return ErrQuestPersistVerify
}
plan, err = catalog.PlanAutoCompleteMainQuests(record, input.Eligibility, input.CutoffLevel, input.TargetQuestID, input.CompletedAt)
if err != nil {
return err
}
plan.Record.CharacterID = input.CharacterID
// The item repair runs against the planned record, so a producer that is
// being completed right now already counts, and it runs even when the
// quest plan itself is idempotent: a replay must still be able to hand
// over an item the previous attempt could not allocate.
carryForwardItems, postInventory, err = repairAutoCompletedQuestCarryForwardItems(
ctx,
catalog,
tx.Inventory,
input.CharacterID,
plan.Record,
input.Eligibility,
input.CompletedAt,
input.AllocateItem,
)
if err != nil {
return err
}
if len(plan.ChangedFields) == 0 {
return nil
}
return dnfrepo.SaveQuestFields(ctx, tx.Quest, plan.Record, plan.ChangedFields...)
})
if err != nil {
return AutoCompleteMainResult{}, err
}
persisted, found, err := o.quests.Load(ctx, input.CharacterID)
if err != nil {
return AutoCompleteMainResult{}, err
}
if !found {
return AutoCompleteMainResult{}, ErrQuestPersistVerify
}
for _, questID := range append(append([]int64(nil), plan.EligibleQuestIDs...), plan.ChangedLinkedSubIDs...) {
state, known := questStateFor(persisted, questID)
if !known || !isCompletedQuestStatus(state.Status) {
return AutoCompleteMainResult{}, fmt.Errorf("%w: quest=%d", ErrQuestPersistVerify, questID)
}
}
return AutoCompleteMainResult{
CharacterID: input.CharacterID,
CutoffLevel: input.CutoffLevel,
EligibleQuestIDs: append([]int64(nil), plan.EligibleQuestIDs...),
ChangedQuestIDs: append([]int64(nil), plan.ChangedQuestIDs...),
ChangedLinkedSubIDs: append([]int64(nil), plan.ChangedLinkedSubIDs...),
ActiveCompletedCount: plan.ActiveCompletedCount,
HighestCompletedQuest: plan.HighestCompletedQuest,
PostCommitQuest: dnfrepo.CloneQuest(persisted),
PostCommitInventory: dnfrepo.CloneInventory(postInventory),
CarryForwardItems: cloneFinishItems(carryForwardItems),
Idempotent: len(plan.ChangedFields) == 0 && len(carryForwardItems) == 0,
}, nil
}
func definitionCharacterJobValid(character CharacterEligibility) bool {
return character.Level > 0 && jobmap.Valid(character.Job)
}
// autoCompleteMainEligible is the bulk pass's own view of one definition.
// wantAwakening selects which side of the awakening split is wanted: false for
// the quests the pass may complete, true for the ones it must leave to the
// player and only reports on.
func (c *Catalog) autoCompleteMainEligible(
definition Definition,
character CharacterEligibility,
cutoffLevel int,
) bool {
return c.autoCompleteMainShapeEligible(definition, character, cutoffLevel) &&
// A PVF [question] quest is the player's own faction/branch choice. Its
// successors are gated by [pre required quest answer]; completing it
// here would either record no answer or silently select branch zero,
// which strands the rest of the chain for good.
normalizeQuestTag(definition.Type) != "question" &&
!c.isAwakeningQuest(definition)
}
// autoCompleteMainShapeEligible is everything the bulk pass asks of a
// definition apart from the two exclusions layered on top of it: awakening and
// [question] branch quests. Keeping the shape separate is what lets the
// "nothing to complete" answer name which of the two is actually holding the
// chain up.
func (c *Catalog) autoCompleteMainShapeEligible(
definition Definition,
character CharacterEligibility,
cutoffLevel int,
) bool {
grade := normalizeQuestTag(definition.Grade)
// The current PVF identifies 3054 and 3157 as [sub], but both belong to
// the verified early main-story chain and must participate in op1429.
if grade != "epic" && definition.ID != 3054 && definition.ID != 3157 {
return false
}
_, linkedChildrenSafe := c.autoCompletableQuestClearChildren(definition)
return definition.LevelMin > 0 && definition.LevelMin <= cutoffLevel &&
definition.ExposedByNPC != 0 && !definition.IsEvent &&
linkedChildrenSafe &&
definitionMatchesCharacter(definition, character)
}
func autoCompleteMainPrerequisitesMet(
definition Definition,
projectedCompleted map[int64]struct{},
answers map[int64]int64,
) bool {
if definition.MalformedPreAnswers {
return false
}
if definition.MainQuestID > 0 {
if _, parentCompleted := projectedCompleted[definition.MainQuestID]; !parentCompleted {
return false
}
}
if len(definition.PreRequiredGroups) > 0 {
matched := false
for _, group := range definition.PreRequiredGroups {
if completedAll(group, projectedCompleted) {
matched = true
break
}
}
if !matched {
return false
}
}
for _, requirement := range definition.PreRequiredAnswers {
answer, known := answers[requirement.QuestID]
if !known || answer != requirement.Answer {
return false
}
}
for _, collision := range definition.CollisionQuests {
if collision <= 0 {
continue
}
if _, collisionCompleted := projectedCompleted[collision]; collisionCompleted {
return false
}
}
return true
}
// pendingAwakeningMainQuest returns the awakening quest the character may take
// right now: eligible, prerequisites met, not completed, and either unaccepted
// or still active. It is what stands between an empty bulk result and the rest
// of the story chain.
func (c *Catalog) pendingAwakeningMainQuest(
record dnfrepo.QuestRecord,
completed map[int64]struct{},
answers map[int64]int64,
character CharacterEligibility,
cutoffLevel int,
) (Definition, bool) {
return c.pendingExcludedMainQuest(record, completed, answers, character, cutoffLevel,
func(definition Definition) bool {
return normalizeQuestTag(definition.Type) != "question" && c.isAwakeningQuest(definition)
})
}
// pendingChoiceMainQuest returns the [question] branch quest the character may
// answer right now. The story chain past it is gated on
// [pre required quest answer], so while it sits unanswered the bulk pass has
// nothing left it may touch - and the quest is still in the player's list.
func (c *Catalog) pendingChoiceMainQuest(
record dnfrepo.QuestRecord,
completed map[int64]struct{},
answers map[int64]int64,
character CharacterEligibility,
cutoffLevel int,
) (Definition, bool) {
return c.pendingExcludedMainQuest(record, completed, answers, character, cutoffLevel,
func(definition Definition) bool {
return normalizeQuestTag(definition.Type) == "question" && !c.isAwakeningQuest(definition)
})
}
// pendingExcludedMainQuest finds the lowest-level quest the character may take
// right now among the ones the bulk pass deliberately leaves alone: shape
// eligible, prerequisites met, not completed, and either unaccepted or active.
func (c *Catalog) pendingExcludedMainQuest(
record dnfrepo.QuestRecord,
completed map[int64]struct{},
answers map[int64]int64,
character CharacterEligibility,
cutoffLevel int,
excluded func(Definition) bool,
) (Definition, bool) {
var pending Definition
found := false
for _, definition := range c.ordered {
if _, wasCompleted := completed[definition.ID]; wasCompleted {
continue
}
if !excluded(definition) ||
!c.autoCompleteMainShapeEligible(definition, character, cutoffLevel) ||
!autoCompleteMainPrerequisitesMet(definition, completed, answers) {
continue
}
if state, _, stateKnown := mutableQuestState(&record, definition.ID); stateKnown &&
!isActiveQuestStatus(state.Status) {
continue
}
if !found || definition.LevelMin < pending.LevelMin ||
(definition.LevelMin == pending.LevelMin && definition.ID < pending.ID) {
pending, found = definition, true
}
}
return pending, found
}
// autoCompleteMainRequiresClassChange reports whether the bulk request can only
// end in an empty candidate set because the character never took its class
// change. grow_type packs the first transition in the low nibble; jobs 9 and 10
// have no class change to take.
func autoCompleteMainRequiresClassChange(character CharacterEligibility, cutoffLevel int) bool {
if cutoffLevel < autoCompleteMainFirstAwakeningLevel {
return false
}
if firstGrowType, _ := profession.Decode(byte(character.GrowType)); firstGrowType != 0 {
return false
}
return character.Job != 9 && character.Job != 10
}
func sortedQuestIDSet(values map[int64]struct{}) []int64 {
ids := make([]int64, 0, len(values))
for questID := range values {
ids = append(ids, questID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func autoCompleteMainQuestState(state dnfrepo.QuestState, definition Definition, cutoffLevel int, completedAt time.Time) dnfrepo.QuestState {
state.Status = "completed"
state.ProgressValue = 0
state.RewardSelectIndex = 0
state.UpdatedAt = completedAt
if state.Extra == nil {
state.Extra = make(map[string]string, 8)
}
if state.Extra["pvf_path"] == "" {
state.Extra["pvf_path"] = definition.Path
}
if state.Extra["quest_type"] == "" {
state.Extra["quest_type"] = definition.Type
}
state.Extra["reward_state"] = finishRewardGranted
state.Extra["auto_completed"] = "true"
state.Extra["auto_complete_reason"] = "epic_below_character_level_no_reward"
state.Extra["auto_complete_level_cutoff"] = strconv.Itoa(cutoffLevel)
return state
}
func shouldAutoCompleteNoRewardSubQuest(definition Definition) bool {
rewardType := normalizeQuestTag(definition.RewardType)
legacyEmptyReward := rewardType == ""
zeroItemPlaceholder := definition.NoExperience && rewardType == "item" &&
len(definition.RewardIntData) == 2 &&
definition.RewardIntData[0] == 0 && definition.RewardIntData[1] == 0
return normalizeQuestTag(definition.Grade) == "sub" &&
(legacyEmptyReward || zeroItemPlaceholder) && definition.RewardParseError == "" &&
!definition.HasGoldReward && emptyRewardDataValid(definition.RewardIntData) &&
len(definition.RewardItems) == 0 && len(definition.RewardSelectionItems) == 0
}
func (c *Catalog) autoCompletableQuestClearChildren(parent Definition) ([]Definition, bool) {
tag := normalizeQuestTag(parent.Type)
if tag != "quest clear" && tag != "clear quest" {
return nil, true
}
// Only subtype 2 has the proved parent/int-data child graph. Preserve the
// existing policy for other legacy quest-clear encodings instead of
// guessing that their integers are linked quest IDs.
if parent.SubType != 2 {
return nil, true
}
children, ok := c.parentBoundChildren(parent)
if !ok {
return nil, false
}
for _, child := range children {
if !c.autoCompleteClosableQuestClearChild(child) {
return nil, false
}
}
return children, true
}
// autoCompleteClosableQuestClearChild reports whether one PVF-bound child of a
// [quest clear] parent may be closed together with it.
//
// The rule the live client's task manual implements is "complete every main
// quest below the character's level, except class-change and awakening
// quests". An item reward on a child is therefore not a reason to refuse the
// parent: op1429 suppresses rewards everywhere, and the story items a later
// epic still has to hand in are restored by autoCompleteCarryForwardGrants,
// which sums exactly this child closure. Requiring every child to be a
// no-reward objective instead stranded whole regions - MirrorMelt's 3458
// 阿尔伯特的请求 has a child (3472) that awards two 毁灭纪碎片, so the pass stopped
// dead in front of it and the quest sat in the player's list for good.
//
// Two refusals remain, because neither is the pass's call to make: a
// [question] branch is the player's own faction choice, and a class-change or
// awakening step has to be run by the player.
func (c *Catalog) autoCompleteClosableQuestClearChild(child Definition) bool {
if child.ID <= 0 || normalizeQuestTag(child.Type) == "question" {
return false
}
return !c.isAwakeningQuest(child)
}
func (c *Catalog) completeAutoCompletedLinkedSubQuest(
record *dnfrepo.QuestRecord,
parentID int64,
child Definition,
completedAt time.Time,
) (AutoCompletedLinkedSubQuestCompletion, bool) {
if record == nil || parentID <= 0 || child.ID <= 0 || child.MainQuestID != parentID ||
!c.autoCompleteClosableQuestClearChild(child) {
return AutoCompletedLinkedSubQuestCompletion{}, false
}
state, field, known := mutableQuestState(record, child.ID)
if known && isCompletedQuestStatus(state.Status) {
return AutoCompletedLinkedSubQuestCompletion{}, false
}
synthesized := !known
if synthesized {
field = dnfrepo.QuestFieldStates
if record.States == nil {
record.States = make(map[int64]dnfrepo.QuestState)
}
}
if !autoCompleteNoRewardSubQuestState(&state, child, completedAt) {
// A reward-bearing child still closes with the parent, with its reward
// suppressed like every other op1429 completion.
autoCompleteQuestClearChildState(&state, child, completedAt)
}
state.Extra["auto_completed_by_parent"] = strconv.FormatInt(parentID, 10)
state.Extra["main_quest_id"] = strconv.FormatInt(parentID, 10)
if state.Extra["pvf_path"] == "" {
state.Extra["pvf_path"] = child.Path
}
switch field {
case dnfrepo.QuestFieldStates:
record.States[child.ID] = state
case dnfrepo.QuestFieldProgress:
record.Progress[child.ID] = state
default:
return AutoCompletedLinkedSubQuestCompletion{}, false
}
return AutoCompletedLinkedSubQuestCompletion{
ParentID: parentID,
QuestID: child.ID,
Field: field,
Synthesized: synthesized,
}, true
}
func appendQuestIDOnce(values []int64, questID int64) []int64 {
for _, existing := range values {
if existing == questID {
return values
}
}
return append(values, questID)
}
// autoCompleteQuestClearChildState closes a PVF-bound child that carries its
// own reward. The reward is dropped, exactly like the parent's: op1429 grants
// nothing, and story items a later epic still needs come back through
// autoCompleteCarryForwardGrants instead.
func autoCompleteQuestClearChildState(state *dnfrepo.QuestState, child Definition, completedAt time.Time) {
if state == nil {
return
}
state.Status = "completed"
state.ProgressValue = 0
state.RewardSelectIndex = 0
state.UpdatedAt = completedAt
if state.Extra == nil {
state.Extra = make(map[string]string, 8)
}
state.Extra["reward_state"] = finishRewardGranted
state.Extra["auto_completed"] = "true"
state.Extra["auto_complete_reason"] = "quest_clear_parent_closure"
if state.Extra["quest_type"] == "" {
state.Extra["quest_type"] = child.Type
}
}
func autoCompleteNoRewardSubQuestState(state *dnfrepo.QuestState, definition Definition, completedAt time.Time) bool {
if state == nil || !shouldAutoCompleteNoRewardSubQuest(definition) {
return false
}
state.Status = "completed"
state.ProgressValue = 0
state.UpdatedAt = completedAt
if state.Extra == nil {
state.Extra = make(map[string]string, 8)
}
state.Extra["reward_state"] = finishRewardGranted
state.Extra["auto_completed"] = "true"
state.Extra["auto_complete_reason"] = "no_reward_sub_quest"
return true
}
func mergeQuestChangedField(current []dnfrepo.QuestField, field dnfrepo.QuestField) []dnfrepo.QuestField {
if field == "" {
return current
}
for _, existing := range current {
if existing == field {
return current
}
}
return append(current, field)
}
type LinkedSubQuestActivation struct {
ParentID int64
QuestID int64
Field dnfrepo.QuestField
InitTrigger uint32
PVFPath string
QuestType string
AlreadyPresent bool
}
type LinkedSubQuestActivationPlan struct {
Record dnfrepo.QuestRecord
Activations []LinkedSubQuestActivation
ChangedFields []dnfrepo.QuestField
}
func (c *Catalog) PlanActiveQuestClearLinkedSubQuestActivation(record dnfrepo.QuestRecord, activatedAt time.Time) (LinkedSubQuestActivationPlan, error) {
if c == nil {
return LinkedSubQuestActivationPlan{}, ErrCatalogEmpty
}
if activatedAt.IsZero() {
activatedAt = time.Now()
}
plan := LinkedSubQuestActivationPlan{Record: dnfrepo.CloneQuest(record)}
plan.Activations, plan.ChangedFields = c.activateMissingLinkedNoRewardSubQuests(&plan.Record, activatedAt, "")
if len(plan.ChangedFields) > 0 {
plan.Record.UpdatedAt = activatedAt
}
return plan, nil
}
func (c *Catalog) activateMissingLinkedNoRewardSubQuests(
record *dnfrepo.QuestRecord,
activatedAt time.Time,
childType string,
) ([]LinkedSubQuestActivation, []dnfrepo.QuestField) {
if c == nil {
return nil, nil
}
childType = normalizeQuestTag(childType)
completed, _ := questStateSets(*record)
activations := make([]LinkedSubQuestActivation, 0)
changedFields := make([]dnfrepo.QuestField, 0, 2)
seenChildren := make(map[int64]struct{})
collect := func(field dnfrepo.QuestField, states map[int64]dnfrepo.QuestState) {
if len(states) == 0 {
return
}
parentIDs := make([]int64, 0, len(states))
for questID := range states {
parentIDs = append(parentIDs, questID)
}
sort.Slice(parentIDs, func(left, right int) bool { return parentIDs[left] < parentIDs[right] })
for _, parentID := range parentIDs {
parentState := states[parentID]
if !isActiveQuestStatus(parentState.Status) {
continue
}
parent, known := c.Find(parentID)
if !known {
continue
}
parentType := normalizeQuestTag(parent.Type)
if parentType != "quest clear" && parentType != "clear quest" {
continue
}
for _, childID := range parent.IntData {
if childID <= 0 {
continue
}
if _, duplicate := seenChildren[childID]; duplicate {
continue
}
if _, known := questStateFor(*record, childID); known {
continue
}
child, known := c.Find(childID)
if !known || child.MainQuestID != parent.ID || child.HasDependGiveItem ||
(childType != "" && normalizeQuestTag(child.Type) != childType) ||
!shouldAutoCompleteNoRewardSubQuest(child) {
continue
}
seenChildren[childID] = struct{}{}
initialTrigger := definitionInitialTrigger(child, completed)
state := dnfrepo.QuestState{
Status: "active",
ProgressValue: int64(initialTrigger),
UpdatedAt: activatedAt,
Extra: map[string]string{
"pvf_path": child.Path,
"quest_type": child.Type,
"main_quest_id": int64Text(parent.ID),
"auto_activated_by_main_quest": "true",
"auto_activation_reason": "active_quest_clear_parent_reconcile",
"auto_activation_parent_progress_value": int64Text(parentState.ProgressValue),
},
}
switch field {
case dnfrepo.QuestFieldStates:
if record.States == nil {
record.States = make(map[int64]dnfrepo.QuestState, 1)
}
record.States[child.ID] = state
changedFields = mergeQuestChangedField(changedFields, dnfrepo.QuestFieldStates)
case dnfrepo.QuestFieldProgress:
if record.Progress == nil {
record.Progress = make(map[int64]dnfrepo.QuestState, 1)
}
record.Progress[child.ID] = state
changedFields = mergeQuestChangedField(changedFields, dnfrepo.QuestFieldProgress)
}
activations = append(activations, LinkedSubQuestActivation{
ParentID: parent.ID,
QuestID: child.ID,
Field: field,
InitTrigger: initialTrigger,
PVFPath: child.Path,
QuestType: child.Type,
})
}
}
}
collect(dnfrepo.QuestFieldStates, record.States)
collect(dnfrepo.QuestFieldProgress, record.Progress)
return activations, changedFields
}
func int64Text(value int64) string {
return strconv.FormatInt(value, 10)
}
@@ -0,0 +1,418 @@
package quest
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Task-manual auto completion (op1429) deliberately grants no rewards. That is
// right for experience, gold and ordinary loot, but a handful of epic quests
// reward a story item that a later epic requires: 3226 hands over 斯卡迪女王的
// 书信 (10164786) and 3610 later asks for it; six mirror-era epics each drop one
// 毁灭纪碎片 (10164800) and 3509 asks for ten. A character who auto-completes
// the producer and then plays the consumer has no way to obtain the item and
// the chain is stuck for good.
//
// The repair below is a narrow join, not a reward replay: only items that an
// outstanding story consumer actually asks for, and that a completed no-reward
// producer would have handed over, are granted, and only up to the deficit the
// inventory shows. Parent closure (a [quest clear] parent auto-completing its
// no-reward children) does not make this redundant - it only reaches children
// whose parent itself falls under the level cutoff, whereas the stuck case is
// a producer under the cutoff and a consumer above it.
// AutoCompleteCarryForwardRepairInput repairs only story items whose ordinary
// reward was deliberately skipped by the task-manual no-reward path. It does
// not replay experience, currency, selectable rewards, or ordinary loot.
type AutoCompleteCarryForwardRepairInput struct {
CharacterID string
RepairedAt time.Time
AllocateItem FinishItemAllocator
}
type AutoCompleteCarryForwardRepairResult struct {
CharacterID string
Items []FinishCommittedItem
Inventory dnfrepo.InventoryRecord
Idempotent bool
}
type autoCompleteCarryForwardRequirement struct {
ConsumerQuestID int64
Count int64
}
type autoCompleteCarryForwardGrant struct {
ProducerQuestID int64
ConsumerQuestID int64
PVFPath string
ItemID int64
Count int64
}
type autoCompleteCarryForwardContribution struct {
anchorQuestID int64
anchorPath string
rewardCount int64
consumeCount int64
}
// ApplyAutoCompleteCarryForwardRepair is the reconnect compatibility path for
// characters saved before carry-forward item preservation existed. The
// completed no-reward producer, active/next consumer, and inventory deficit
// are checked again inside one character-settlement transaction.
func (o *Owner) ApplyAutoCompleteCarryForwardRepair(
ctx context.Context,
catalog *Catalog,
input AutoCompleteCarryForwardRepairInput,
) (AutoCompleteCarryForwardRepairResult, error) {
if o == nil || o.repositories.CharacterSettlement == nil || catalog == nil {
return AutoCompleteCarryForwardRepairResult{}, ErrAutoCompleteMainUnavailable
}
input.CharacterID = strings.TrimSpace(input.CharacterID)
if input.CharacterID == "" {
return AutoCompleteCarryForwardRepairResult{}, ErrCharacterRequired
}
if input.RepairedAt.IsZero() {
input.RepairedAt = time.Now().UTC()
}
if ctx == nil {
ctx = context.Background()
}
var items []FinishCommittedItem
var inventory dnfrepo.InventoryRecord
err := o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, input.CharacterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Quest == nil || tx.Inventory == nil {
return ErrAutoCompleteMainUnavailable
}
character, found, err := tx.Character.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(character.CharacterID) != input.CharacterID {
return ErrCharacterNotFound
}
job, err := strconv.Atoi(strings.TrimSpace(character.Job))
growType := character.Stats["grow_type"]
if err != nil || !definitionCharacterJobValid(CharacterEligibility{Level: character.Level, Job: job}) || growType < 0 || growType > 0xff {
return ErrAutoCompleteLevelInvalid
}
record, found, err := tx.Quest.Load(ctx, input.CharacterID)
if err != nil {
return err
}
if !found {
return nil
}
items, inventory, err = repairAutoCompletedQuestCarryForwardItems(
ctx,
catalog,
tx.Inventory,
input.CharacterID,
record,
CharacterEligibility{Level: character.Level, Job: job, GrowType: int(growType)},
input.RepairedAt,
input.AllocateItem,
)
return err
})
if err != nil {
return AutoCompleteCarryForwardRepairResult{}, err
}
return AutoCompleteCarryForwardRepairResult{
CharacterID: input.CharacterID,
Items: cloneFinishItems(items),
Inventory: dnfrepo.CloneInventory(inventory),
Idempotent: len(items) == 0,
}, nil
}
// repairAutoCompletedQuestCarryForwardItems grants the deficit of every
// carry-forward item to the main inventory. It is idempotent by construction:
// a second pass finds the inventory already holding the required count and
// grants nothing.
func repairAutoCompletedQuestCarryForwardItems(
ctx context.Context,
catalog *Catalog,
inventoryRepository dnfrepo.InventoryRepository,
characterID string,
record dnfrepo.QuestRecord,
character CharacterEligibility,
repairedAt time.Time,
allocator FinishItemAllocator,
) ([]FinishCommittedItem, dnfrepo.InventoryRecord, error) {
grants, err := catalog.autoCompleteCarryForwardGrants(record, character)
if err != nil {
return nil, dnfrepo.InventoryRecord{}, err
}
if len(grants) == 0 {
return nil, dnfrepo.InventoryRecord{}, nil
}
if inventoryRepository == nil {
return nil, dnfrepo.InventoryRecord{}, ErrAutoCompleteMainUnavailable
}
inventory, found, err := inventoryRepository.Load(ctx, characterID)
if err != nil {
return nil, dnfrepo.InventoryRecord{}, err
}
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
return nil, dnfrepo.InventoryRecord{}, ErrAutoCompleteMainUnavailable
}
inventory = dnfrepo.CloneInventory(inventory)
committed := make([]FinishCommittedItem, 0, len(grants))
for _, grant := range grants {
held := countItemInMain(inventory, grant.ItemID)
if held >= grant.Count {
continue
}
if allocator == nil {
return nil, dnfrepo.InventoryRecord{}, ErrFinishItemAllocatorRequired
}
deficit := grant.Count - held
completionKey := fmt.Sprintf(
"auto-complete-carry-forward/%s/%d/%d/%d",
characterID,
grant.ProducerQuestID,
grant.ConsumerQuestID,
grant.ItemID,
)
item, allocateErr := allocator(&inventory, FinishItemGrantRequest{
QuestID: grant.ProducerQuestID,
CompletionKey: completionKey,
Source: "quest_pvf_auto_complete_carry_forward:" + grant.PVFPath,
ItemID: grant.ItemID,
Count: deficit,
})
if allocateErr != nil {
return nil, dnfrepo.InventoryRecord{}, allocateErr
}
if item.ItemID != grant.ItemID || item.Delta != deficit || item.SlotKey == "" || item.PostCount < item.Delta || len(item.RawEntry) == 0 {
return nil, dnfrepo.InventoryRecord{}, fmt.Errorf("%w: invalid carry-forward allocator receipt item=%d", ErrQuestRewardMalformed, grant.ItemID)
}
committed = append(committed, item)
}
if len(committed) == 0 {
return nil, inventory, nil
}
inventory.UpdatedAt = repairedAt.UTC()
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepository, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return nil, dnfrepo.InventoryRecord{}, err
}
return committed, inventory, nil
}
// autoCompleteCarryForwardGrants matches the immediate active/acceptable
// storyline's required item against a completed task-manual producer whose
// ordinary item reward was explicitly suppressed. This narrow join is what
// distinguishes a continuity item from the ordinary rewards op1429 must skip.
func (c *Catalog) autoCompleteCarryForwardGrants(
record dnfrepo.QuestRecord,
character CharacterEligibility,
) ([]autoCompleteCarryForwardGrant, error) {
if c == nil {
return nil, ErrCatalogEmpty
}
requirements := c.autoCompleteOutstandingItemRequirements(record, character)
if len(requirements) == 0 {
return nil, nil
}
contributions := make(map[int64]autoCompleteCarryForwardContribution, len(requirements))
visitedDefinitions := make(map[int64]struct{})
var collectDefinition func(Definition) error
collectDefinition = func(definition Definition) error {
if definition.ID <= 0 || !definitionMatchesCharacter(definition, character) {
return nil
}
if _, visited := visitedDefinitions[definition.ID]; visited {
return nil
}
visitedDefinitions[definition.ID] = struct{}{}
if normalizeQuestTag(definition.RewardType) == "item" && definition.RewardParseError == "" {
for _, rule := range definition.RewardItems {
if _, needed := requirements[rule.ItemID]; !needed || !rewardRuleMatches(rule, character) {
continue
}
contribution := contributions[rule.ItemID]
contribution.rewardCount += rule.Count
if contribution.anchorQuestID == 0 || definition.ID < contribution.anchorQuestID {
contribution.anchorQuestID = definition.ID
contribution.anchorPath = definition.Path
}
contributions[rule.ItemID] = contribution
}
}
for _, rule := range finishDefinitionSeekingRules(definition) {
if _, needed := requirements[rule.ItemID]; !needed {
continue
}
contribution := contributions[rule.ItemID]
contribution.consumeCount += rule.Count
contributions[rule.ItemID] = contribution
}
if !questClearDefinition(definition) {
return nil
}
// Task-manual completion closes a composite epic without executing its
// rewarded internal stages. Reconstruct those stages from the parent so
// cumulative story items (for example quest 3509's ten fragments) retain
// the same net balance as an ordinary play-through.
for _, childID := range definition.IntData {
child, found := c.Find(childID)
if !found || child.MainQuestID != definition.ID {
continue
}
if err := collectDefinition(child); err != nil {
return err
}
}
return nil
}
collect := func(questID int64, state dnfrepo.QuestState) error {
if !isCompletedQuestStatus(state.Status) ||
state.Extra["auto_completed"] != "true" ||
state.Extra["auto_complete_reason"] != "epic_below_character_level_no_reward" {
return nil
}
definition, found := c.Find(questID)
if !found || normalizeQuestTag(definition.Grade) != "epic" {
return nil
}
return collectDefinition(definition)
}
seen := make(map[int64]struct{}, len(record.States)+len(record.Progress))
for questID, state := range record.States {
seen[questID] = struct{}{}
if err := collect(questID, state); err != nil {
return nil, err
}
}
for questID, state := range record.Progress {
if _, duplicate := seen[questID]; duplicate {
continue
}
if err := collect(questID, state); err != nil {
return nil, err
}
}
itemIDs := make([]int64, 0, len(contributions))
netCounts := make(map[int64]int64, len(contributions))
for itemID, contribution := range contributions {
netCount := contribution.rewardCount - contribution.consumeCount
// What the pass owes the character is exactly what it withheld: the net
// the completed producers would have handed over. It is deliberately
// not the consumer's requirement. The pass stops wherever the chain
// stops being safe to auto-complete - a [quest clear] parent with a
// reward-bearing child, for one - and the producers inside that
// remaining stretch are ones the player still earns by playing it. A
// short net there is normal, not malformed PVF: quest 3473 needs three
// fragments while its own sibling 3472 awards two of them.
if contribution.anchorQuestID <= 0 || netCount <= 0 {
return nil, fmt.Errorf(
"%w: cumulative producers item=%d reward=%d consumed=%d net=%d required=%d",
ErrQuestRewardMalformed,
itemID,
contribution.rewardCount,
contribution.consumeCount,
netCount,
requirements[itemID].Count,
)
}
if requirement := requirements[itemID].Count; netCount > requirement {
netCount = requirement
}
netCounts[itemID] = netCount
itemIDs = append(itemIDs, itemID)
}
sort.Slice(itemIDs, func(left, right int) bool { return itemIDs[left] < itemIDs[right] })
grants := make([]autoCompleteCarryForwardGrant, 0, len(itemIDs))
for _, itemID := range itemIDs {
contribution := contributions[itemID]
requirement := requirements[itemID]
grants = append(grants, autoCompleteCarryForwardGrant{
ProducerQuestID: contribution.anchorQuestID,
ConsumerQuestID: requirement.ConsumerQuestID,
PVFPath: contribution.anchorPath,
ItemID: itemID,
Count: netCounts[itemID],
})
}
return grants, nil
}
// autoCompleteOutstandingItemRequirements collects the story items the
// character's active or currently acceptable epic line still has to hand in.
// Consumers are epics, or children of an epic [quest clear] parent, whose type
// consumes items ([seeking] / [seek n meet npc]).
func (c *Catalog) autoCompleteOutstandingItemRequirements(
record dnfrepo.QuestRecord,
character CharacterEligibility,
) map[int64]autoCompleteCarryForwardRequirement {
requirements := make(map[int64]autoCompleteCarryForwardRequirement)
consumerIDs := make(map[int64]struct{})
collectActive := func(states map[int64]dnfrepo.QuestState) {
for questID, state := range states {
if isActiveQuestStatus(state.Status) {
consumerIDs[questID] = struct{}{}
}
}
}
collectActive(record.States)
collectActive(record.Progress)
for _, questID := range c.Acceptable(character, record).IDs {
consumerIDs[int64(questID)] = struct{}{}
}
// A quest-clear parent owns internal child steps that are not advertised as
// independently acceptable. Include those definitions so the item needed by
// the next composite main quest is preserved before the parent is accepted.
for questID := range consumerIDs {
definition, found := c.Find(questID)
if !found || !questClearDefinition(definition) {
continue
}
for _, childID := range definition.IntData {
if child, childFound := c.Find(childID); childFound && child.MainQuestID == definition.ID {
consumerIDs[childID] = struct{}{}
}
}
}
for questID := range consumerIDs {
if state, found := questStateFor(record, questID); found && isCompletedQuestStatus(state.Status) {
continue
}
definition, found := c.Find(questID)
if !found || !c.autoCompleteStoryItemConsumer(definition) || !definitionMatchesCharacter(definition, character) {
continue
}
for _, rule := range finishDefinitionSeekingRules(definition) {
if rule.ItemID <= 0 || rule.Count <= 0 {
continue
}
current, exists := requirements[rule.ItemID]
if !exists || rule.Count > current.Count || (rule.Count == current.Count && questID < current.ConsumerQuestID) {
requirements[rule.ItemID] = autoCompleteCarryForwardRequirement{ConsumerQuestID: questID, Count: rule.Count}
}
}
}
return requirements
}
func (c *Catalog) autoCompleteStoryItemConsumer(definition Definition) bool {
if normalizeQuestTag(definition.Grade) == "epic" {
return true
}
if definition.MainQuestID <= 0 {
return false
}
parent, found := c.Find(definition.MainQuestID)
return found && normalizeQuestTag(parent.Grade) == "epic" && questClearDefinition(parent)
}
@@ -0,0 +1,113 @@
package quest
import (
"context"
"os"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func loadRealQuestCatalogForCarryForward(t *testing.T) *Catalog {
t.Helper()
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify carry-forward story items against the runtime PVF")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, 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
}
// Runtime PVF: 3226 (alphraira_epic_03, lvl 30) rewards 10164786 斯卡迪女王的
// 书信; 3610 (child of the lvl-35 [quest clear] 3249) is [seek n meet npc]
// {10164786 1 303}. A level-35 character auto-completes 3226 under cutoff 34
// and must then play 3249, so parent closure never reaches 3610 - the letter
// has to be carried forward.
func TestRealScriptPVFAutoCompletePreservesSkadiLetterForQuest3610(t *testing.T) {
catalog := loadRealQuestCatalogForCarryForward(t)
producer, found := catalog.Find(3226)
if !found || len(producer.RewardItems) != 1 || producer.RewardItems[0].ItemID != 10164786 {
t.Fatalf("quest 3226 definition=%+v found=%t; the letter is expected as its reward item", producer, found)
}
consumer, found := catalog.Find(3610)
if !found || consumer.MainQuestID != 3249 || len(consumer.IntData) < 2 || consumer.IntData[0] != 10164786 || consumer.IntData[1] != 1 {
t.Fatalf("quest 3610 definition=%+v found=%t; expected [seek n meet npc] of one letter under 3249", consumer, found)
}
record := dnfrepo.QuestRecord{
CharacterID: "7",
States: map[int64]dnfrepo.QuestState{
3226: {
Status: "completed",
Extra: map[string]string{
"auto_completed": "true",
"auto_complete_reason": "epic_below_character_level_no_reward",
"reward_state": finishRewardGranted,
},
},
3249: {Status: "active"},
3609: {Status: "completed"},
3610: {Status: "active"},
},
}
grants, err := catalog.autoCompleteCarryForwardGrants(record, CharacterEligibility{Level: 35, Job: 0, GrowType: 3})
if err != nil {
t.Fatal(err)
}
if len(grants) != 1 || grants[0].ProducerQuestID != 3226 || grants[0].ConsumerQuestID != 3610 || grants[0].ItemID != 10164786 || grants[0].Count != 1 {
t.Fatalf("Skadi letter continuity grants=%+v", grants)
}
// A character who actually played 3226 has no auto_completed marker and
// must not be handed a second letter.
played := dnfrepo.CloneQuest(record)
played.States[3226] = dnfrepo.QuestState{Status: "completed"}
if grants, err := catalog.autoCompleteCarryForwardGrants(played, CharacterEligibility{Level: 35, Job: 0, GrowType: 3}); err != nil || len(grants) != 0 {
t.Fatalf("played producer must yield no grant: grants=%+v err=%v", grants, err)
}
}
// Runtime PVF: 3449/3456/3460/3483 each reward one 10164800 毁灭纪碎片, and the
// [quest clear] producers 3458/3469 contribute through their children; 3509 is
// [seek n meet npc] {10164800 10 310}. The join must reconstruct the net ten.
func TestRealScriptPVFAutoCompleteRebuildsDestructionEraFragmentsForQuest3509(t *testing.T) {
catalog := loadRealQuestCatalogForCarryForward(t)
completedNoReward := dnfrepo.QuestState{
Status: "completed",
Extra: map[string]string{
"auto_completed": "true",
"auto_complete_reason": "epic_below_character_level_no_reward",
},
}
record := dnfrepo.QuestRecord{
CharacterID: "7",
States: map[int64]dnfrepo.QuestState{
3449: completedNoReward,
3456: completedNoReward,
3458: completedNoReward,
3460: completedNoReward,
3469: completedNoReward,
3483: completedNoReward,
3509: {Status: "active"},
},
}
grants, err := catalog.autoCompleteCarryForwardGrants(record, CharacterEligibility{Level: 62, Job: 6, GrowType: 20})
if err != nil {
t.Fatal(err)
}
if len(grants) != 1 || grants[0].ProducerQuestID != 3449 || grants[0].ConsumerQuestID != 3509 ||
grants[0].ItemID != 10164800 || grants[0].Count != 10 {
t.Fatalf("quest 3509 carry-forward grants=%+v", grants)
}
}
@@ -0,0 +1,304 @@
package quest
import (
"context"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestAutoCompleteCarryForwardRepairGrantsOnlyOutstandingStoryItemOnce(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "17",
Job: "2",
Level: 35,
Stats: map[string]int64{"grow_type": 0},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "17",
Slots: map[string]dnfrepo.ItemStack{"0:10": {ItemID: 9001, Count: 3}},
}); err != nil {
t.Fatal(err)
}
completedNoReward := dnfrepo.QuestState{
Status: "completed",
Extra: map[string]string{
"auto_completed": "true",
"auto_complete_reason": "epic_below_character_level_no_reward",
"reward_state": finishRewardGranted,
},
}
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "17",
States: map[int64]dnfrepo.QuestState{
100: completedNoReward,
101: completedNoReward,
300: {Status: "active"},
},
}); err != nil {
t.Fatal(err)
}
// 101 rewards an item that only a [side] consumer wants; a side quest is
// not a story consumer, so 7002 must not be granted.
definitions := []Definition{
{ID: 100, Path: "n_quest/producer.qst", Grade: "[epic]", Type: "[meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 1}}},
{ID: 101, Path: "n_quest/unrelated.qst", Grade: "[epic]", Type: "[meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7002, Count: 5}}},
{ID: 200, Path: "n_quest/parent.qst", Grade: "[epic]", Type: "[quest clear]", Job: "[all]", LevelMin: 1, LevelMax: 99, ExposedByNPC: 1, IntData: []int64{250}},
{ID: 250, Path: "n_quest/consumer.qst", Grade: "[sub]", Type: "[seek n meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 200, IntData: []int64{7001, 1, 303}},
{ID: 300, Path: "n_quest/unrelated_side_consumer.qst", Grade: "[side]", Type: "[seeking]", Job: "[all]", LevelMin: 1, LevelMax: 99, ExposedByNPC: 1, IntData: []int64{7002, 1}},
}
catalog := &Catalog{ordered: definitions, byID: make(map[int64]Definition, len(definitions))}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
input := AutoCompleteCarryForwardRepairInput{
CharacterID: "17",
RepairedAt: time.Date(2026, 9, 5, 10, 30, 0, 0, time.UTC),
AllocateItem: finishOwnerAllocator(nil),
}
result, err := owner.ApplyAutoCompleteCarryForwardRepair(ctx, catalog, input)
if err != nil {
t.Fatal(err)
}
if result.Idempotent || len(result.Items) != 1 || result.Items[0].ItemID != 7001 || result.Items[0].Delta != 1 || result.Items[0].PostCount != 1 {
t.Fatalf("carry-forward result=%+v", result)
}
stored, found, err := repositories.Inventory.Load(ctx, "17")
if err != nil || !found || countItemInMain(stored, 7001) != 1 || countItemInMain(stored, 7002) != 0 {
t.Fatalf("stored inventory found=%t err=%v record=%+v", found, err, stored)
}
replayed, err := owner.ApplyAutoCompleteCarryForwardRepair(ctx, catalog, input)
if err != nil {
t.Fatal(err)
}
if !replayed.Idempotent || len(replayed.Items) != 0 {
t.Fatalf("replayed carry-forward=%+v", replayed)
}
stored, found, err = repositories.Inventory.Load(ctx, "17")
if err != nil || !found || countItemInMain(stored, 7001) != 1 {
t.Fatalf("replay duplicated inventory found=%t err=%v record=%+v", found, err, stored)
}
}
// Mirrors the 3509 shape: several producers each reward one fragment, a
// composite [quest clear] producer both rewards and consumes fragments through
// its children, and the consumer asks for the cumulative total.
func TestAutoCompleteCarryForwardRepairAggregatesCompositeStoryItemBalance(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "21",
Job: "6",
Level: 62,
Stats: map[string]int64{"grow_type": 20},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "21", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
completedNoReward := dnfrepo.QuestState{
Status: "completed",
Extra: map[string]string{
"auto_completed": "true",
"auto_complete_reason": "epic_below_character_level_no_reward",
"reward_state": finishRewardGranted,
},
}
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "21",
States: map[int64]dnfrepo.QuestState{
100: completedNoReward,
110: completedNoReward,
120: completedNoReward,
300: {Status: "active"},
},
}); err != nil {
t.Fatal(err)
}
definitions := []Definition{
{ID: 100, Path: "n_quest/producer_1.qst", Grade: "[epic]", Type: "[meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 1}}},
{ID: 110, Path: "n_quest/producer_2.qst", Grade: "[epic]", Type: "[meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 2}}},
{ID: 120, Path: "n_quest/composite.qst", Grade: "[epic]", Type: "[quest clear]", Job: "[all]", LevelMin: 1, LevelMax: 99, IntData: []int64{121, 122, 123}},
{ID: 121, Path: "n_quest/composite_reward_1.qst", Grade: "[sub]", Type: "[hunt monster]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 120, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 2}}},
{ID: 122, Path: "n_quest/composite_exchange.qst", Grade: "[sub]", Type: "[seeking]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 120, IntData: []int64{7001, 3}, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 8}}},
{ID: 123, Path: "n_quest/composite_report.qst", Grade: "[sub]", Type: "[meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 120},
{ID: 300, Path: "n_quest/consumer.qst", Grade: "[epic]", Type: "[seek n meet npc]", Job: "[all]", LevelMin: 1, LevelMax: 99, ExposedByNPC: 1, IntData: []int64{7001, 10, 303}},
}
catalog := &Catalog{ordered: definitions, byID: make(map[int64]Definition, len(definitions))}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
input := AutoCompleteCarryForwardRepairInput{
CharacterID: "21",
RepairedAt: time.Date(2026, 9, 5, 13, 0, 0, 0, time.UTC),
AllocateItem: finishOwnerAllocator(nil),
}
result, err := owner.ApplyAutoCompleteCarryForwardRepair(ctx, catalog, input)
if err != nil {
t.Fatal(err)
}
// 1 + 2 + 2 + 8 rewarded, 3 consumed by the composite's own exchange step:
// net 10, exactly what the consumer asks for.
if result.Idempotent || len(result.Items) != 1 || result.Items[0].ItemID != 7001 || result.Items[0].Delta != 10 || result.Items[0].PostCount != 10 {
t.Fatalf("cumulative carry-forward result=%+v", result)
}
replayed, err := owner.ApplyAutoCompleteCarryForwardRepair(ctx, catalog, input)
if err != nil {
t.Fatal(err)
}
if !replayed.Idempotent || len(replayed.Items) != 0 {
t.Fatalf("replayed cumulative carry-forward=%+v", replayed)
}
}
// The producer is auto-completed under the cutoff while the consumer's parent
// sits above it. This is the level-35 shape of 3226 -> 3249/3610: parent
// closure never reaches 3610, so the item has to travel with the completion.
func TestAutoCompleteMainPreservesStoryItemRequiredByNewSuccessor(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
Job: "2",
Level: 35,
Stats: map[string]int64{"grow_type": 0},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{100: {Status: "active"}},
}); err != nil {
t.Fatal(err)
}
definitions := []Definition{
{ID: 100, Path: "n_quest/producer.qst", Grade: "[epic]", Type: "[meet npc]", Job: "[all]", LevelMin: 10, LevelMax: 99, ExposedByNPC: 1, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 1}}},
{ID: 200, Path: "n_quest/parent.qst", Grade: "[epic]", Type: "[quest clear]", Job: "[all]", LevelMin: 35, LevelMax: 99, ExposedByNPC: 1, PreRequiredGroups: [][]int64{{100}}, IntData: []int64{250}},
{ID: 250, Path: "n_quest/consumer.qst", Grade: "[sub]", Type: "[seek n meet npc]", Job: "[all]", LevelMin: 35, LevelMax: 99, MainQuestID: 200, IntData: []int64{7001, 1, 303}},
}
catalog := &Catalog{ordered: definitions, byID: make(map[int64]Definition, len(definitions))}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyAutoCompleteMain(ctx, catalog, AutoCompleteMainInput{
CharacterID: "19",
Eligibility: CharacterEligibility{Level: 35, Job: 2, GrowType: 0},
CutoffLevel: 34,
TargetQuestID: 100,
CompletedAt: time.Date(2026, 9, 5, 11, 0, 0, 0, time.UTC),
AllocateItem: finishOwnerAllocator(nil),
})
if err != nil {
t.Fatal(err)
}
if len(result.ChangedQuestIDs) != 1 || result.ChangedQuestIDs[0] != 100 || len(result.CarryForwardItems) != 1 || result.CarryForwardItems[0].ItemID != 7001 || result.Idempotent {
t.Fatalf("auto-complete carry-forward result=%+v", result)
}
stored, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found || countItemInMain(stored, 7001) != 1 {
t.Fatalf("stored inventory found=%t err=%v record=%+v", found, err, stored)
}
// Without an allocator the quest side still commits; the item repair is
// what needs one, and it must say so rather than silently skip.
if _, err := owner.ApplyAutoCompleteMain(ctx, catalog, AutoCompleteMainInput{
CharacterID: "19",
Eligibility: CharacterEligibility{Level: 35, Job: 2, GrowType: 0},
CutoffLevel: 34,
TargetQuestID: 100,
CompletedAt: time.Date(2026, 9, 5, 11, 1, 0, 0, time.UTC),
}); err != nil {
t.Fatalf("replay without allocator must be idempotent (item already held): %v", err)
}
}
// The bulk pass stops before a [quest clear] parent whose children carry their
// own rewards, so the producers inside that parent are ones the player still
// earns by playing it. Carrying forward only what the pass withheld is the
// whole contract; demanding that it cover the child step's full requirement
// rejected the entire operation instead. This is the live 3449/3456 -> 3458
// (3472 awards 2, 3473 needs 3) shape.
func TestAutoCompleteCarryForwardGrantsOnlyTheWithheldShareOfAnUnreachedComposite(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "23", Job: "6", Level: 62, Stats: map[string]int64{"grow_type": 20},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "23", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
completedNoReward := dnfrepo.QuestState{
Status: "completed",
Extra: map[string]string{
"auto_completed": "true",
"auto_complete_reason": "epic_below_character_level_no_reward",
"reward_state": finishRewardGranted,
},
}
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "23",
States: map[int64]dnfrepo.QuestState{
100: completedNoReward,
110: completedNoReward,
120: {Status: "active"},
},
}); err != nil {
t.Fatal(err)
}
definitions := []Definition{
{ID: 100, Path: "n_quest/producer_1.qst", Grade: "[epic]", Type: "[clear map]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 1}}},
{ID: 110, Path: "n_quest/producer_2.qst", Grade: "[epic]", Type: "[clear map]", Job: "[all]", LevelMin: 1, LevelMax: 99, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 1}}},
{ID: 120, Path: "n_quest/composite.qst", Grade: "[epic]", Type: "[quest clear]", Job: "[all]", LevelMin: 1, LevelMax: 99, ExposedByNPC: 1, IntData: []int64{121, 122}},
{ID: 121, Path: "n_quest/composite_hunt.qst", Grade: "[sub]", Type: "[hunt monster]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 120, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 2}}},
{ID: 122, Path: "n_quest/composite_exchange.qst", Grade: "[sub]", Type: "[seeking]", Job: "[all]", LevelMin: 1, LevelMax: 99, MainQuestID: 120, IntData: []int64{7001, 3}, RewardType: "[item]", RewardItems: []RewardItemRule{{ItemID: 7001, Count: 4}}},
}
catalog := &Catalog{ordered: definitions, byID: make(map[int64]Definition, len(definitions))}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyAutoCompleteCarryForwardRepair(ctx, catalog, AutoCompleteCarryForwardRepairInput{
CharacterID: "23",
RepairedAt: time.Date(2026, 9, 10, 13, 0, 0, 0, time.UTC),
AllocateItem: finishOwnerAllocator(nil),
})
if err != nil {
t.Fatalf("unreached composite must not fail the pass: %v", err)
}
// 1 + 1 withheld by the two auto-completed producers. The remaining two of
// the child step's three come from 121, which the player still plays.
if result.Idempotent || len(result.Items) != 1 || result.Items[0].ItemID != 7001 ||
result.Items[0].Delta != 2 || result.Items[0].PostCount != 2 {
t.Fatalf("withheld-share carry-forward result=%+v", result)
}
}
@@ -0,0 +1,295 @@
package quest
import (
"context"
"errors"
"reflect"
"testing"
"time"
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 TestOwnerAutoCompleteMainClosesOnlySelectedActiveEpicWithoutRewards(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{
CharacterID: "17",
AccountID: "dnf:1",
Job: "2",
Level: 90,
Stats: map[string]int64{"grow_type": 0, "sp": 777, "exp": 12345},
}
if err := repositories.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
inventory := dnfrepo.InventoryRecord{
CharacterID: "17",
Slots: map[string]dnfrepo.ItemStack{
"0:10": {ItemID: 9001, Count: 3},
},
}
if err := repositories.Inventory.Save(ctx, inventory); err != nil {
t.Fatal(err)
}
skill := dnfrepo.SkillRecord{CharacterID: "17", Skills: map[int64]dnfrepo.SkillState{1001: {Level: 5, Enabled: true}}}
if err := repositories.Skill.Save(ctx, skill); err != nil {
t.Fatal(err)
}
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "17",
States: map[int64]dnfrepo.QuestState{
100: {Status: "active", ProgressValue: 1},
102: {Status: "active", ProgressValue: 1},
103: {Status: "active", ProgressValue: 1},
106: {Status: "active", ProgressValue: 1},
},
Progress: map[int64]dnfrepo.QuestState{
150: {Status: "active", ProgressValue: 3},
},
}); err != nil {
t.Fatal(err)
}
catalog := autoCompleteMainTestCatalog(t)
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
completedAt := time.Date(2026, 7, 22, 7, 30, 0, 0, time.UTC)
result, err := owner.ApplyAutoCompleteMain(ctx, catalog, AutoCompleteMainInput{
CharacterID: "17",
Eligibility: CharacterEligibility{Level: 90, Job: 2, GrowType: 0},
CutoffLevel: 89,
TargetQuestID: 100,
CompletedAt: completedAt,
})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result.ChangedQuestIDs, []int64{100}) ||
!reflect.DeepEqual(result.ChangedLinkedSubIDs, []int64{150}) ||
result.ActiveCompletedCount != 1 || result.HighestCompletedQuest != 100 || result.Idempotent {
t.Fatalf("auto-complete result=%+v", result)
}
stored := result.PostCommitQuest
for _, questID := range []int64{100} {
state := stored.States[questID]
if state.Status != "completed" || state.ProgressValue != 0 ||
state.Extra["reward_state"] != finishRewardGranted ||
state.Extra["auto_complete_reason"] != "epic_below_character_level_no_reward" ||
state.Extra["auto_complete_level_cutoff"] != "89" {
t.Fatalf("completed epic quest %d state=%+v", questID, state)
}
}
if _, exists := stored.States[101]; exists {
t.Fatalf("absent successor quest 101 was manufactured: %+v", stored.States[101])
}
if acceptable := catalog.Acceptable(CharacterEligibility{Level: 90, Job: 2, GrowType: 0}, stored); !containsQuestID(acceptable.IDs, 101) {
t.Fatalf("successor main quest 101 missing after active-only completion: %v", acceptable.IDs)
}
if state := stored.Progress[150]; state.Status != "completed" || state.ProgressValue != 0 || state.Extra["auto_complete_reason"] != "no_reward_sub_quest" {
t.Fatalf("linked sub state=%+v", state)
}
for _, questID := range []int64{102, 103, 106} {
if state := stored.States[questID]; state.Status != "active" || state.ProgressValue != 1 {
t.Fatalf("non-target quest %d changed: %+v", questID, state)
}
}
for _, questID := range []int64{104, 105} {
if _, exists := stored.States[questID]; exists {
t.Fatalf("inapplicable/answer quest %d was manufactured", questID)
}
}
postCharacter, found, err := repositories.Character.Load(ctx, "17")
if err != nil || !found || !reflect.DeepEqual(postCharacter, character) {
t.Fatalf("character changed found=%t err=%v got=%+v want=%+v", found, err, postCharacter, character)
}
postInventory, found, err := repositories.Inventory.Load(ctx, "17")
if err != nil || !found || !reflect.DeepEqual(postInventory, inventory) {
t.Fatalf("inventory changed found=%t err=%v got=%+v want=%+v", found, err, postInventory, inventory)
}
postSkill, found, err := repositories.Skill.Load(ctx, "17")
if err != nil || !found || !reflect.DeepEqual(postSkill, skill) {
t.Fatalf("skill changed found=%t err=%v got=%+v want=%+v", found, err, postSkill, skill)
}
replayed, err := owner.ApplyAutoCompleteMain(ctx, catalog, AutoCompleteMainInput{
CharacterID: "17",
Eligibility: CharacterEligibility{Level: 90, Job: 2, GrowType: 0},
CutoffLevel: 89,
TargetQuestID: 100,
CompletedAt: completedAt.Add(time.Minute),
})
if err != nil {
t.Fatal(err)
}
if !replayed.Idempotent || len(replayed.ChangedQuestIDs) != 0 || len(replayed.ChangedLinkedSubIDs) != 0 {
t.Fatalf("idempotent replay=%+v", replayed)
}
}
func TestPlanAutoCompleteMainZeroTargetCompletesEligibleActiveAndUnacceptedMainQuests(t *testing.T) {
catalog := autoCompleteMainTestCatalog(t)
record := dnfrepo.QuestRecord{
CharacterID: "17",
States: map[int64]dnfrepo.QuestState{
100: {Status: "active", ProgressValue: 1},
106: {Status: "active", ProgressValue: 1},
},
}
plan, err := catalog.PlanAutoCompleteMainQuests(
record,
CharacterEligibility{Level: 90, Job: 2, GrowType: 2},
89,
0,
time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC),
)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(plan.EligibleQuestIDs, []int64{100, 101, 106}) ||
!reflect.DeepEqual(plan.ChangedQuestIDs, []int64{100, 101, 106}) ||
plan.ActiveCompletedCount != 3 || plan.HighestCompletedQuest != 106 {
t.Fatalf("zero-selector plan=%+v", plan)
}
for _, questID := range []int64{100, 101, 106} {
if state := plan.Record.States[questID]; state.Status != "completed" || state.Extra["auto_complete_reason"] != "epic_below_character_level_no_reward" {
t.Fatalf("bulk-completed quest %d state=%+v", questID, state)
}
}
if _, exists := plan.Record.States[105]; exists {
t.Fatalf("answer-branch quest 105 was completed without a persisted answer: %+v", plan.Record.States[105])
}
if record.States[100].Status != "active" || record.States[106].Status != "active" {
t.Fatalf("planning mutated source record=%+v", record)
}
}
func TestPlanAutoCompleteMainZeroTargetRejectsWhenNoEligibleMainExists(t *testing.T) {
catalog := autoCompleteMainTestCatalog(t)
_, err := catalog.PlanAutoCompleteMainQuests(
dnfrepo.QuestRecord{CharacterID: "17", States: map[int64]dnfrepo.QuestState{
102: {Status: "active", ProgressValue: 1},
103: {Status: "active", ProgressValue: 1},
}},
CharacterEligibility{Level: 90, Job: 2},
10,
0,
time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC),
)
if !errors.Is(err, ErrAutoCompleteTargetInvalid) {
t.Fatalf("zero target without eligible main quest error=%v", err)
}
}
func TestPlanAutoCompleteMainZeroTargetIncludesVerifiedLegacyMainStorySubQuests(t *testing.T) {
source := catalogTestSource{
DefaultList: "100 `parent.qst`\n3054 `clear.qst`\n3157 `hunt.qst`\n",
"n_quest/parent.qst": questCatalogTestDefinition("[epic]", 20, 99, "[gunner]", "[type]\n`[quest clear]`\n"),
"n_quest/clear.qst": questCatalogTestDefinition("[sub]", 20, 99, "[gunner]", "[type]\n`[clear map]`\n[main quest]\n100\n"),
"n_quest/hunt.qst": questCatalogTestDefinition("[sub]", 20, 99, "[gunner]", "[type]\n`[hunt enemy]`\n[main quest]\n100\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.PlanAutoCompleteMainQuests(
dnfrepo.QuestRecord{CharacterID: "17"},
CharacterEligibility{Level: 90, Job: 2, GrowType: 2},
89,
0,
time.Date(2026, 8, 10, 17, 30, 0, 0, time.UTC),
)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(plan.ChangedQuestIDs, []int64{100, 3054, 3157}) {
t.Fatalf("legacy main-story plan=%+v", plan)
}
for _, questID := range plan.ChangedQuestIDs {
if state := plan.Record.States[questID]; state.Status != "completed" {
t.Fatalf("quest %d state=%+v", questID, state)
}
}
}
func TestPlanAutoCompleteMainNeverClosesAwakeningStoryCanonical(t *testing.T) {
canonical := Definition{
ID: 3367,
Grade: "[epic]",
Type: "[clear map]",
LevelMin: 50,
LevelMax: 99,
Job: "[swordman]",
TargetCharacterRules: []TargetCharacterRule{{JobTag: "[swordman]", FirstGrowType: 2, AwakeningStage: 1}},
RewardType: "[item]",
RewardIntData: []int64{0, 0},
IntData: []int64{53546},
CollisionQuests: []int64{3371},
}
companion := Definition{
ID: 3371,
Grade: "[epic]",
Type: "[clear map]",
LevelMin: 50,
LevelMax: 99,
Job: "[swordman]",
JobChangeQuest: 2,
GrowType: 2,
HasGrowType: true,
RewardType: "[awakening type]",
RewardIntData: []int64{1},
IntData: []int64{53546},
CollisionQuests: []int64{3367},
}
catalog := &Catalog{
ordered: []Definition{canonical, companion},
byID: map[int64]Definition{3367: canonical, 3371: companion},
}
catalog.buildStoryAwakeningAliases()
_, err := catalog.PlanAutoCompleteMainQuests(
dnfrepo.QuestRecord{CharacterID: "awakening", States: map[int64]dnfrepo.QuestState{
3367: {Status: "active", ProgressValue: 1},
}},
CharacterEligibility{Level: 54, Job: 0, GrowType: 2},
53,
3367,
time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC),
)
if !errors.Is(err, ErrAutoCompleteTargetInvalid) {
t.Fatalf("awakening canonical was eligible for auto-complete: %v", err)
}
}
func autoCompleteMainTestCatalog(t *testing.T) *Catalog {
t.Helper()
source := catalogTestSource{
DefaultList: "100 `epic_start.qst`\n101 `epic_next.qst`\n102 `epic_level_90.qst`\n103 `side.qst`\n104 `other_job.qst`\n105 `answer.qst`\n106 `other_active_epic.qst`\n150 `linked_sub.qst`\n",
"n_quest/epic_start.qst": questCatalogTestDefinition("[epic]", 20, 99, "[gunner]", "[type]\n`[clear map]`\n"),
"n_quest/epic_next.qst": questCatalogTestDefinition("[epic]", 30, 99, "[gunner]", "[type]\n`[meet npc]`\n[pre required quest]\n100\n"),
"n_quest/epic_level_90.qst": questCatalogTestDefinition("[epic]", 90, 99, "[gunner]", "[type]\n`[meet npc]`\n[pre required quest]\n101\n"),
"n_quest/side.qst": questCatalogTestDefinition("[side]", 10, 99, "[gunner]", "[type]\n`[meet npc]`\n"),
"n_quest/other_job.qst": questCatalogTestDefinition("[epic]", 10, 99, "[fighter]", "[type]\n`[meet npc]`\n"),
"n_quest/answer.qst": questCatalogTestDefinition("[epic]", 40, 99, "[gunner]", "[type]\n`[meet npc]`\n[pre required quest answer]\n1\n"),
"n_quest/other_active_epic.qst": questCatalogTestDefinition("[epic]", 35, 99, "[gunner]", "[type]\n`[meet npc]`\n"),
"n_quest/linked_sub.qst": questCatalogTestDefinition("[sub]", 30, 99, "[gunner]", "[type]\n`[hunt enemy]`\n[main quest]\n100\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
}
@@ -0,0 +1,361 @@
package quest
import (
"context"
"errors"
"reflect"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestPlanAutoCompleteMainCompletesMissingPVFBoundNoRewardChildren(t *testing.T) {
catalog := autoCompleteQuestClearClosureCatalog(false)
completedAt := time.Date(2026, 8, 25, 15, 30, 0, 0, time.UTC)
plan, err := catalog.PlanAutoCompleteMainQuests(
dnfrepo.QuestRecord{CharacterID: "19"},
CharacterEligibility{Level: 90, Job: 0, GrowType: 2},
89,
0,
completedAt,
)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(plan.ChangedQuestIDs, []int64{3249}) ||
!reflect.DeepEqual(plan.ChangedLinkedSubIDs, []int64{3609, 3610}) ||
!reflect.DeepEqual(plan.ChangedFields, []dnfrepo.QuestField{dnfrepo.QuestFieldStates}) {
t.Fatalf("auto-complete closure=%+v", plan)
}
if state := plan.Record.States[3249]; state.Status != "completed" ||
state.Extra["auto_complete_reason"] != "epic_below_character_level_no_reward" {
t.Fatalf("parent state=%+v", state)
}
for _, questID := range []int64{3609, 3610} {
state := plan.Record.States[questID]
if state.Status != "completed" || state.ProgressValue != 0 ||
state.Extra["auto_complete_reason"] != "no_reward_sub_quest" ||
state.Extra["auto_completed_by_parent"] != "3249" ||
state.Extra["main_quest_id"] != "3249" || state.Extra["pvf_path"] == "" {
t.Fatalf("child %d state=%+v", questID, state)
}
}
}
// The task manual completes every main quest below the character's level except
// class-change and awakening quests, so an item reward on a child is no reason
// to strand the parent. The child closes with it, reward suppressed; what a
// later epic still has to hand in comes back through the carry-forward pass.
func TestPlanAutoCompleteMainClosesRewardBearingQuestClearChild(t *testing.T) {
catalog := autoCompleteQuestClearClosureCatalog(true)
plan, err := catalog.PlanAutoCompleteMainQuests(
dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
3249: {Status: "active"},
}},
CharacterEligibility{Level: 90, Job: 0},
89,
3249,
time.Date(2026, 8, 25, 15, 31, 0, 0, time.UTC),
)
if err != nil {
t.Fatalf("reward-bearing child auto-complete error=%v", err)
}
if len(plan.ChangedQuestIDs) != 1 || plan.ChangedQuestIDs[0] != 3249 {
t.Fatalf("changed parents=%v", plan.ChangedQuestIDs)
}
if len(plan.ChangedLinkedSubIDs) != 2 ||
plan.ChangedLinkedSubIDs[0] != 3609 || plan.ChangedLinkedSubIDs[1] != 3610 {
t.Fatalf("changed children=%v", plan.ChangedLinkedSubIDs)
}
// 3610 is the reward-bearing one in this fixture.
state, known := questStateFor(plan.Record, 3610)
if !known || !isCompletedQuestStatus(state.Status) ||
state.Extra["auto_completed"] != "true" ||
state.Extra["auto_complete_reason"] != "quest_clear_parent_closure" ||
state.Extra["reward_state"] != finishRewardGranted ||
state.Extra["auto_completed_by_parent"] != "3249" {
t.Fatalf("reward-bearing child state=%+v known=%t", state, known)
}
}
// A [question] child is the player's own branch choice and an awakening child
// has to be run, so either one still stops the parent.
func TestPlanAutoCompleteMainRefusesQuestionAndAwakeningQuestClearChildren(t *testing.T) {
for _, testCase := range []struct {
name string
child Definition
}{
{name: "question child", child: Definition{
ID: 3610, Path: "n_quest/branch.qst", Grade: "[sub]", Type: "[question]",
MainQuestID: 3249, NoExperience: true, RewardType: "[item]", RewardIntData: []int64{0, 0},
}},
{name: "awakening child", child: Definition{
ID: 3610, Path: "n_quest/awakening_child.qst", Grade: "[sub]", Type: "[meet npc]",
MainQuestID: 3249, NoExperience: true, JobChangeQuest: 2,
RewardType: "[awakening type]", RewardIntData: []int64{0, 0},
}},
} {
t.Run(testCase.name, func(t *testing.T) {
parent := Definition{
ID: 3249, Path: "n_quest/parent.qst", Grade: "[epic]", Type: "[quest clear]",
SubType: 2, IntData: []int64{3610}, LevelMin: 35, LevelMax: 99,
Job: "[all]", ExposedByNPC: 303, RewardType: "[item]", RewardIntData: []int64{0, 0},
}
catalog := &Catalog{
ordered: []Definition{parent, testCase.child},
byID: map[int64]Definition{3249: parent, 3610: testCase.child},
}
_, err := catalog.PlanAutoCompleteMainQuests(
dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
3249: {Status: "active"},
}},
CharacterEligibility{Level: 90, Job: 0},
89,
3249,
time.Date(2026, 8, 25, 15, 31, 0, 0, time.UTC),
)
if !errors.Is(err, ErrAutoCompleteTargetInvalid) {
t.Fatalf("closure error=%v want target invalid", err)
}
})
}
}
func TestOwnerReconcilesHistoricalAutoCompletedQuestClearClosure(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", Job: "0", Level: 90}); err != nil {
t.Fatal(err)
}
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3249: {
Status: "completed",
Extra: map[string]string{
"auto_completed": "true",
"auto_complete_reason": "epic_below_character_level_no_reward",
"reward_state": finishRewardGranted,
},
},
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
reconciledAt := time.Date(2026, 8, 25, 15, 32, 0, 0, time.UTC)
result, err := owner.ApplyAutoCompletedLinkedSubQuestReconciliation(
ctx,
autoCompleteQuestClearClosureCatalog(false),
"19",
reconciledAt,
)
if err != nil {
t.Fatal(err)
}
if result.Idempotent || len(result.Completions) != 2 ||
!result.Completions[0].Synthesized || !result.Completions[1].Synthesized {
t.Fatalf("reconcile result=%+v", result)
}
for _, questID := range []int64{3609, 3610} {
state := result.PostCommit.States[questID]
if state.Status != "completed" || state.Extra["auto_completed_by_parent"] != "3249" {
t.Fatalf("reconciled child %d state=%+v", questID, state)
}
}
replay, err := owner.ApplyAutoCompletedLinkedSubQuestReconciliation(
ctx,
autoCompleteQuestClearClosureCatalog(false),
"19",
reconciledAt.Add(time.Second),
)
if err != nil || !replay.Idempotent || len(replay.Completions) != 0 {
t.Fatalf("reconcile replay=%+v err=%v", replay, err)
}
}
func autoCompleteQuestClearClosureCatalog(rewardBearing bool) *Catalog {
childReward := []int64{0, 0}
childItems := []RewardItemRule(nil)
if rewardBearing {
childReward = []int64{10164786, 1}
childItems = []RewardItemRule{{ItemID: 10164786, Count: 1}}
}
parent := Definition{
ID: 3249, Path: "n_quest/quest_renewal/epic_quest/alphraira/alphraira_epic_26.qst",
Grade: "[epic]", Type: "[quest clear]", SubType: 2, IntData: []int64{3609, 3610},
LevelMin: 35, LevelMax: 99, Job: "[all]", ExposedByNPC: 303,
RewardType: "[item]", RewardIntData: []int64{0, 0},
}
first := Definition{
ID: 3609, Path: "n_quest/quest_renewal/epic_quest/alphraira/alphraira_epic_26_01.qst",
Grade: "[sub]", Type: "[meet npc]", MainQuestID: 3249, NoExperience: true,
RewardType: "[item]", RewardIntData: []int64{0, 0},
}
second := Definition{
ID: 3610, Path: "n_quest/quest_renewal/epic_quest/alphraira/alphraira_epic_26_02.qst",
Grade: "[sub]", Type: "[seek n meet npc]", MainQuestID: 3249, NoExperience: true,
RewardType: "[item]", RewardIntData: childReward, RewardItems: childItems,
}
return &Catalog{
ordered: []Definition{parent, first, second},
byID: map[int64]Definition{3249: parent, 3609: first, 3610: second},
}
}
// An empty bulk result on an untransferred character is reported as the missing
// class change, because the story chain past the first awakening level is gated
// on the awakening quest group and a bulk pass must never complete that for the
// player. Jobs 9 and 10 have no class change and stay exempt, and a request
// that still has work to do is never rejected.
func TestPlanAutoCompleteMainReportsMissingClassChangeOnEmptyBulkResult(t *testing.T) {
catalog := autoCompleteQuestClearClosureCatalog(false)
completedAt := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC)
exhausted := dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
3249: {Status: "completed"},
3609: {Status: "completed"},
3610: {Status: "completed"},
}}
for _, testCase := range []struct {
name string
record dnfrepo.QuestRecord
job int
growType int
cutoff int
wantErr error
}{
{name: "untransferred and exhausted", record: exhausted, job: 0, growType: 0, cutoff: 89, wantErr: ErrAutoCompleteClassRequired},
{name: "transferred and exhausted", record: exhausted, job: 0, growType: 2, cutoff: 89, wantErr: ErrAutoCompleteTargetInvalid},
{name: "awakened and exhausted", record: exhausted, job: 0, growType: 0x12, cutoff: 89, wantErr: ErrAutoCompleteTargetInvalid},
{name: "below awakening cutoff", record: exhausted, job: 0, growType: 0, cutoff: 49, wantErr: ErrAutoCompleteTargetInvalid},
{name: "job without class change", record: exhausted, job: 9, growType: 0, cutoff: 89, wantErr: ErrAutoCompleteTargetInvalid},
{name: "untransferred with work left", record: dnfrepo.QuestRecord{CharacterID: "19"}, job: 0, growType: 0, cutoff: 89},
} {
t.Run(testCase.name, func(t *testing.T) {
_, err := catalog.PlanAutoCompleteMainQuests(
testCase.record,
CharacterEligibility{Level: 90, Job: testCase.job, GrowType: testCase.growType},
testCase.cutoff,
0,
completedAt,
)
if testCase.wantErr == nil {
if err != nil {
t.Fatalf("plan err=%v, want a completed plan", err)
}
return
}
if !errors.Is(err, testCase.wantErr) {
t.Fatalf("plan err=%v want=%v", err, testCase.wantErr)
}
})
}
}
// The story chain standing on an awakening quest is the common empty-bulk case,
// and the quest is still sitting in the player's list. Report which one blocks
// the pass instead of "nothing left", and never complete it here.
func TestPlanAutoCompleteMainNamesTheBlockingAwakeningQuest(t *testing.T) {
awakening := Definition{
ID: 4000, Path: "n_quest/awakening.qst", Name: "依然存在的不祥之感",
Grade: "[epic]", Type: "[clear map]", LevelMin: 50, LevelMax: 99,
Job: "[swordman]", ExposedByNPC: 28, GrowType: 3, HasGrowType: true,
JobChangeQuest: 2, RewardType: "[awakening type]",
}
successor := Definition{
ID: 4001, Path: "n_quest/after_awakening.qst", Name: "银冠之镜",
Grade: "[epic]", Type: "[meet npc]", LevelMin: 50, LevelMax: 99,
Job: "[all]", ExposedByNPC: 28, PreRequiredGroups: [][]int64{{4000}},
}
catalog := &Catalog{
ordered: []Definition{awakening, successor},
byID: map[int64]Definition{4000: awakening, 4001: successor},
}
_, err := catalog.PlanAutoCompleteMainQuests(
dnfrepo.QuestRecord{CharacterID: "19"},
CharacterEligibility{Level: 90, Job: 0, GrowType: 0x13},
89,
0,
time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC),
)
if !errors.Is(err, ErrAutoCompleteAwakeningRequired) {
t.Fatalf("plan err=%v want awakening required", err)
}
var blocked *AwakeningRequiredError
if !errors.As(err, &blocked) || blocked.QuestID != 4000 || blocked.QuestName != "依然存在的不祥之感" {
t.Fatalf("blocking quest=%+v err=%v", blocked, err)
}
// Completing the awakening quest by hand unblocks the rest of the chain.
plan, err := catalog.PlanAutoCompleteMainQuests(
dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
4000: {Status: "completed"},
}},
CharacterEligibility{Level: 90, Job: 0, GrowType: 0x13},
89,
0,
time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC),
)
if err != nil || len(plan.ChangedQuestIDs) != 1 || plan.ChangedQuestIDs[0] != 4001 {
t.Fatalf("post-awakening plan changed=%v err=%v", plan.ChangedQuestIDs, err)
}
}
// The Lv87 Zelba chain stands on 3868 冒险家联盟的劝说和选择, a [question] quest
// whose answer gates every successor through [pre required quest answer]. The
// pass must not answer it, so an empty bulk result there has to name it rather
// than claim there is nothing left.
func TestPlanAutoCompleteMainNamesTheBlockingChoiceQuest(t *testing.T) {
choice := Definition{
ID: 3868, Path: "n_quest/branch.qst", Name: "冒险家联盟的劝说和选择",
Grade: "[epic]", Type: "[question]", LevelMin: 87, LevelMax: 99,
Job: "[all]", ExposedByNPC: 331,
}
branchA := Definition{
ID: 3870, Path: "n_quest/branch_a.qst", Name: "联盟路线",
Grade: "[epic]", Type: "[meet npc]", LevelMin: 87, LevelMax: 99,
Job: "[all]", ExposedByNPC: 331,
PreRequiredAnswers: []QuestAnswerRequirement{{QuestID: 3868, Answer: 1}},
}
catalog := &Catalog{
ordered: []Definition{choice, branchA},
byID: map[int64]Definition{3868: choice, 3870: branchA},
}
record := dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
3868: {Status: "active"},
}}
_, err := catalog.PlanAutoCompleteMainQuests(
record,
CharacterEligibility{Level: 90, Job: 0, GrowType: 0x13},
89,
0,
time.Date(2026, 9, 10, 14, 0, 0, 0, time.UTC),
)
if !errors.Is(err, ErrAutoCompleteChoiceRequired) {
t.Fatalf("plan err=%v want choice required", err)
}
var blocked *ChoiceRequiredError
if !errors.As(err, &blocked) || blocked.QuestID != 3868 || blocked.QuestName != "冒险家联盟的劝说和选择" {
t.Fatalf("blocking choice=%+v err=%v", blocked, err)
}
// Once the player has answered, the branch the answer opens completes.
answered := dnfrepo.CloneQuest(record)
answered.States[3868] = dnfrepo.QuestState{
Status: "completed",
Extra: map[string]string{questAnswerExtraKey: "1"},
}
plan, err := catalog.PlanAutoCompleteMainQuests(
answered,
CharacterEligibility{Level: 90, Job: 0, GrowType: 0x13},
89,
0,
time.Date(2026, 9, 10, 14, 0, 0, 0, time.UTC),
)
if err != nil || len(plan.ChangedQuestIDs) != 1 || plan.ChangedQuestIDs[0] != 3870 {
t.Fatalf("post-answer plan changed=%v err=%v", plan.ChangedQuestIDs, err)
}
}
@@ -0,0 +1,152 @@
package quest
import (
"context"
"sort"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// AutoCompleteQuestionRepairResult describes the narrow reconnect repair for
// question quests that an older task-manual bulk completion closed without a
// player-authored answer. Ordinary question completions and every non-question
// quest remain untouched.
type AutoCompleteQuestionRepairResult struct {
CharacterID string
RepairedQuestIDs []int64
Record dnfrepo.QuestRecord
Idempotent bool
}
type autoCompleteQuestionRepairPlan struct {
Record dnfrepo.QuestRecord
RepairedQuestIDs []int64
ChangedFields []dnfrepo.QuestField
}
// questionHasAnswerBranches reports whether any catalog quest gates itself on a
// selected answer of the given question. Only such questions carry a player
// choice worth restoring; a question without dependents has nothing to block.
func (c *Catalog) questionHasAnswerBranches(questID int64) bool {
if c == nil || questID <= 0 {
return false
}
for _, definition := range c.ordered {
for _, requirement := range definition.PreRequiredAnswers {
if requirement.QuestID == questID {
return true
}
}
}
return false
}
func (c *Catalog) planAutoCompleteQuestionRepair(
record dnfrepo.QuestRecord,
repairedAt time.Time,
) (autoCompleteQuestionRepairPlan, error) {
if c == nil {
return autoCompleteQuestionRepairPlan{}, ErrCatalogEmpty
}
plan := autoCompleteQuestionRepairPlan{Record: dnfrepo.CloneQuest(record)}
repair := func(field dnfrepo.QuestField, states map[int64]dnfrepo.QuestState) {
for questID, state := range states {
if !isCompletedQuestStatus(state.Status) ||
strings.TrimSpace(state.Extra[questAnswerExtraKey]) != "" ||
state.Extra["auto_completed"] != "true" ||
state.Extra["auto_complete_reason"] != "epic_below_character_level_no_reward" {
continue
}
definition, found := c.Find(questID)
if !found || normalizeQuestTag(definition.Type) != "question" ||
!c.questionHasAnswerBranches(questID) {
continue
}
delete(states, questID)
plan.RepairedQuestIDs = appendQuestIDOnce(plan.RepairedQuestIDs, questID)
plan.ChangedFields = mergeQuestChangedField(plan.ChangedFields, field)
}
}
repair(dnfrepo.QuestFieldStates, plan.Record.States)
repair(dnfrepo.QuestFieldProgress, plan.Record.Progress)
if len(plan.RepairedQuestIDs) > 0 {
sort.Slice(plan.RepairedQuestIDs, func(left, right int) bool {
return plan.RepairedQuestIDs[left] < plan.RepairedQuestIDs[right]
})
plan.Record.UpdatedAt = repairedAt.UTC()
}
return plan, nil
}
// ApplyAutoCompleteQuestionRepair reopens only malformed question rows created
// by the older no-reward bulk path. Removing the row makes the PVF question
// acceptable again; the ordinary accept/answer flow then records the player's
// own choice and unlocks exactly one dependent branch.
func (o *Owner) ApplyAutoCompleteQuestionRepair(
ctx context.Context,
catalog *Catalog,
characterID string,
repairedAt time.Time,
) (AutoCompleteQuestionRepairResult, error) {
if o == nil || o.characters == nil || o.quests == nil || catalog == nil {
return AutoCompleteQuestionRepairResult{}, ErrOwnerUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return AutoCompleteQuestionRepairResult{}, ErrCharacterRequired
}
if repairedAt.IsZero() {
repairedAt = time.Now().UTC()
}
if ctx == nil {
ctx = context.Background()
}
if _, found, err := o.characters.Load(ctx, characterID); err != nil {
return AutoCompleteQuestionRepairResult{}, err
} else if !found {
return AutoCompleteQuestionRepairResult{}, ErrCharacterNotFound
}
record, found, err := o.quests.Load(ctx, characterID)
if err != nil {
return AutoCompleteQuestionRepairResult{}, err
}
if !found {
return AutoCompleteQuestionRepairResult{CharacterID: characterID, Idempotent: true}, nil
}
if strings.TrimSpace(record.CharacterID) == "" {
record.CharacterID = characterID
}
plan, err := catalog.planAutoCompleteQuestionRepair(record, repairedAt)
if err != nil {
return AutoCompleteQuestionRepairResult{}, err
}
result := AutoCompleteQuestionRepairResult{
CharacterID: characterID,
RepairedQuestIDs: append([]int64(nil), plan.RepairedQuestIDs...),
Record: dnfrepo.CloneQuest(plan.Record),
Idempotent: len(plan.RepairedQuestIDs) == 0,
}
if result.Idempotent {
return result, nil
}
plan.Record.CharacterID = characterID
if err := dnfrepo.SaveQuestFields(ctx, o.quests, plan.Record, plan.ChangedFields...); err != nil {
return AutoCompleteQuestionRepairResult{}, err
}
persisted, found, err := o.quests.Load(ctx, characterID)
if err != nil {
return AutoCompleteQuestionRepairResult{}, err
}
if !found {
return AutoCompleteQuestionRepairResult{}, ErrQuestPersistVerify
}
for _, questID := range result.RepairedQuestIDs {
if _, stillPresent := questStateFor(persisted, questID); stillPresent {
return AutoCompleteQuestionRepairResult{}, ErrQuestPersistVerify
}
}
result.Record = dnfrepo.CloneQuest(persisted)
return result, nil
}
@@ -0,0 +1,152 @@
package quest
import (
"context"
"errors"
"reflect"
"testing"
"time"
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 TestPlanAutoCompleteMainStopsBeforePlayerQuestion(t *testing.T) {
source := catalogTestSource{
DefaultList: "100 `before.qst`\n101 `question.qst`\n102 `answer.qst`\n103 `independent.qst`\n",
"n_quest/before.qst": questCatalogTestDefinition("[epic]", 20, 99, "[gunner]",
"[type]\n`[meet npc]`\n"),
"n_quest/question.qst": questCatalogTestDefinition("[epic]", 30, 99, "[gunner]",
"[type]\n`[question]`\n[pre required quest]\n100\n"),
"n_quest/answer.qst": questCatalogTestDefinition("[epic]", 30, 99, "[gunner]",
"[type]\n`[meet npc]`\n[pre required quest answer]\n101 0\n"),
"n_quest/independent.qst": questCatalogTestDefinition("[epic]", 25, 99, "[gunner]",
"[type]\n`[meet npc]`\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)
}
record := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
100: {Status: "active"},
}}
plan, err := catalog.PlanAutoCompleteMainQuests(
record,
CharacterEligibility{Level: 90, Job: 2, GrowType: 2},
89,
0,
time.Date(2026, 8, 22, 13, 0, 0, 0, time.UTC),
)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(plan.ChangedQuestIDs, []int64{100, 103}) {
t.Fatalf("changed quests=%v want [100 103]", plan.ChangedQuestIDs)
}
if _, completed := questStateFor(plan.Record, 101); completed {
t.Fatalf("player question was auto-completed: %+v", plan.Record)
}
acceptable := catalog.Acceptable(CharacterEligibility{Level: 90, Job: 2, GrowType: 2}, plan.Record)
if !containsCatalogQuestID(acceptable.IDs, 101) || containsCatalogQuestID(acceptable.IDs, 102) {
t.Fatalf("acceptable after bulk=%v want question 101 only", acceptable.IDs)
}
if _, err := catalog.PlanAutoCompleteMainQuests(
dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
100: {Status: "completed"},
101: {Status: "active"},
}},
CharacterEligibility{Level: 90, Job: 2, GrowType: 2},
89,
101,
time.Date(2026, 8, 22, 13, 0, 0, 0, time.UTC),
); !errors.Is(err, ErrAutoCompleteTargetInvalid) {
t.Fatalf("exact question auto-complete error=%v", err)
}
}
func TestOwnerRepairsOnlyAutoCompletedQuestionWithoutPlayerAnswer(t *testing.T) {
ctx := context.Background()
source := catalogTestSource{
DefaultList: "100 `before.qst`\n101 `broken_question.qst`\n102 `broken_answer.qst`\n201 `valid_question.qst`\n202 `valid_answer.qst`\n300 `ordinary.qst`\n",
"n_quest/before.qst": questCatalogTestDefinition("[epic]", 20, 99, "[gunner]", "[type]\n`[meet npc]`\n"),
"n_quest/broken_question.qst": questCatalogTestDefinition("[epic]", 30, 99, "[gunner]", "[type]\n`[question]`\n[pre required quest]\n100\n"),
"n_quest/broken_answer.qst": questCatalogTestDefinition("[epic]", 30, 99, "[gunner]", "[type]\n`[meet npc]`\n[pre required quest answer]\n101 0\n"),
"n_quest/valid_question.qst": questCatalogTestDefinition("[epic]", 40, 99, "[gunner]", "[type]\n`[question]`\n"),
"n_quest/valid_answer.qst": questCatalogTestDefinition("[epic]", 40, 99, "[gunner]", "[type]\n`[meet npc]`\n[pre required quest answer]\n201 1\n"),
"n_quest/ordinary.qst": questCatalogTestDefinition("[epic]", 50, 99, "[gunner]", "[type]\n`[meet npc]`\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)
}
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "17", Job: "2", Level: 90, Stats: map[string]int64{"grow_type": 2},
}); err != nil {
t.Fatal(err)
}
autoCompleted := func() dnfrepo.QuestState {
return dnfrepo.QuestState{Status: "completed", Extra: map[string]string{
"auto_completed": "true",
"auto_complete_reason": "epic_below_character_level_no_reward",
}}
}
record := dnfrepo.QuestRecord{CharacterID: "17", States: map[int64]dnfrepo.QuestState{
100: autoCompleted(),
101: autoCompleted(),
201: {Status: "completed", Extra: map[string]string{
"auto_completed": "true",
"auto_complete_reason": "epic_below_character_level_no_reward",
questAnswerExtraKey: "1",
}},
300: autoCompleted(),
}}
if err := repositories.Quest.Save(ctx, record); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyAutoCompleteQuestionRepair(
ctx, catalog, "17", time.Date(2026, 8, 22, 13, 30, 0, 0, time.UTC),
)
if err != nil {
t.Fatal(err)
}
if result.Idempotent || !reflect.DeepEqual(result.RepairedQuestIDs, []int64{101}) {
t.Fatalf("repair result=%+v", result)
}
if _, found := questStateFor(result.Record, 101); found {
t.Fatalf("broken question remained: %+v", result.Record)
}
for _, preserved := range []int64{100, 201, 300} {
if _, found := questStateFor(result.Record, preserved); !found {
t.Fatalf("quest %d was removed: %+v", preserved, result.Record)
}
}
persisted, found, err := repositories.Quest.Load(ctx, "17")
if err != nil || !found {
t.Fatalf("persisted quest record found=%t err=%v", found, err)
}
if _, present := questStateFor(persisted, 101); present {
t.Fatalf("broken question still persisted: %+v", persisted)
}
acceptable := catalog.Acceptable(CharacterEligibility{Level: 90, Job: 2, GrowType: 2}, result.Record)
if !containsCatalogQuestID(acceptable.IDs, 101) {
t.Fatalf("repaired question not acceptable: %v", acceptable.IDs)
}
replay, err := owner.ApplyAutoCompleteQuestionRepair(ctx, catalog, "17", time.Now().UTC())
if err != nil || !replay.Idempotent || len(replay.RepairedQuestIDs) != 0 {
t.Fatalf("repair replay=%+v err=%v", replay, err)
}
}
@@ -0,0 +1,79 @@
package quest
import (
"context"
"fmt"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type AutoCompletedLinkedSubQuestReconcileResult struct {
CharacterID string
Completions []AutoCompletedLinkedSubQuestCompletion
ChangedFields []dnfrepo.QuestField
PostCommit dnfrepo.QuestRecord
Idempotent bool
}
func (o *Owner) ApplyAutoCompletedLinkedSubQuestReconciliation(
ctx context.Context,
catalog *Catalog,
characterID string,
reconciledAt time.Time,
) (AutoCompletedLinkedSubQuestReconcileResult, error) {
if o == nil || o.characters == nil || o.quests == nil {
return AutoCompletedLinkedSubQuestReconcileResult{}, ErrOwnerUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return AutoCompletedLinkedSubQuestReconcileResult{}, ErrCharacterRequired
}
if _, found, err := o.characters.Load(ctx, characterID); err != nil {
return AutoCompletedLinkedSubQuestReconcileResult{}, err
} else if !found {
return AutoCompletedLinkedSubQuestReconcileResult{}, ErrCharacterNotFound
}
record, found, err := o.quests.Load(ctx, characterID)
if err != nil {
return AutoCompletedLinkedSubQuestReconcileResult{}, err
}
if !found {
return AutoCompletedLinkedSubQuestReconcileResult{CharacterID: characterID, Idempotent: true}, nil
}
plan, err := catalog.PlanAutoCompletedLinkedSubQuestReconciliation(record, reconciledAt)
if err != nil {
return AutoCompletedLinkedSubQuestReconcileResult{}, err
}
result := AutoCompletedLinkedSubQuestReconcileResult{
CharacterID: characterID,
Completions: append([]AutoCompletedLinkedSubQuestCompletion(nil), plan.Completions...),
ChangedFields: append([]dnfrepo.QuestField(nil), plan.ChangedFields...),
PostCommit: dnfrepo.CloneQuest(plan.Record),
Idempotent: len(plan.ChangedFields) == 0,
}
if len(plan.ChangedFields) == 0 {
return result, nil
}
if err := dnfrepo.SaveQuestFields(ctx, o.quests, plan.Record, plan.ChangedFields...); err != nil {
return AutoCompletedLinkedSubQuestReconcileResult{}, err
}
persisted, found, err := o.quests.Load(ctx, characterID)
if err != nil {
return AutoCompletedLinkedSubQuestReconcileResult{}, err
}
if !found {
return AutoCompletedLinkedSubQuestReconcileResult{}, ErrQuestPersistVerify
}
for _, completion := range result.Completions {
state, known := questStateFor(persisted, completion.QuestID)
if !known || !isCompletedQuestStatus(state.Status) ||
state.Extra["auto_completed_by_parent"] != fmt.Sprint(completion.ParentID) ||
state.Extra["reward_state"] != finishRewardGranted {
return AutoCompletedLinkedSubQuestReconcileResult{}, ErrQuestPersistVerify
}
}
result.PostCommit = dnfrepo.CloneQuest(persisted)
return result, nil
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,817 @@
package quest
import (
"context"
"errors"
"reflect"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type catalogTestSource map[string]string
func (s catalogTestSource) ReadText(relativePath string) (string, error) {
value, ok := s[relativePath]
if !ok {
return "", errors.New("missing test PVF path: " + relativePath)
}
return value, nil
}
func TestCatalogAcceptableUsesPVFCharacterAndQuestState(t *testing.T) {
source := catalogTestSource{
DefaultList: "1 `main.qst`\n2 `next.qst`\n3 `active.qst`\n4 `event.qst`\n5 `other_job.qst`\n6 `answer.qst`\n7 `collision.qst`\n",
"n_quest/main.qst": questCatalogTestDefinition("[epic]", 1, 10, "[gunner]", ""),
"n_quest/next.qst": questCatalogTestDefinition("[epic]", 2, 10, "[gunner]", "[pre required quest]\n1\n"),
"n_quest/active.qst": questCatalogTestDefinition("[normal]", 1, 10, "[gunner]", ""),
"n_quest/event.qst": questCatalogTestDefinition("[epic]", 1, 10, "[gunner]", "[event]\n1\n"),
"n_quest/other_job.qst": questCatalogTestDefinition("[epic]", 1, 10, "[fighter]", ""),
"n_quest/answer.qst": questCatalogTestDefinition("[epic]", 1, 10, "[gunner]", "[pre required quest answer]\n1\n"),
"n_quest/collision.qst": questCatalogTestDefinition("[epic]", 1, 10, "[gunner]", "[collision quest]\n1\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)
}
record := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
3: {Status: "active"},
}}
fresh := catalog.Acceptable(CharacterEligibility{Level: 1, Job: 2, GrowType: 0}, record)
if !reflect.DeepEqual(fresh.IDs, []int32{1, 7}) || fresh.EpicCount != 2 {
t.Fatalf("fresh acceptable=%v epic=%d, want [1 7] epic=2", fresh.IDs, fresh.EpicCount)
}
listed := catalog.QuestList(CharacterEligibility{Level: 1, Job: 2, GrowType: 0}, record)
if !reflect.DeepEqual(listed.IDs, []int32{1, 3, 7}) || listed.EpicCount != 2 || listed.ActiveCount != 1 {
t.Fatalf("fresh quest list=%v epic=%d active=%d, want [1 3 7] epic=2 active=1", listed.IDs, listed.EpicCount, listed.ActiveCount)
}
record.States[1] = dnfrepo.QuestState{Status: "completed"}
advanced := catalog.Acceptable(CharacterEligibility{Level: 2, Job: 2, GrowType: 0}, record)
if !reflect.DeepEqual(advanced.IDs, []int32{2}) || advanced.EpicCount != 1 {
t.Fatalf("advanced acceptable=%v epic=%d, want [2] epic=1", advanced.IDs, advanced.EpicCount)
}
advancedList := catalog.QuestList(CharacterEligibility{Level: 2, Job: 2, GrowType: 0}, record)
if !reflect.DeepEqual(advancedList.IDs, []int32{2, 3}) || advancedList.EpicCount != 1 || advancedList.ActiveCount != 1 {
t.Fatalf("advanced quest list=%v epic=%d active=%d, want [2 3] epic=1 active=1", advancedList.IDs, advancedList.EpicCount, advancedList.ActiveCount)
}
}
func TestCatalogSeekingDungeonScopesInheritMatchingPVFSibling(t *testing.T) {
source := catalogTestSource{
DefaultList: "8557 `event.qst`\n13702 `other.qst`\n",
"n_quest/event.qst": "[name]\n`进攻准备`\n[type]\n`[seeking]`\n[int data]\n10094602 10\n",
"n_quest/regular.qst": "[name]\n`进攻准备`\n[type]\n`[seeking]`\n[int data]\n10094602 3\n[dungeon info]\n3200 -1\n[/dungeon info]\n",
"n_quest/other.qst": "[name]\n`进攻准备`\n[type]\n`[seeking]`\n[int data]\n10094699 10\n[dungeon info]\n9999 -1\n[/dungeon info]\n",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{
Lists: []string{DefaultList}, Paths: []string{"n_quest/regular.qst"},
})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
if got := catalog.SeekingItemTargets(8557); !reflect.DeepEqual(got, []SeekingItemTarget{{ItemID: 10094602, Count: 10}}) {
t.Fatalf("event seeking targets=%+v", got)
}
want := []DungeonScope{{DungeonID: 3200, Difficulty: -1}}
if got := catalog.SeekingDungeonScopes(8557); !reflect.DeepEqual(got, want) {
t.Fatalf("event inherited scopes=%+v want=%+v", got, want)
}
if snapshot := catalog.Snapshot(); snapshot.ScopeHints != 1 {
t.Fatalf("scope hints=%d want=1", snapshot.ScopeHints)
}
}
func TestCatalogAcceptableMissionUsesRaidPhaseClearCounts(t *testing.T) {
source := catalogTestSource{
DefaultList: "12829 `anton_enter.qst`\n12830 `anton_mission.qst`\n",
"n_quest/anton_enter.qst": questCatalogTestDefinition("[side]", 85, 99, "[all]", ""),
"n_quest/anton_mission.qst": questCatalogTestDefinition("[mission]", 85, 99, "[all]",
"[type]\n`[raid phase clear]`\n[pre required quest]\n12829\n[int data]\n0 0 5 -1 1 0 5 -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)
}
character := CharacterEligibility{Level: 90, Job: 2}
if got := catalog.Acceptable(character, dnfrepo.QuestRecord{}).IDs; !reflect.DeepEqual(got, []int32{12829}) {
t.Fatalf("before prerequisite acceptable=%v want [12829]", got)
}
record := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{12829: {Status: "completed"}}}
if got := catalog.Acceptable(character, record).IDs; !reflect.DeepEqual(got, []int32{12830}) {
t.Fatalf("after prerequisite acceptable=%v want [12830]", got)
}
plan, err := catalog.PlanAccept(character, record, 12830)
if err != nil {
t.Fatal(err)
}
if want := packTriggerChannels(5, 5); plan.InitTrigger != want {
t.Fatalf("raid mission init trigger=%#x want=%#x", plan.InitTrigger, want)
}
}
func TestExpertJobTransitionQuestDirectoryPreservesChainAndTerminals(t *testing.T) {
catalog := &Catalog{ordered: []Definition{
{ID: 2702, JobChangeQuest: 20},
{ID: 10020, JobChangeQuest: 20, PreRequiredGroups: [][]int64{{2702}}},
{ID: 10021, JobChangeQuest: 20, PreRequiredGroups: [][]int64{{10020}}},
{ID: 9000, JobChangeQuest: 2},
}}
if got := catalog.ExpertJobTransitionQuestIDs(); !reflect.DeepEqual(got, []int64{2702, 10020, 10021}) {
t.Fatalf("transition quests=%v", got)
}
if got := catalog.ExpertJobTransitionTerminalQuestIDs(); !reflect.DeepEqual(got, []int64{10021}) {
t.Fatalf("terminal transition quests=%v", got)
}
}
func TestCatalogActiveMeetingNPCTriggersUsesPVFTargetAndCompoundOrder(t *testing.T) {
catalog := &Catalog{byID: map[int64]Definition{
100: {ID: 100, Type: "[meet npc]", IntData: []int64{347}},
101: {ID: 101, Type: "[seek n meet npc]", IntData: []int64{10158964, 5, 347}},
102: {ID: 102, Type: "[meet npc]", IntData: []int64{348}},
103: {ID: 103, Type: "[meet secret npc]", IntData: []int64{347}},
}}
record := dnfrepo.QuestRecord{
States: map[int64]dnfrepo.QuestState{
101: {Status: "active", ProgressValue: 517},
102: {Status: "active", ProgressValue: 1},
103: {Status: "completed"},
},
Progress: map[int64]dnfrepo.QuestState{
100: {Status: "in_progress", ProgressValue: 1},
101: {Status: "active", ProgressValue: 517},
},
}
want := []MeetingNPCTrigger{
{QuestID: 100, TriggerType: 0},
{QuestID: 101, TriggerType: 0x10},
{QuestID: 101, TriggerType: 0x20},
}
if got := catalog.ActiveMeetingNPCTriggers(record, 347); !reflect.DeepEqual(got, want) {
t.Fatalf("meeting triggers=%+v want=%+v", got, want)
}
if got := catalog.ActiveMeetingNPCTriggers(record, 999); len(got) != 0 {
t.Fatalf("unknown NPC triggers=%+v", got)
}
record.States[101] = dnfrepo.QuestState{Status: "active", ProgressValue: 5}
if got := catalog.ActiveMeetingNPCTriggers(record, 347); !reflect.DeepEqual(got, []MeetingNPCTrigger{
{QuestID: 100, TriggerType: 0},
{QuestID: 101, TriggerType: 0x10},
}) {
t.Fatalf("already-met compound triggers=%+v", got)
}
}
func TestCatalogAcceptableAppliesGrowType(t *testing.T) {
source := catalogTestSource{
DefaultList: "10 `grow.qst`\n11 `job_change.qst`\n12 `second_awaken.qst`\n",
"n_quest/grow.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]", "[grow type]\n2\n"),
"n_quest/job_change.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]", "[grow type]\n2\n[job change quest]\n2\n"),
"n_quest/second_awaken.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]", "[grow type]\n2\n[job change quest]\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)
}
got := catalog.Acceptable(CharacterEligibility{Level: 20, Job: 2, GrowType: 0x12}, dnfrepo.QuestRecord{})
if !reflect.DeepEqual(got.IDs, []int32{11, 12}) {
t.Fatalf("grow acceptable=%v, want [11 12]", got.IDs)
}
}
func TestCatalogPlanAcceptUsesCSharpInitialTriggerRulesAndProjectsEventItems(t *testing.T) {
source := catalogTestSource{
DefaultList: "3145 `clear_map.qst`\n3146 `event_item.qst`\n3147 `seek_meet.qst`\n3148 `quest_clear.qst`\n3149 `fallback.qst`\n",
"n_quest/clear_map.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]",
"[type]\n`[clear map]`\n[int data]\n76126\n"),
"n_quest/event_item.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]",
"[type]\n`[clear map]`\n[int data]\n76126\n[depend give item]\n1001 1\n"),
"n_quest/seek_meet.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]",
"[type]\n`[seek n meet npc]`\n[int data]\n1001 1 1\n"),
"n_quest/quest_clear.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]",
"[type]\n`[quest clear]`\n[int data]\n1 2 3\n"),
"n_quest/fallback.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]",
"[type]\n`[custom client only type]`\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)
}
character := CharacterEligibility{Level: 1, Job: 2, GrowType: 0}
plan, err := catalog.PlanAccept(character, dnfrepo.QuestRecord{}, 3145)
if err != nil {
t.Fatalf("PlanAccept clear-map error = %v", err)
}
if plan.QuestID != 3145 || normalizeQuestTag(plan.Type) != "clear map" || plan.InitTrigger != 1 {
t.Fatalf("clear-map plan = %+v", plan)
}
eventPlan, err := catalog.PlanAccept(character, dnfrepo.QuestRecord{}, 3146)
if err != nil || len(eventPlan.EventItems) != 1 || eventPlan.EventItems[0].ItemID != 1001 || eventPlan.EventItems[0].Count != 1 {
t.Fatalf("event-item PlanAccept = %+v, %v", eventPlan, err)
}
seekPlan, err := catalog.PlanAccept(character, dnfrepo.QuestRecord{}, 3147)
if err != nil || seekPlan.InitTrigger != 1|(1<<9) {
t.Fatalf("seek-and-meet PlanAccept = %+v, %v", seekPlan, err)
}
questClearPlan, err := catalog.PlanAccept(character, dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
1: {Status: "completed"},
}}, 3148)
if err != nil || questClearPlan.InitTrigger != 2 {
t.Fatalf("quest-clear PlanAccept = %+v, %v", questClearPlan, err)
}
fallbackPlan, err := catalog.PlanAccept(character, dnfrepo.QuestRecord{}, 3149)
if err != nil || fallbackPlan.InitTrigger != 1 {
t.Fatalf("fallback PlanAccept = %+v, %v", fallbackPlan, err)
}
active := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{3145: {Status: "active"}}}
if _, err := catalog.PlanAccept(character, active, 3145); !errors.Is(err, ErrQuestNotAcceptable) {
t.Fatalf("active PlanAccept error = %v", err)
}
}
func TestCatalogPlanAcceptLinksNoRewardMainSubQuests(t *testing.T) {
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)
}
if definition, ok := catalog.Find(3157); !ok || definition.MainQuestID != 3146 {
t.Fatalf("main quest parse = %+v ok=%t, want 3146", definition, ok)
}
plan, err := catalog.PlanAccept(CharacterEligibility{Level: 3, Job: 2, GrowType: 0}, dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
3145: {Status: "completed"},
}}, 3146)
if err != nil {
t.Fatalf("PlanAccept parent error = %v", err)
}
if plan.InitTrigger != 2 || len(plan.LinkedSubQuests) != 2 {
t.Fatalf("parent plan = %+v, want trigger 2 with two linked subtasks", plan)
}
if plan.LinkedSubQuests[0].QuestID != 3157 || plan.LinkedSubQuests[0].InitTrigger != 1 ||
plan.LinkedSubQuests[1].QuestID != 3054 || plan.LinkedSubQuests[1].InitTrigger != 1 {
t.Fatalf("linked subtasks = %+v", plan.LinkedSubQuests)
}
withoutParent := catalog.QuestList(CharacterEligibility{Level: 3, Job: 2, GrowType: 0}, dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
3145: {Status: "completed"},
}})
if reflect.DeepEqual(withoutParent.IDs, []int32{3146, 3157, 3054}) || containsCatalogQuestID(withoutParent.IDs, 3157) || containsCatalogQuestID(withoutParent.IDs, 3054) {
t.Fatalf("unowned linked subtasks leaked into quest list: %v", withoutParent.IDs)
}
withParent := catalog.QuestList(CharacterEligibility{Level: 3, Job: 2, GrowType: 0}, dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
3145: {Status: "completed"},
3146: {Status: "active"},
3157: {Status: "active"},
3054: {Status: "active"},
}})
if !containsCatalogQuestID(withParent.IDs, 3157) || !containsCatalogQuestID(withParent.IDs, 3054) {
t.Fatalf("active parent's linked subtasks missing from quest list: %v", withParent.IDs)
}
}
func TestTriggerFromIntDataPacksThreeNineBitChannels(t *testing.T) {
values := []int64{10, 0, 0, 5, 20, 0, 0, 3, 30, 0, 0, 2}
if got, want := triggerFromIntData(values, 4), uint32(5|(3<<9)|(2<<18)); got != want {
t.Fatalf("trigger = %d, want %d", got, want)
}
}
func TestCatalogPlanFinishRewardFiltersPVFJobAndSelection(t *testing.T) {
source := catalogTestSource{
DefaultList: "3145 `finish.qst`\n",
"n_quest/finish.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]", `
[difficulty]
`+"`1`"+`
[reward type]
`+"`[item]`"+`
[reward int data]
10165055 1 104010230 `+"`[job]`"+` 2 -1 1 999 `+"`[job]`"+` 1 -1 1
[reward selection int data]
2001 `+"`[job]`"+` 2 -1 1 2002 `+"`[job]`"+` 2 -1 1
`),
}
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.PlanFinishReward(CharacterEligibility{Level: 1, Job: 2, GrowType: 0}, 3145, 1, true)
if err != nil {
t.Fatal(err)
}
if plan.Difficulty != '1' || !plan.RewardSelectionUsed || len(plan.Items) != 3 ||
plan.Items[0].ItemID != 10165055 || plan.Items[1].ItemID != 104010230 || plan.Items[2].ItemID != 2002 {
t.Fatalf("finish reward plan = %+v", plan)
}
if _, err := catalog.PlanFinishReward(CharacterEligibility{Level: 1, Job: 2, GrowType: 0}, 3145, 2, true); !errors.Is(err, ErrQuestRewardSelectionInvalid) {
t.Fatalf("out-of-range selection error = %v", err)
}
}
func TestCatalogPlanFinishRewardPreservesGoldMarkerOutOfItemSlots(t *testing.T) {
source := catalogTestSource{
DefaultList: "3145 `finish.qst`\n",
"n_quest/finish.qst": questCatalogTestDefinition("[epic]", 1, 99, "[gunner]", `
[difficulty]
`+"`N`"+`
[reward type]
`+"`[item]`"+`
[gold multiple]
150
[reward int data]
0 1 10403 2
`),
}
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.PlanFinishReward(CharacterEligibility{Level: 1, Job: 2, GrowType: 0}, 3145, 0, false)
if err != nil {
t.Fatal(err)
}
if !plan.HasGoldReward || plan.GoldMultiple != 150 {
t.Fatalf("gold reward marker not preserved: %+v", plan)
}
if len(plan.Items) != 1 || plan.Items[0].ItemID != 10403 || plan.Items[0].Count != 2 {
t.Fatalf("gold marker leaked into item rewards: %+v", plan.Items)
}
}
func TestCatalogPlanFinishRewardRejectsMalformedPVF(t *testing.T) {
source := catalogTestSource{
DefaultList: "7 `bad.qst`\n",
"n_quest/bad.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]", `
[reward type]
`+"`[item]`"+`
[reward int data]
101 `+"`[job]`"+` 2
`),
}
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)
}
if _, err := catalog.PlanFinishReward(CharacterEligibility{Level: 1, Job: 2}, 7, 0, false); !errors.Is(err, ErrQuestRewardMalformed) {
t.Fatalf("malformed reward error = %v", err)
}
}
func TestCatalogPlanFinishRewardPlansPVFProfessionTransitions(t *testing.T) {
source := catalogTestSource{
DefaultList: "100 `change.qst`\n101 `awaken.qst`\n102 `second.qst`\n",
"n_quest/change.qst": questCatalogTestDefinition("[epic]", 15, 99, "[gunner]", `
[job change quest]
1
[reward type]
`+"`[grow type]`"+`
[reward int data]
2
`),
"n_quest/awaken.qst": questCatalogTestDefinition("[epic]", 50, 99, "[gunner]", `
[job change quest]
2
[grow type]
2
[reward type]
`+"`[awakening type]`"+`
[reward int data]
1
`),
"n_quest/second.qst": questCatalogTestDefinition("[epic]", 75, 99, "[gunner]", `
[job change quest]
3
[grow type]
2
[reward type]
`+"`[awakening type]`"+`
[reward int data]
2
`),
}
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)
}
changed, err := catalog.PlanFinishReward(CharacterEligibility{Level: 15, Job: 2, GrowType: 0}, 100, 0, false)
if err != nil || !changed.HasProfession || changed.ProfessionRequest.GrowNumber != 2 || changed.ProfessionRequest.ChainType != 1 || len(changed.Items) != 0 {
t.Fatalf("change plan = %+v, %v", changed, err)
}
awakened, err := catalog.PlanFinishReward(CharacterEligibility{Level: 50, Job: 2, GrowType: 0x02}, 101, 0, false)
if err != nil || awakened.ProfessionRequest.GrowNumber != 1 || awakened.ProfessionRequest.ChainType != 2 {
t.Fatalf("awakening plan = %+v, %v", awakened, err)
}
second, err := catalog.PlanFinishReward(CharacterEligibility{Level: 75, Job: 2, GrowType: 0x12}, 102, 0, false)
if err != nil || second.ProfessionRequest.GrowNumber != 2 || second.ProfessionRequest.JobChangeQuest != 3 {
t.Fatalf("second awakening plan = %+v, %v", second, err)
}
}
func TestCatalogPlanFinishRewardPlansExpertJobChain20(t *testing.T) {
source := catalogTestSource{
DefaultList: "2710 `expert.qst`\n",
"n_quest/expert.qst": questCatalogTestDefinition("[common unique]", 20, 99, "[all]", `
[job change quest]
20
[reward type]
`+"`[expert job]`"+`
[reward int data]
3
`),
}
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.PlanFinishReward(CharacterEligibility{Level: 20, Job: 2}, 2710, 0, false)
if err != nil || !plan.HasExpertJob || plan.ExpertJobType != 3 || plan.HasProfession || len(plan.Items) != 0 {
t.Fatalf("expert-job plan = %+v, %v", plan, err)
}
if _, err := catalog.PlanFinishReward(CharacterEligibility{Level: 20, Job: 2}, 2710, 0, true); !errors.Is(err, ErrQuestRewardSelectionInvalid) {
t.Fatalf("expert-job selectable plan error = %v", err)
}
}
func TestCatalogPlanFinishRewardDerivesSlotExpansionFromRuntimePVFIndex(t *testing.T) {
source := catalogTestSource{
DefaultList: "649 `support.qst`\n650 `magic.qst`\n2636 `earring.qst`\n",
"n_quest/support.qst": questCatalogTestDefinition("[common unique]", 1, 99, "[all]", `
[reward type]
`+"`[slot expansion]`"+`
[reward int data]
0
`),
"n_quest/magic.qst": questCatalogTestDefinition("[common unique]", 1, 99, "[all]", `
[reward type]
`+"`[slot expansion]`"+`
[reward int data]
1
`),
"n_quest/earring.qst": questCatalogTestDefinition("[common unique]", 1, 99, "[all]", `
[reward type]
`+"`[slot expansion]`"+`
[reward int data]
2
`),
}
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)
}
for _, test := range []struct {
questID int64
index uint32
bit byte
}{
{questID: 649, index: 0, bit: 0x01},
{questID: 650, index: 1, bit: 0x02},
{questID: 2636, index: 2, bit: 0x10},
} {
plan, planErr := catalog.PlanFinishReward(CharacterEligibility{Level: 90, Job: 2}, test.questID, 0, false)
if planErr != nil {
t.Fatalf("quest %d plan: %v", test.questID, planErr)
}
if !plan.HasSlotExpansion || plan.SlotExpansionIndex != test.index || plan.SlotExpansionBit != test.bit {
t.Fatalf("quest %d slot expansion plan=%+v", test.questID, plan)
}
}
if ExEquipSlotAll != 0x13 {
t.Fatalf("current NoPack extra-equipment-slot mask=%#x want=0x13", ExEquipSlotAll)
}
for _, values := range [][]int64{nil, {}, {0, 1}, {-1}, {3}} {
definition := Definition{
ID: 9000, Path: "n_quest/bad_slot.qst", LevelMin: 1, LevelMax: 99,
Difficulty: "N", RewardType: "[slot expansion]", RewardIntData: values,
}
badCatalog := &Catalog{ordered: []Definition{definition}, byID: map[int64]Definition{definition.ID: definition}}
if _, planErr := badCatalog.PlanFinishReward(CharacterEligibility{Level: 90, Job: 2}, definition.ID, 0, false); !errors.Is(planErr, ErrQuestRewardMalformed) {
t.Fatalf("slot expansion values=%v error=%v", values, planErr)
}
}
}
func TestCatalogPlanFinishRewardAcceptsPVFLegitimateEmptyReward(t *testing.T) {
definition := Definition{
ID: 943, Path: "n_quest/job2_mid.qst", LevelMin: 1, LevelMax: 99,
Difficulty: "N", JobChangeQuest: 2, RewardIntData: []int64{0, 0},
}
catalog := &Catalog{ordered: []Definition{definition}, byID: map[int64]Definition{definition.ID: definition}}
plan, err := catalog.PlanFinishReward(CharacterEligibility{Level: 50, Job: 2, GrowType: 0x01}, definition.ID, 0, false)
if err != nil {
t.Fatal(err)
}
if plan.HasProfession || len(plan.Items) != 0 || plan.QuestID != definition.ID {
t.Fatalf("empty PVF reward plan = %+v", plan)
}
if _, err := catalog.PlanFinishReward(CharacterEligibility{Level: 50, Job: 2, GrowType: 0x01}, definition.ID, 0, true); !errors.Is(err, ErrQuestRewardSelectionInvalid) {
t.Fatalf("empty reward selection error = %v", err)
}
malformed := definition
malformed.ID = 944
malformed.RewardIntData = []int64{0, 1}
catalog.byID[malformed.ID] = malformed
if _, err := catalog.PlanFinishReward(CharacterEligibility{Level: 50, Job: 2, GrowType: 0x01}, malformed.ID, 0, false); !errors.Is(err, ErrQuestRewardMalformed) {
t.Fatalf("nonzero empty-type reward error = %v", err)
}
}
func TestCatalogPlanFinishRewardParsesHellChallengeStrictly(t *testing.T) {
for _, test := range []struct {
name string
data []int64
want bool
}{
{name: "valid", data: []int64{1}, want: true},
{name: "zero", data: []int64{0}},
{name: "extra", data: []int64{1, 2}},
{name: "negative", data: []int64{-1}},
} {
t.Run(test.name, func(t *testing.T) {
definition := Definition{ID: 2900, Path: "metrocenter/hell.qst", LevelMin: 1, LevelMax: 99, Difficulty: "N", RewardType: "[hell challenge]", RewardIntData: test.data}
catalog := &Catalog{ordered: []Definition{definition}, byID: map[int64]Definition{definition.ID: definition}}
plan, err := catalog.PlanFinishReward(CharacterEligibility{Level: 90, Job: 2}, definition.ID, 0, false)
if test.want {
if err != nil || !plan.HasHellPartyUnlock {
t.Fatalf("valid hell challenge plan=%+v err=%v", plan, err)
}
return
}
if !errors.Is(err, ErrQuestRewardMalformed) {
t.Fatalf("hell challenge data=%v error=%v", test.data, err)
}
})
}
}
func TestCatalogPlanFinishRewardParsesInfernoChallengeStrictly(t *testing.T) {
for _, test := range []struct {
name string
data []int64
want int64
}{
{name: "valid", data: []int64{7527}, want: 7527},
{name: "zero", data: []int64{0}},
{name: "extra", data: []int64{7527, 1}},
{name: "negative", data: []int64{-1}},
} {
t.Run(test.name, func(t *testing.T) {
definition := Definition{ID: 10104, Path: "Hell/achievement_70_Hell_inferno_2.qst", LevelMin: 77, LevelMax: 99, Difficulty: "G", RewardType: "[inferno challenge]", RewardIntData: test.data}
catalog := &Catalog{ordered: []Definition{definition}, byID: map[int64]Definition{definition.ID: definition}}
plan, err := catalog.PlanFinishReward(CharacterEligibility{Level: 90, Job: 2}, definition.ID, 0, false)
if test.want > 0 {
if err != nil || plan.InfernoChallengeDungeonID != test.want {
t.Fatalf("valid inferno challenge plan=%+v err=%v", plan, err)
}
return
}
if !errors.Is(err, ErrQuestRewardMalformed) {
t.Fatalf("inferno challenge data=%v error=%v", test.data, err)
}
})
}
}
func TestCatalogAcceptablePreservesTargetCharacterGrowAndAwakeningTuple(t *testing.T) {
source := catalogTestSource{
DefaultList: "2680 `branch_zero_first.qst`\n5135 `branch_zero_second.qst`\n",
"n_quest/branch_zero_first.qst": questCatalogTestDefinition("[realization]", 50, 99, "[all]", `
[target character]
`+"`[demonic swordman]`"+` 0 0 `+"`[creator mage]`"+` 0 0
[job change quest]
2
`),
"n_quest/branch_zero_second.qst": questCatalogTestDefinition("[realization]", 75, 99, "[all]", `
[target character]
`+"`[demonic swordman]`"+` 0 1
[job change quest]
3
`),
}
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)
}
if got := catalog.Acceptable(CharacterEligibility{Level: 50, Job: 9, GrowType: 0x00}, dnfrepo.QuestRecord{}).IDs; !reflect.DeepEqual(got, []int32{2680}) {
t.Fatalf("branch-zero first-awakening acceptable=%v", got)
}
if got := catalog.Acceptable(CharacterEligibility{Level: 75, Job: 9, GrowType: 0x10}, dnfrepo.QuestRecord{}).IDs; !reflect.DeepEqual(got, []int32{5135}) {
t.Fatalf("branch-zero second-awakening acceptable=%v", got)
}
if got := catalog.Acceptable(CharacterEligibility{Level: 75, Job: 2, GrowType: 0x10}, dnfrepo.QuestRecord{}).IDs; len(got) != 0 {
t.Fatalf("target tuple leaked to ordinary job: %v", got)
}
}
func questCatalogTestDefinition(grade string, levelMin, levelMax int, job, extra string) string {
return "[grade]\n`" + grade + "`\n[level]\n" + integerText(levelMin) + " " + integerText(levelMax) + "\n[job]\n`" + job + "`\n[exposed by npc]\n1\n" + extra
}
func integerText(value int) string {
if value == 0 {
return "0"
}
digits := make([]byte, 0, 8)
for value > 0 {
digits = append(digits, byte('0'+value%10))
value /= 10
}
for left, right := 0, len(digits)-1; left < right; left, right = left+1, right-1 {
digits[left], digits[right] = digits[right], digits[left]
}
return string(digits)
}
func TestCatalogSuccessorsFollowsPrerequisiteGroupsInOrder(t *testing.T) {
source := catalogTestSource{
DefaultList: "1 `first.qst`\n2 `second.qst`\n3 `branch.qst`\n4 `unrelated.qst`\n",
"n_quest/first.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]", ""),
"n_quest/second.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]",
"[pre required quest]\n1\n"),
"n_quest/branch.qst": questCatalogTestDefinition("[normal]", 1, 99, "[all]",
"[pre required quest]\n1 9\n"),
"n_quest/unrelated.qst": questCatalogTestDefinition("[epic]", 1, 99, "[all]",
"[pre required quest]\n2\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)
}
successors := catalog.Successors(1)
if len(successors) != 2 || successors[0].ID != 2 || successors[1].ID != 3 {
t.Fatalf("successors(1) = %+v, want [2 3] in catalog order", successors)
}
if none := catalog.Successors(99); len(none) != 0 {
t.Fatalf("successors(99) = %+v, want none", none)
}
if successors[0].PreRequiredGroups[0][0] != 1 {
t.Fatalf("successor clone shares prerequisite state: %+v", successors[0].PreRequiredGroups)
}
character := CharacterEligibility{Level: 1, Job: 2, GrowType: 0}
partial := catalog.EligibleSuccessors(1, character, dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
1: {Status: "completed"},
}})
if !reflect.DeepEqual(definitionIDs(partial), []int64{2}) {
t.Fatalf("eligible successors with partial prerequisites=%v, want [2]", definitionIDs(partial))
}
complete := catalog.EligibleSuccessors(1, character, dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
1: {Status: "completed"},
9: {Status: "completed"},
}})
if !reflect.DeepEqual(definitionIDs(complete), []int64{2, 3}) {
t.Fatalf("eligible successors with complete prerequisites=%v, want [2 3]", definitionIDs(complete))
}
}
func containsCatalogQuestID(ids []int32, want int32) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
func TestCatalogStoryAwakeningCompanionProjectsCanonicalFinish(t *testing.T) {
canonical := Definition{
ID: 3367, Grade: "[epic]", Type: "[clear map]", LevelMin: 50, LevelMax: 99,
RewardType: "[item]", RewardIntData: []int64{0, 0}, ExposedByNPC: -1,
TargetCharacterRules: []TargetCharacterRule{
{JobTag: "[swordman]", FirstGrowType: 4, AwakeningStage: 0},
{JobTag: "[swordman]", FirstGrowType: 4, AwakeningStage: 1},
}, CollisionQuests: []int64{3371}, IntData: []int64{95264},
}
variant := Definition{
ID: 3371, Grade: "[epic]", Type: "[clear map]", LevelMin: 50, LevelMax: 99,
Job: "[swordman]", GrowType: 4, HasGrowType: true, JobChangeQuest: 2,
RewardType: "[awakening type]", RewardIntData: []int64{1}, CollisionQuests: []int64{3367},
IntData: []int64{95264},
}
catalog := &Catalog{ordered: []Definition{canonical, variant}, byID: map[int64]Definition{3367: canonical, 3371: variant}, storyAwakeningByCanonical: map[int64][]int64{}, storyAwakeningCanonicalFor: map[int64]int64{}}
catalog.buildStoryAwakeningAliases()
character := CharacterEligibility{Level: 56, Job: 0, GrowType: 4}
if got := catalog.eligibility(character, dnfrepo.QuestRecord{}, false); !containsCatalogQuestID(got.IDs, 3367) || containsCatalogQuestID(got.IDs, 3371) {
t.Fatalf("story task projection=%v, want canonical only", got.IDs)
}
plan, err := catalog.PlanFinishReward(character, 3367, 0, false)
if err != nil || !plan.HasProfession || plan.ProfessionRequest.ChainType != 2 || plan.ProfessionRequest.GrowNumber != 1 {
t.Fatalf("canonical finish plan=%+v err=%v", plan, err)
}
if !catalog.FinishRewardNeedsProfessionResources(dnfrepo.QuestRecord{}, character, 3367, 0, false) {
t.Fatal("canonical story awakening did not require profession resources")
}
}
func TestCatalogFinishRewardDefinitionProjectsFinalRewardChild(t *testing.T) {
parent := Definition{ID: 5000, Grade: "[epic]", Type: "[quest clear]", SubType: 2, RewardType: "[item]", RewardIntData: []int64{0, 0}, IntData: []int64{5001, 5002}}
first := Definition{ID: 5001, Grade: "[sub]", Type: "[meet npc]", MainQuestID: 5000, NoExperience: true}
final := Definition{ID: 5002, Grade: "[sub]", Type: "[seek n meet npc]", MainQuestID: 5000, NoExperience: true, RewardType: "[item]", RewardIntData: []int64{123, 2}}
catalog := &Catalog{ordered: []Definition{parent, first, final}, byID: map[int64]Definition{5000: parent, 5001: first, 5002: final}}
record := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
5000: {Status: "active", ProgressValue: 0},
5001: {Status: "completed", ProgressValue: 0},
5002: {Status: "active", ProgressValue: 0},
}}
source, additional, ok := catalog.FinishRewardDefinition(record, CharacterEligibility{Level: 90, Job: 0}, 5000)
if !ok || source.ID != 5002 || !reflect.DeepEqual(additional, []int64{5002}) {
t.Fatalf("final child source=%+v additional=%v ok=%t", source, additional, ok)
}
}
func TestCatalogTitleAchievementDefinitionsStayOutsideOrdinaryQuestAcceptance(t *testing.T) {
definitions := []Definition{
{ID: 6501, Grade: "[title]", LevelMin: 10, LevelMax: 99},
{ID: 6502, Grade: "[title]", LevelMin: 10, LevelMax: 49},
{ID: 6503, Grade: "[title]", LevelMin: 10, LevelMax: 99, Job: "[fighter]"},
{ID: 1, Grade: "[epic]", LevelMin: 1, LevelMax: 99},
}
catalog := &Catalog{ordered: definitions, byID: map[int64]Definition{}}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
character := CharacterEligibility{Level: 50, Job: 0}
if got := definitionIDs(catalog.TitleAchievementDefinitions(character)); !reflect.DeepEqual(got, []int64{6501}) {
t.Fatalf("title achievement definitions=%v, want [6501]", got)
}
if got := catalog.QuestList(character, dnfrepo.QuestRecord{}).IDs; containsCatalogQuestID(got, 6501) {
t.Fatalf("title achievement leaked into ordinary acceptable quests: %v", got)
}
}
func definitionIDs(definitions []Definition) []int64 {
ids := make([]int64, 0, len(definitions))
for _, definition := range definitions {
ids = append(ids, definition.ID)
}
return ids
}
@@ -0,0 +1,393 @@
package quest
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrClearMapTargetRequired = errors.New("dnf clear-map dungeon or map target is required")
ErrClearMapCompletionKeyRequired = errors.New("dnf clear-map completion key is required")
ErrClearMapCompletionTimeRequired = errors.New("dnf clear-map completion time is required")
)
const (
clearMapRewardPending = "pending"
clearMapCompletionKind = "clear_map"
)
// ClearMapCompletionInput contains only facts owned by an authoritative final
// dungeon completion. CompletionKey must be stable for replays of that run.
type ClearMapCompletionInput struct {
DungeonID int64
MapID int64
WorldMapID int64
Difficulty int64
HellPartyComplete bool
AllRoomsCleared bool
ClearedRoomCount int64
WeddingCouple bool // Exactly the engaged pair completed an ordinary dungeon.
CompletionKey string
CompletedAt time.Time
}
type ClearMapCompletion struct {
QuestID int64
Field dnfrepo.QuestField
PreviousTrigger int64
NewTrigger int64
Completed bool
PVFPath string
Idempotent bool
}
// ClearMapCompletionPlan is a repository-independent mutation plan. Record is
// a deep clone; no caller-owned map is modified.
type ClearMapCompletionPlan struct {
Record dnfrepo.QuestRecord
Completions []ClearMapCompletion
ChangedFields []dnfrepo.QuestField
}
// PlanClearMapCompletion advances matching active clear-map and supported
// condition-under-clear triggers. Trigger zero is the quest-complete/pending-
// reward state used by the existing quest snapshot. This plan grants no
// assets or experience; those mutations require a wider transaction.
func (c *Catalog) PlanClearMapCompletion(record dnfrepo.QuestRecord, input ClearMapCompletionInput) (ClearMapCompletionPlan, error) {
if c == nil {
return ClearMapCompletionPlan{}, ErrCatalogEmpty
}
if input.DungeonID <= 0 && input.MapID <= 0 {
return ClearMapCompletionPlan{}, ErrClearMapTargetRequired
}
input.CompletionKey = strings.TrimSpace(input.CompletionKey)
if input.CompletionKey == "" {
return ClearMapCompletionPlan{}, ErrClearMapCompletionKeyRequired
}
if input.CompletedAt.IsZero() {
return ClearMapCompletionPlan{}, ErrClearMapCompletionTimeRequired
}
plan := ClearMapCompletionPlan{Record: dnfrepo.CloneQuest(record)}
statesChanged := false
progressChanged := false
_, activationFields := c.activateMissingLinkedNoRewardSubQuests(&plan.Record, input.CompletedAt, "")
for _, field := range activationFields {
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
seen := make(map[int64]struct{})
visit := func(states map[int64]dnfrepo.QuestState, field dnfrepo.QuestField) {
ids := make([]int64, 0, len(states))
for questID := range states {
ids = append(ids, questID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, questID := range ids {
if _, duplicate := seen[questID]; duplicate {
continue
}
seen[questID] = struct{}{}
state := states[questID]
if !isActiveQuestStatus(state.Status) {
continue
}
definition, ok := c.byID[questID]
if !ok {
continue
}
channel, matched := clearCompletionMatchChannel(definition, input)
if !matched {
continue
}
completion := ClearMapCompletion{
QuestID: questID,
Field: field,
PreviousTrigger: state.ProgressValue,
PVFPath: definition.Path,
}
if state.ProgressValue < 0 {
// Repository corruption is not a completed clear-map trigger and
// must never be normalized into a rewardable state here.
continue
}
if state.ProgressValue > 0 && state.Extra["completion_key"] == input.CompletionKey {
completion.Idempotent = true
completion.NewTrigger = state.ProgressValue
plan.Completions = append(plan.Completions, completion)
continue
}
if state.ProgressValue == 0 {
completion.Idempotent = true
completion.NewTrigger = 0
completion.Completed = true
if !c.retainTerminalLinkedObjective(plan.Record, definition) &&
autoCompleteNoRewardSubQuestState(&state, definition, input.CompletedAt) {
states[questID] = state
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
plan.Completions = append(plan.Completions, completion)
continue
}
newTrigger := int64(0)
if normalizeQuestTag(definition.Type) == "condition under clear" {
newTrigger = int64(decrementClearTriggerChannel(uint32(state.ProgressValue), channel))
}
state.ProgressValue = newTrigger
completion.NewTrigger = newTrigger
completion.Completed = newTrigger == 0
state.UpdatedAt = input.CompletedAt
if state.Extra == nil {
state.Extra = make(map[string]string, 8)
}
state.Extra["completion_kind"] = clearMapCompletionKind
state.Extra["completion_key"] = input.CompletionKey
state.Extra["completion_dungeon_id"] = strconv.FormatInt(input.DungeonID, 10)
state.Extra["completion_map_id"] = strconv.FormatInt(input.MapID, 10)
if completion.Completed {
state.Extra["reward_state"] = clearMapRewardPending
} else {
delete(state.Extra, "reward_state")
}
state.Extra["pvf_path"] = definition.Path
state.Extra["quest_type"] = definition.Type
if completion.Completed && !c.retainTerminalLinkedObjective(plan.Record, definition) {
autoCompleteNoRewardSubQuestState(&state, definition, input.CompletedAt)
}
states[questID] = state
plan.Completions = append(plan.Completions, completion)
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
}
visit(plan.Record.States, dnfrepo.QuestFieldStates)
visit(plan.Record.Progress, dnfrepo.QuestFieldProgress)
for _, field := range syncActiveQuestClearParentProgress(c, &plan.Record, input.CompletedAt) {
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
if statesChanged || progressChanged {
plan.Record.UpdatedAt = input.CompletedAt
}
if statesChanged {
plan.ChangedFields = append(plan.ChangedFields, dnfrepo.QuestFieldStates)
}
if progressChanged {
plan.ChangedFields = append(plan.ChangedFields, dnfrepo.QuestFieldProgress)
}
return plan, nil
}
// retainTerminalLinkedObjective keeps the final no-reward child of an active
// [quest clear] parent visible at trigger zero. The current client must first
// observe that state, then emit its native parent op33/op34 finish sequence;
// archiving the child here collapses the parent too early and loses the finish
// dialogue.
func (c *Catalog) retainTerminalLinkedObjective(record dnfrepo.QuestRecord, definition Definition) bool {
if c == nil || definition.MainQuestID <= 0 || !shouldAutoCompleteNoRewardSubQuest(definition) {
return false
}
parent, known := c.Find(definition.MainQuestID)
if !known {
return false
}
parentType := normalizeQuestTag(parent.Type)
if parentType != "quest clear" && parentType != "clear quest" {
return false
}
parentState, _, active := mutableQuestState(&record, parent.ID)
if !active || !isActiveQuestStatus(parentState.Status) {
return false
}
hasCurrentChild := false
for _, childID := range parent.IntData {
if childID <= 0 {
continue
}
if childID == definition.ID {
hasCurrentChild = true
continue
}
state, _, stateKnown := mutableQuestState(&record, childID)
if !stateKnown {
return false
}
if isCompletedQuestStatus(state.Status) {
continue
}
child, childKnown := c.Find(childID)
if !childKnown || child.MainQuestID != parent.ID || !shouldAutoCompleteNoRewardSubQuest(child) {
return false
}
if !isActiveQuestStatus(state.Status) || state.ProgressValue != 0 {
return false
}
}
return hasCurrentChild
}
func clearCompletionMatchChannel(definition Definition, input ClearMapCompletionInput) (int, bool) {
typeTag := normalizeQuestTag(definition.Type)
if typeTag == "clear map" {
return 0, clearMapTargetMatches(definition.IntData, input.DungeonID, input.MapID)
}
// Current PVF side quests such as quest 1680
// encode a set of alternative dungeons as repeated
// (dungeon_id, minimum_difficulty) pairs. The title's "1/4" is the
// chapter number, not four packed trigger channels: clearing any one
// authored dungeon completes the quest's single initial trigger.
if typeTag == "clear a dungeon" && definition.SubType == 6 {
for offset := 0; offset+1 < len(definition.IntData); offset += 2 {
selector := definition.IntData[offset]
minDifficulty := definition.IntData[offset+1]
if selector != input.DungeonID {
continue
}
if minDifficulty >= 0 && input.Difficulty < minDifficulty {
continue
}
return 0, true
}
return 0, false
}
if typeTag != "condition under clear" {
return 0, false
}
values := definition.IntData
if definition.SubType == 22 && !input.WeddingCouple {
return 0, false
}
switch definition.SubType {
case 6, 22:
stride := 3
if len(values) == 2 {
stride = 2
}
for channel, offset := 0, 0; offset < len(values) && channel < 3; channel, offset = channel+1, offset+stride {
selector := values[offset]
minDifficulty := int64(-1)
if offset+1 < len(values) {
minDifficulty = values[offset+1]
}
if selector >= 0 && selector != input.DungeonID {
continue
}
if selector < 0 && selector != -1 {
continue
}
if minDifficulty >= 0 && input.Difficulty < minDifficulty {
continue
}
return channel, true
}
case 11:
if len(values) >= 2 &&
(values[0] == -1 || values[0] == input.DungeonID) &&
(values[1] < 0 || input.Difficulty >= values[1]) {
// The third field is the authored room count when it is positive;
// -1 means "all rooms". Older callers/tests only set AllRoomsCleared,
// so keep that as the fallback when no count was supplied.
requiredRooms := int64(-1)
if len(values) >= 3 {
requiredRooms = values[2]
}
if requiredRooms > 0 && input.ClearedRoomCount > 0 {
return 0, input.ClearedRoomCount >= requiredRooms
}
if input.AllRoomsCleared {
return 0, true
}
}
case 23:
if !input.HellPartyComplete {
return 0, false
}
if len(values) > 0 && values[0] == -2 {
for channel, offset := 0, 0; offset+3 < len(values) && channel < 3; channel, offset = channel+1, offset+4 {
if values[offset] != -2 || values[offset+1] != input.WorldMapID {
continue
}
if values[offset+2] >= 0 && input.Difficulty < values[offset+2] {
continue
}
return channel, true
}
return 0, false
}
for channel, offset := 0, 0; offset < len(values) && channel < 3; channel, offset = channel+1, offset+3 {
selector := values[offset]
minDifficulty := int64(-1)
if offset+1 < len(values) {
minDifficulty = values[offset+1]
}
if selector >= 0 && selector != input.DungeonID {
continue
}
if selector < 0 && selector != -1 {
continue
}
if minDifficulty >= 0 && input.Difficulty < minDifficulty {
continue
}
return channel, true
}
}
return 0, false
}
func decrementClearTriggerChannel(trigger uint32, channel int) uint32 {
if channel < 0 || channel > 2 {
return trigger
}
shift := uint(channel * 9)
current := (trigger >> shift) & 0x1ff
if current == 0 {
return trigger
}
return (trigger &^ (0x1ff << shift)) | ((current - 1) << shift)
}
func clearMapTargetMatches(values []int64, dungeonID, mapID int64) bool {
for _, target := range values {
if target <= 0 {
continue
}
if dungeonID > 0 && target == dungeonID {
return true
}
if mapID > 0 && target == mapID {
return true
}
}
return false
}
func (p ClearMapCompletionPlan) ValidateCharacter(characterID string) error {
if strings.TrimSpace(p.Record.CharacterID) != strings.TrimSpace(characterID) {
return fmt.Errorf("dnf clear-map quest owner mismatch: record=%q selected=%q", p.Record.CharacterID, characterID)
}
return nil
}
@@ -0,0 +1,89 @@
package quest
import (
"context"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type ClearMapPersistResult struct {
CharacterID string
Completions []ClearMapCompletion
ChangedFields []dnfrepo.QuestField
Idempotent bool
}
// ApplyClearMapCompletion persists and verifies the quest-only Phase-A marker.
// It grants no reward. The authoritative op117 owner may continue to op115/op31
// only after this method returns success.
func (o *Owner) ApplyClearMapCompletion(
ctx context.Context,
catalog *Catalog,
characterID string,
input ClearMapCompletionInput,
) (ClearMapPersistResult, error) {
if o == nil || o.characters == nil || o.quests == nil {
return ClearMapPersistResult{}, ErrOwnerUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return ClearMapPersistResult{}, ErrCharacterRequired
}
if _, ok, err := o.characters.Load(ctx, characterID); err != nil {
return ClearMapPersistResult{}, err
} else if !ok {
return ClearMapPersistResult{}, ErrCharacterNotFound
}
record, ok, err := o.quests.Load(ctx, characterID)
if err != nil {
return ClearMapPersistResult{}, err
}
if !ok {
record = dnfrepo.QuestRecord{CharacterID: characterID}
} else {
record = dnfrepo.CloneQuest(record)
}
if strings.TrimSpace(record.CharacterID) == "" {
record.CharacterID = characterID
}
plan, err := catalog.PlanClearMapCompletion(record, input)
if err != nil {
return ClearMapPersistResult{}, err
}
if err := plan.ValidateCharacter(characterID); err != nil {
return ClearMapPersistResult{}, err
}
result := ClearMapPersistResult{
CharacterID: characterID,
Completions: append([]ClearMapCompletion(nil), plan.Completions...),
ChangedFields: append([]dnfrepo.QuestField(nil), plan.ChangedFields...),
Idempotent: len(plan.Completions) > 0 && len(plan.ChangedFields) == 0,
}
if len(plan.ChangedFields) == 0 {
return result, nil
}
if err := dnfrepo.SaveQuestFields(ctx, o.quests, plan.Record, plan.ChangedFields...); err != nil {
return ClearMapPersistResult{}, err
}
persisted, ok, err := o.quests.Load(ctx, characterID)
if err != nil {
return ClearMapPersistResult{}, err
}
if !ok {
return ClearMapPersistResult{}, ErrQuestPersistVerify
}
for _, completion := range result.Completions {
state, known := questStateFor(persisted, completion.QuestID)
if !known || (!isActiveQuestStatus(state.Status) && !isCompletedQuestStatus(state.Status)) || state.ProgressValue != completion.NewTrigger {
return ClearMapPersistResult{}, ErrQuestPersistVerify
}
if completion.Completed && !completion.Idempotent && (state.Extra["completion_key"] != input.CompletionKey || state.Extra["reward_state"] != clearMapRewardPending) {
if !isCompletedQuestStatus(state.Status) || state.Extra["reward_state"] != finishRewardGranted ||
state.Extra["completion_key"] != input.CompletionKey {
return ClearMapPersistResult{}, ErrQuestPersistVerify
}
}
}
return result, nil
}
@@ -0,0 +1,139 @@
package quest
import (
"context"
"testing"
"time"
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 TestOwnerApplyClearMapCompletionPersistsAndVerifiesIdempotently(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account"}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3145: {Status: "active", ProgressValue: 1},
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
input := ClearMapCompletionInput{
DungeonID: 3, MapID: 76126, CompletionKey: "runtime-12/op117/430", CompletedAt: time.Date(2026, 7, 15, 20, 45, 0, 0, time.UTC),
}
first, err := owner.ApplyClearMapCompletion(ctx, clearMapTestCatalog(t), "19", input)
if err != nil {
t.Fatal(err)
}
if first.Idempotent || len(first.Completions) != 1 || len(first.ChangedFields) != 1 {
t.Fatalf("first result = %+v", first)
}
persisted, ok, err := repos.Quest.Load(ctx, "19")
if err != nil || !ok {
t.Fatalf("load persisted ok=%t err=%v", ok, err)
}
state := persisted.States[3145]
if state.ProgressValue != 0 || state.Extra["completion_key"] != input.CompletionKey || state.Extra["reward_state"] != "pending" {
t.Fatalf("persisted state = %+v", state)
}
second, err := owner.ApplyClearMapCompletion(ctx, clearMapTestCatalog(t), "19", input)
if err != nil {
t.Fatal(err)
}
if !second.Idempotent || len(second.Completions) != 1 || len(second.ChangedFields) != 0 {
t.Fatalf("second result = %+v", second)
}
}
func TestOwnerApplyConditionUnderClearPersistsPartialProgressIdempotently(t *testing.T) {
ctx := context.Background()
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(ctx, source, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(ctx, index)
if err != nil {
t.Fatal(err)
}
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account"}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
5783: {Status: "active", ProgressValue: 2},
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
input := ClearMapCompletionInput{
DungeonID: 86, WorldMapID: 15, Difficulty: 0, HellPartyComplete: true,
CompletionKey: "hell/run-1", CompletedAt: time.Date(2026, 8, 13, 18, 0, 0, 0, time.UTC),
}
first, err := owner.ApplyClearMapCompletion(ctx, catalog, "19", input)
if err != nil {
t.Fatal(err)
}
if first.Idempotent || len(first.Completions) != 1 || first.Completions[0].Completed || first.Completions[0].NewTrigger != 1 || len(first.ChangedFields) != 1 {
t.Fatalf("first partial result = %+v", first)
}
persisted, ok, err := repos.Quest.Load(ctx, "19")
if err != nil || !ok {
t.Fatalf("load persisted ok=%t err=%v", ok, err)
}
state := persisted.States[5783]
if state.Status != "active" || state.ProgressValue != 1 || state.Extra["completion_key"] != input.CompletionKey || state.Extra["reward_state"] != "" {
t.Fatalf("persisted partial state = %+v", state)
}
second, err := owner.ApplyClearMapCompletion(ctx, catalog, "19", input)
if err != nil {
t.Fatal(err)
}
if !second.Idempotent || len(second.Completions) != 1 || second.Completions[0].NewTrigger != 1 || len(second.ChangedFields) != 0 {
t.Fatalf("replayed partial result = %+v", second)
}
}
func TestOwnerApplyClearMapCompletionDoesNotCreateFakeQuest(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "20", AccountID: "account"}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyClearMapCompletion(ctx, clearMapTestCatalog(t), "20", ClearMapCompletionInput{
MapID: 76126, CompletionKey: "runtime-empty", CompletedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if len(result.Completions) != 0 || len(result.ChangedFields) != 0 || result.Idempotent {
t.Fatalf("result = %+v", result)
}
if _, ok, err := repos.Quest.Load(ctx, "20"); err != nil || ok {
t.Fatalf("empty completion created quest row ok=%t err=%v", ok, err)
}
}
@@ -0,0 +1,125 @@
package quest
import (
"context"
"os"
"reflect"
"testing"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFQuest3145ClearMapCompletionPlan(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify real clear-map completion")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, 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{
3145: {Status: "active", ProgressValue: 1},
},
}, ClearMapCompletionInput{
DungeonID: 3, MapID: 76126, CompletionKey: "real-pvf-smoke/run/op117", CompletedAt: time.Date(2026, 7, 15, 0, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 3145 || plan.Record.States[3145].ProgressValue != 0 || plan.Record.States[3145].Extra["reward_state"] != "pending" {
t.Fatalf("real quest 3145 clear-map plan = %+v", plan)
}
t.Logf("real quest 3145 clear-map completion path=%q map=%d", plan.Completions[0].PVFPath, 76126)
}
func TestRealScriptPVFQuest1680ClearADungeonCompletionPlan(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify real clear-a-dungeon completion")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, 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)
wantIntData := []int64{
7165, -1, 7166, -1, 7167, -1, 7168, -1, 7169, -1,
7170, -1, 7171, -1, 7172, -1, 7173, -1, 7174, -1,
7175, -1, 7176, -1, 7177, -1, 7178, -1, 7179, -1,
}
if !found || normalizeQuestTag(definition.Type) != "clear a dungeon" ||
definition.SubType != 6 || !reflect.DeepEqual(definition.IntData, wantIntData) {
t.Fatalf("real quest 1680 definition = %+v found=%t", definition, found)
}
plan, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{1680: {Status: "active", ProgressValue: 1}},
}, ClearMapCompletionInput{
DungeonID: 7165, Difficulty: 0,
CompletionKey: "real-pvf-blackhall/run", CompletedAt: time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 1680 ||
!plan.Completions[0].Completed || plan.Record.States[1680].ProgressValue != 0 {
t.Fatalf("real quest 1680 clear-a-dungeon plan = %+v", plan)
}
}
func TestRealScriptPVFQuest5782RegionalHellPartyCompletionPlan(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify real hell-party completion")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, 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(5782)
if !found || definition.SubType != 23 || !reflect.DeepEqual(definition.IntData, []int64{-2, 15, -1, 1}) {
t.Fatalf("real quest 5782 definition = %+v found=%t", definition, found)
}
plan, err := catalog.PlanClearMapCompletion(dnfrepo.QuestRecord{
CharacterID: "4", States: map[int64]dnfrepo.QuestState{5782: {Status: "active", ProgressValue: 1}},
}, ClearMapCompletionInput{
DungeonID: 86, MapID: 20317, WorldMapID: 15, Difficulty: 0, HellPartyComplete: true,
CompletionKey: "real-pvf-smoke/hell-party/train", CompletedAt: time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 5782 || !plan.Completions[0].Completed || plan.Record.States[5782].ProgressValue != 0 {
t.Fatalf("real quest 5782 hell-party plan = %+v", plan)
}
}
@@ -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
}
@@ -0,0 +1,77 @@
// 本文件定义任务命令计划。
// 计划只承接 C2S 解析后的任务意图,不直接修改任务状态、发奖励或刷新 USERINFO。
package quest
import (
"fmt"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
)
// Command 是 quest handler 交给后续 owner 的最小命令计划。
// 完成任务可能发经验、金币、物品、职业状态或后续 NOTI,必须等 quest/reward owner 闭合后再回包。
type Command struct {
Operation string
AccountID string
SelectedCharacterID uint16
QuestID uint16
TriggerType byte
TriggerArgument byte
IsIncrement bool
RewardSelectIndex uint16
HasRewardSelect bool
Multiplier uint16
NeedsOwner string
}
// NewQuestIDCommand 构造只携带 questId 的接受/放弃任务计划。
func NewQuestIDCommand(req alignedcmd.Request, operation string, parsed QuestIDRequest) Command {
return Command{
Operation: operation,
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
QuestID: parsed.QuestID,
NeedsOwner: "quest owner + state persistence + response order",
}
}
// NewTriggerCommand 构造任务触发进度计划。
func NewTriggerCommand(req alignedcmd.Request, parsed SetTriggerRequest) Command {
return Command{
Operation: "set_quest_trigger",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
QuestID: parsed.QuestID,
TriggerType: parsed.TriggerType,
TriggerArgument: parsed.TriggerArgument,
IsIncrement: parsed.IsIncrement,
NeedsOwner: "quest owner + progress idempotency + notify order",
}
}
// NewFinishCommand 构造完成任务计划。
func NewFinishCommand(req alignedcmd.Request, parsed FinishQuestRequest) Command {
return Command{
Operation: "finish_quest",
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
QuestID: parsed.QuestID,
RewardSelectIndex: parsed.RewardSelectIndex,
HasRewardSelect: parsed.HasRewardSelect,
Multiplier: parsed.Multiplier,
NeedsOwner: "quest owner + reward owner + mutation id + USERINFO/NOTI order",
}
}
func (c Command) String() string {
base := fmt.Sprintf("account=%q char=%d quest=%d", c.AccountID, c.SelectedCharacterID, c.QuestID)
switch c.Operation {
case "set_quest_trigger":
return fmt.Sprintf("%s triggerType=%d increment=%t needs=%s", base, c.TriggerType, c.IsIncrement, c.NeedsOwner)
case "finish_quest":
return fmt.Sprintf("%s rewardSelect=%d hasReward=%t multiplier=%d needs=%s", base, c.RewardSelectIndex, c.HasRewardSelect, c.Multiplier, c.NeedsOwner)
default:
return fmt.Sprintf("%s needs=%s", base, c.NeedsOwner)
}
}
@@ -0,0 +1,563 @@
package quest
import (
"errors"
"fmt"
"sort"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrCompletionTicketActionInvalid = errors.New("quest completion ticket action is invalid")
ErrCompletionTicketLevelInvalid = errors.New("quest completion ticket level requirement is not met")
ErrCompletionTicketNoApplicableQuest = errors.New("quest completion ticket has no applicable quest")
ErrCompletionTicketSelectionRequired = errors.New("quest completion ticket requires an explicit quest selection")
)
// CompletionTicketSpec is the PVF-derived policy attached to one stackable.
// ActionValues are interpreted only by the corresponding action family.
type CompletionTicketSpec struct {
ActionType string
ActionValues []int64
MinimumLevel int64
MaximumLevel int64
PreserveSideQuests bool
PreserveHellPartyUnlocks bool
}
// CompletionTicketTitleGrant is one title-book reward unlocked by the ticket.
// QuestID keys the titlebook.etc mapping when the quest is itself a [title]
// achievement; ItemID is the reward item carried by the PVF so old
// [achievement] quests that also reward a title can be resolved by item id.
type CompletionTicketTitleGrant struct {
QuestID int64
ItemID int32
}
// CompletionTicketPlan contains only no-reward quest completion changes and,
// for awakening coupons, the canonical grow_type stage transition.
type CompletionTicketPlan struct {
Quest dnfrepo.QuestRecord
Character dnfrepo.CharacterRecord
QuestFields []dnfrepo.QuestField
CharacterFields []dnfrepo.CharacterField
CompletedQuestIDs []int64
TitleGrants []CompletionTicketTitleGrant
ProfessionChanged bool
}
// PlanCompletionTicket interprets every quest-ticket [action type] present in
// the current PVF. It never grants ordinary quest rewards: the ticket itself is
// the authorization to record the selected quests as completed.
func (c *Catalog) PlanCompletionTicket(
record dnfrepo.QuestRecord,
characterRecord dnfrepo.CharacterRecord,
character CharacterEligibility,
spec CompletionTicketSpec,
completedAt time.Time,
) (CompletionTicketPlan, error) {
if c == nil || completedAt.IsZero() || !definitionCharacterJobValid(character) {
return CompletionTicketPlan{}, ErrCompletionTicketActionInvalid
}
if characterRecord.Level != character.Level || character.Level <= 0 ||
(spec.MinimumLevel > 0 && int64(character.Level) < spec.MinimumLevel) ||
(spec.MaximumLevel > 0 && int64(character.Level) > spec.MaximumLevel) {
return CompletionTicketPlan{}, ErrCompletionTicketLevelInvalid
}
action := normalizeTicketAction(spec.ActionType)
if action == "[quest clear coupon]" {
// This family opens the native selection UI; op1429 owns the selected
// quest mutation and therefore must retain the selector from that packet.
return CompletionTicketPlan{}, ErrCompletionTicketSelectionRequired
}
plan := CompletionTicketPlan{
Quest: dnfrepo.CloneQuest(record),
Character: dnfrepo.CloneCharacter(characterRecord),
}
completed, _ := questStateSets(plan.Quest)
answers := questAnswerSelections(plan.Quest)
candidates := make([]int64, 0, 64)
selected := make(map[int64]struct{})
projected := make(map[int64]struct{}, len(completed)+64)
for questID := range completed {
projected[questID] = struct{}{}
}
add := func(definition Definition, requirePrerequisites bool) bool {
if definition.ID <= 0 || definition.LevelMin > character.Level ||
!definitionMatchesCharacter(definition, character) {
return false
}
// Level-up tickets advance main progression only. Side/external stories
// and equipment-slot unlocks stay player-selected, while Hell Party
// unlocks must reach the normal finish path so each reward can write its
// own durable permission.
if (spec.PreserveSideQuests && ticketOptionalExternalQuest(definition)) ||
(spec.PreserveHellPartyUnlocks && c.HasHellPartyUnlock(definition.ID)) {
return false
}
if _, done := projected[definition.ID]; done {
return false
}
if _, exists := selected[definition.ID]; exists {
return false
}
if requirePrerequisites && !ticketPrerequisitesMet(definition, projected, answers) {
return false
}
selected[definition.ID] = struct{}{}
projected[definition.ID] = struct{}{}
candidates = append(candidates, definition.ID)
// A [question] quest auto-completed by the ticket answers with the
// legacy default 0 (the only answer the old handler could persist).
// Record that projected answer so the branch depending on it
// (for example 3868 -> 3870) can be selected in the same plan instead
// of waiting for a second ticket.
if normalizeQuestTag(definition.Type) == "question" {
if _, answered := answers[definition.ID]; !answered {
answers[definition.ID] = 0
}
}
return true
}
switch action {
case "[any quest clear]":
if len(spec.ActionValues) == 0 {
return CompletionTicketPlan{}, ErrCompletionTicketActionInvalid
}
for _, questID := range spec.ActionValues {
definition, found := c.Find(questID)
if !found {
continue
}
add(definition, false)
}
case "[gm quest clear]":
c.collectTicketBulkCandidates(character, character.Level, func(definition Definition) bool {
return normalizeQuestTag(definition.Grade) == "epic"
}, add, true)
case "[lv clear]":
cutoff := character.Level
if len(spec.ActionValues) > 0 && spec.ActionValues[0] > 0 && int64(cutoff) > spec.ActionValues[0] {
cutoff = int(spec.ActionValues[0])
}
c.collectTicketBulkCandidates(character, cutoff, func(definition Definition) bool {
grade := normalizeQuestTag(definition.Grade)
return grade == "epic" || grade == "title" ||
(grade == "achievement" && !ticketExcludedAchievement(definition))
}, add, true)
case "[quest clear]":
cutoff := character.Level
if spec.MaximumLevel > 0 && int64(cutoff) > spec.MaximumLevel {
cutoff = int(spec.MaximumLevel)
}
c.collectTicketBulkCandidates(character, cutoff, func(definition Definition) bool {
switch normalizeQuestTag(definition.Grade) {
case "epic", "", "normal", "side":
return true
default:
return false
}
}, add, true)
case "[achievement quest clear]", "[side quest clear]":
c.collectTicketBulkCandidates(character, character.Level, func(definition Definition) bool {
grade := normalizeQuestTag(definition.Grade)
if grade == "title" {
return true
}
return (grade == "achievement" || (action == "[side quest clear]" && grade == "side")) &&
!ticketExcludedAchievement(definition)
}, add, true)
case "[first awaken clear]", "[second awaken clear]":
// 转职达成券 reuses [first awaken clear] with a single operand. The
// item description excludes 外传 jobs (dark knight / creator). For the
// normal jobs the client's native "转职状态" gate rejects use even with
// an accepted branch, so 90CN.dll routes it through the op44 fallback
// and the server completes the accepted branch (or the operand branch
// when none is active). The two-operand form is the real awakening
// ticket.
if action == "[first awaken clear]" && len(spec.ActionValues) == 1 {
if character.Job == currentExternalJobDarkKnight || character.Job == currentExternalJobCreator {
return CompletionTicketPlan{}, ErrCompletionTicketNoApplicableQuest
}
wanted := spec.ActionValues[0]
if wanted <= 0 {
wanted = 1
}
var chosen Definition
chosenTarget := int64(-1)
// 1) A branch already accepted at the trainer wins.
_, active := questStateSets(record)
for questID := range active {
definition, found := c.Find(questID)
if !found || definition.JobChangeQuest != 1 ||
!definitionMatchesCharacter(definition, character) {
continue
}
target := classChangeGrowType(definition)
if target < 0 {
// "转职祝贺" and other [job change quest] 1 helpers do not
// carry the [grow type] finish reward; only the actual
// branch quest can be the ticket target.
continue
}
chosen, chosenTarget = definition, target
break
}
// 2) No accepted branch: use the operand branch, else the lowest.
if chosenTarget < 0 {
for _, definition := range c.ordered {
if definition.JobChangeQuest != 1 || !definitionMatchesCharacter(definition, character) {
continue
}
target := classChangeGrowType(definition)
if target < 0 {
continue
}
if target == wanted {
chosen, chosenTarget = definition, target
break
}
if chosenTarget < 0 || target < chosenTarget {
chosen, chosenTarget = definition, target
}
}
}
if chosenTarget < 0 || !add(chosen, false) {
return CompletionTicketPlan{}, ErrCompletionTicketNoApplicableQuest
}
if plan.Character.Stats == nil {
plan.Character.Stats = make(map[string]int64)
}
newGrowType := int64(character.GrowType&0xf0) | chosenTarget
if plan.Character.Stats["grow_type"] != newGrowType {
plan.Character.Stats["grow_type"] = newGrowType
plan.Character.UpdatedAt = completedAt
if !containsCharacterField(plan.CharacterFields, dnfrepo.CharacterFieldStats) {
plan.CharacterFields = append(plan.CharacterFields, dnfrepo.CharacterFieldStats)
}
plan.ProfessionChanged = true
}
break
}
targetStage := 1
jobChangeQuest := 2
if action == "[second awaken clear]" {
targetStage = 2
jobChangeQuest = 3
}
currentStage := (character.GrowType >> 4) & 0x0f
if currentStage >= targetStage || currentStage+1 != targetStage {
return CompletionTicketPlan{}, ErrCompletionTicketNoApplicableQuest
}
c.collectTicketBulkCandidates(character, character.Level, func(definition Definition) bool {
return definition.JobChangeQuest == jobChangeQuest
}, add, false)
if len(candidates) > 0 {
newGrowType := int64(targetStage<<4 | (character.GrowType & 0x0f))
if plan.Character.Stats == nil {
plan.Character.Stats = make(map[string]int64)
}
if plan.Character.Stats["grow_type"] != newGrowType {
plan.Character.Stats["grow_type"] = newGrowType
plan.Character.UpdatedAt = completedAt
plan.CharacterFields = append(plan.CharacterFields, dnfrepo.CharacterFieldStats)
plan.ProfessionChanged = true
}
}
default:
return CompletionTicketPlan{}, fmt.Errorf("%w: %q", ErrCompletionTicketActionInvalid, spec.ActionType)
}
// Parent [quest clear] definitions own objective children. Completing the
// parent via a coupon must also retire already-active child rows so the task
// panel does not retain a ghost objective after its parent is cleared.
for index := 0; index < len(candidates); index++ {
parent, found := c.Find(candidates[index])
if !found || normalizeQuestTag(parent.Type) != "quest clear" {
continue
}
for _, childID := range parent.IntData {
child, childFound := c.Find(childID)
if childFound && child.MainQuestID == parent.ID && definitionMatchesCharacter(child, character) {
add(child, false)
}
}
}
if len(candidates) == 0 {
return CompletionTicketPlan{}, ErrCompletionTicketNoApplicableQuest
}
for _, questID := range candidates {
definition, found := c.Find(questID)
if !found {
continue
}
state, field, known := mutableQuestState(&plan.Quest, questID)
isTitleBook := titleBookProgressDefinition(definition)
isTitleRewardAchievement := !isTitleBook && titleAchievementDefinition(definition)
if isTitleBook {
// Title-book achievements (grade [title]) live in Quest.Progress
// as title_book_* rows: the client's title page reads exactly this
// snapshot plus inventory list 100.
if plan.Quest.Progress == nil {
plan.Quest.Progress = make(map[int64]dnfrepo.QuestState)
}
if existing, found := plan.Quest.Progress[questID]; found {
state = existing
} else {
state = dnfrepo.QuestState{}
}
field = dnfrepo.QuestFieldProgress
} else if !known {
state = dnfrepo.QuestState{}
field = dnfrepo.QuestFieldStates
if plan.Quest.States == nil {
plan.Quest.States = make(map[int64]dnfrepo.QuestState)
}
}
if isTitleBook {
state.Status = "title_book_completed"
} else {
state.Status = "completed"
}
state.ProgressValue = 0
state.RewardSelectIndex = 0
state.UpdatedAt = completedAt
if state.Extra == nil {
state.Extra = make(map[string]string, 8)
}
state.Extra["pvf_path"] = definition.Path
state.Extra["quest_type"] = definition.Type
state.Extra["reward_state"] = finishRewardGranted
state.Extra["auto_completed"] = "true"
state.Extra["auto_complete_reason"] = "pvf_quest_completion_ticket_no_reward"
state.Extra["auto_complete_action"] = action
// Persist the legacy answer for [question] quests so later tickets,
// accept lists and successors resolve the same branch the plan took.
if normalizeQuestTag(definition.Type) == "question" {
state.Extra[questAnswerExtraKey] = "0"
}
if isTitleBook {
// Same durable contract the achievement owner writes when a title
// achievement completes: initialized plus all three remainders 0.
state.Extra["title_book_initialized"] = "1"
state.Extra["title_book_remain_1"] = "0"
state.Extra["title_book_remain_2"] = "0"
state.Extra["title_book_remain_3"] = "0"
}
if isTitleBook || isTitleRewardAchievement {
plan.TitleGrants = append(plan.TitleGrants, CompletionTicketTitleGrant{
QuestID: questID,
ItemID: titleRewardItemID(definition),
})
}
// Support/magic-stone/earring quests unlock their equipment slot in
// the normal finish path. The bulk ticket bypasses that settlement, so
// apply the same durable character bit here or the ticket would only
// mark the quest complete while the slot stays locked.
if normalizeQuestTag(definition.RewardType) == "slot expansion" && len(definition.RewardIntData) == 1 {
if bit, ok := ExEquipSlotBitForPVFIndex(uint32(definition.RewardIntData[0])); ok {
if plan.Character.Stats == nil {
plan.Character.Stats = make(map[string]int64)
}
if plan.Character.Stats["ex_equip_slot_stat"]&int64(bit) == 0 {
plan.Character.Stats["ex_equip_slot_stat"] |= int64(bit)
plan.Character.UpdatedAt = completedAt
if !containsCharacterField(plan.CharacterFields, dnfrepo.CharacterFieldStats) {
plan.CharacterFields = append(plan.CharacterFields, dnfrepo.CharacterFieldStats)
}
}
}
}
if field == dnfrepo.QuestFieldProgress {
plan.Quest.Progress[questID] = state
} else {
plan.Quest.States[questID] = state
}
plan.QuestFields = mergeQuestChangedField(plan.QuestFields, field)
plan.CompletedQuestIDs = append(plan.CompletedQuestIDs, questID)
}
if len(plan.QuestFields) == 0 {
return CompletionTicketPlan{}, ErrCompletionTicketNoApplicableQuest
}
plan.Quest.UpdatedAt = completedAt
sort.Slice(plan.CompletedQuestIDs, func(i, j int) bool { return plan.CompletedQuestIDs[i] < plan.CompletedQuestIDs[j] })
return plan, nil
}
func (c *Catalog) collectTicketBulkCandidates(
character CharacterEligibility,
cutoff int,
match func(Definition) bool,
add func(Definition, bool) bool,
requirePrerequisites bool,
) {
if c == nil || cutoff <= 0 || match == nil || add == nil {
return
}
for progressed := true; progressed; {
progressed = false
for _, definition := range c.ordered {
if definition.LevelMin <= 0 || definition.LevelMin > cutoff ||
definition.IsEvent || ticketExcludedGrade(normalizeQuestTag(definition.Grade)) || !match(definition) ||
!definitionMatchesCharacter(definition, character) {
continue
}
// Achievement quests are never offered by an NPC: the current PVF
// sets [exposed by npc] = 0 for the whole family (title-book
// achievements and bounty quests alike), and the client advances
// them through op417 instead of the normal accept list. The
// title-book family (grade [title]) works the same way. Keep the
// NPC-exposure requirement for story quests, but let both
// families through so [lv clear] and the dedicated achievement
// ticket actually complete them.
isAchievementFamily := normalizeQuestTag(definition.Grade) == "achievement" ||
normalizeQuestTag(definition.Grade) == "title"
if definition.ExposedByNPC == 0 && !isAchievementFamily {
continue
}
// These families are counter-driven: their [pre required]
// entries reference trackers (for example id 1892) that are not
// quests at all, so requiring quest prerequisites would leave
// them permanently locked. The QA completion ticket owns the
// "complete everything" contract for this family.
require := requirePrerequisites && !isAchievementFamily
if add(definition, require) {
progressed = true
}
}
}
}
func ticketPrerequisitesMet(definition Definition, completed map[int64]struct{}, answers map[int64]int64) bool {
if definition.MalformedPreAnswers {
return false
}
if definition.MainQuestID > 0 {
if _, found := completed[definition.MainQuestID]; !found {
return false
}
}
if len(definition.PreRequiredGroups) > 0 {
matched := false
for _, group := range definition.PreRequiredGroups {
if completedAll(group, completed) {
matched = true
break
}
}
if !matched {
return false
}
}
for _, requirement := range definition.PreRequiredAnswers {
if answer, found := answers[requirement.QuestID]; !found || answer != requirement.Answer {
return false
}
}
for _, collision := range definition.CollisionQuests {
if collision > 0 {
if _, found := completed[collision]; found {
return false
}
}
}
return true
}
func ticketExcludedAchievement(definition Definition) bool {
if definition.JobChangeQuest != 0 {
return true
}
switch normalizeQuestTag(definition.RewardType) {
case "grow type", "awakening type", "hell challenge":
return true
}
// These IDs are completed only by their dedicated support-equipment,
// magic-stone, earring, or Hell Party [any quest clear] tickets.
switch definition.ID {
case 649, 650, 674, 675, 2002, 2635, 2636, 2900, 4391, 4539:
return true
default:
return false
}
}
func ticketOptionalExternalQuest(definition Definition) bool {
switch normalizeQuestTag(definition.Grade) {
case "side", "common unique":
return true
}
// Support equipment, magic stone, and earring quests are not consistently
// graded across Script.pvf revisions. Their reward contract is authoritative
// and must never be bypassed by a level-up ticket.
return normalizeQuestTag(definition.RewardType) == "slot expansion"
}
func normalizeTicketAction(value string) string {
return strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "`", "")))
}
// titleBookProgressDefinition identifies the title-book achievement family
// (grade [title]). Its state is the title_book_* row in Quest.Progress, and its
// reward item is the physical title in inventory list 100.
func titleBookProgressDefinition(definition Definition) bool {
return normalizeQuestTag(definition.Grade) == "title"
}
// titleAchievementDefinition identifies the older [achievement] quests that
// reward a title item directly. They keep the ordinary completed state (they
// are not titlebook.etc rows) but still materialise their title item.
func titleAchievementDefinition(definition Definition) bool {
return normalizeQuestTag(definition.Grade) == "achievement" &&
normalizeQuestTag(definition.RewardType) == "title"
}
// titleRewardItemID returns the PVF title item carried by the reward data of a
// title-book or title-reward achievement quest.
func titleRewardItemID(definition Definition) int32 {
if len(definition.RewardIntData) == 0 {
return 0
}
itemID := definition.RewardIntData[0]
if itemID <= 0 || itemID > int64(^uint32(0)>>1) {
return 0
}
return int32(itemID)
}
func containsCharacterField(fields []dnfrepo.CharacterField, want dnfrepo.CharacterField) bool {
for _, field := range fields {
if field == want {
return true
}
}
return false
}
// 外传 jobs never receive class-change tickets (the PVF item description
// excludes them): dark knight (9) and creator (10).
const (
currentExternalJobDarkKnight = 9
currentExternalJobCreator = 10
)
// classChangeGrowType extracts the subclass encoded in a [job change quest] 1
// finish reward ([grow type] N). It returns -1 when the definition cannot be
// used as a class-change target.
func classChangeGrowType(definition Definition) int64 {
if normalizeQuestTag(definition.RewardType) != "grow type" || len(definition.RewardIntData) != 1 {
return -1
}
target := definition.RewardIntData[0]
if target <= 0 || target > 0x0f {
return -1
}
return target
}
@@ -0,0 +1,42 @@
package quest
import (
"context"
"os"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFLevelUpTicketProtectsExternalAndFeatureUnlockTasks(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify level-up ticket quest guards")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
for _, questID := range []int64{649, 650, 2635, 2636} {
definition, found := catalog.Find(questID)
if !found || !ticketOptionalExternalQuest(definition) {
t.Fatalf("feature/external quest %d definition=%+v found=%t is not protected", questID, definition, found)
}
}
for _, questID := range []int64{2002, 2900, 4391, 4539} {
definition, found := catalog.Find(questID)
if !found || !catalog.HasHellPartyUnlock(questID) {
t.Fatalf("Hell Party quest %d definition=%+v found=%t is not protected", questID, definition, found)
}
}
}
@@ -0,0 +1,175 @@
package quest
import (
"errors"
"reflect"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestPlanCompletionTicketCoversEveryDirectPVFActionFamily(t *testing.T) {
catalog := completionTicketTestCatalog()
now := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
action string
values []int64
growType int
want []int64
wantGrow int64
profession bool
}{
{name: "any exact", action: "[any quest clear]", values: []int64{300}, growType: 1, want: []int64{300}, wantGrow: 1},
{name: "achievement", action: "[achievement quest clear]", growType: 1, want: []int64{200}, wantGrow: 1},
{name: "side", action: "[side quest clear]", growType: 1, want: []int64{200, 201}, wantGrow: 1},
{name: "main and ordinary", action: "[quest clear]", growType: 1, want: []int64{100, 101, 201, 300}, wantGrow: 1},
{name: "gm main", action: "[gm quest clear]", growType: 1, want: []int64{100, 101}, wantGrow: 1},
{name: "level main achievement", action: "[lv clear]", values: []int64{90}, growType: 1, want: []int64{100, 101, 200}, wantGrow: 1},
{name: "first awakening", action: "[first awaken clear]", growType: 1, want: []int64{400}, wantGrow: 0x11, profession: true},
{name: "second awakening", action: "[second awaken clear]", growType: 0x11, want: []int64{401}, wantGrow: 0x21, profession: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
character := dnfrepo.CharacterRecord{
CharacterID: "77",
Job: "2",
Level: 90,
Stats: map[string]int64{"grow_type": int64(test.growType)},
}
plan, err := catalog.PlanCompletionTicket(
dnfrepo.QuestRecord{CharacterID: "77"},
character,
CharacterEligibility{Level: 90, Job: 2, GrowType: test.growType},
CompletionTicketSpec{ActionType: test.action, ActionValues: test.values, MinimumLevel: 1, MaximumLevel: 90},
now,
)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(plan.CompletedQuestIDs, test.want) {
t.Fatalf("completed=%v want=%v", plan.CompletedQuestIDs, test.want)
}
if plan.Character.Stats["grow_type"] != test.wantGrow || plan.ProfessionChanged != test.profession {
t.Fatalf("character=%+v profession_changed=%t", plan.Character, plan.ProfessionChanged)
}
for _, questID := range test.want {
state := plan.Quest.States[questID]
if state.Status != "completed" || state.Extra["reward_state"] != finishRewardGranted ||
state.Extra["auto_complete_action"] != test.action {
t.Fatalf("quest %d state=%+v", questID, state)
}
}
})
}
}
func TestPlanCompletionTicketSelectionCouponStaysOnSelectorOwnedPath(t *testing.T) {
catalog := completionTicketTestCatalog()
_, err := catalog.PlanCompletionTicket(
dnfrepo.QuestRecord{CharacterID: "77"},
dnfrepo.CharacterRecord{CharacterID: "77", Job: "2", Level: 90, Stats: map[string]int64{"grow_type": 1}},
CharacterEligibility{Level: 90, Job: 2, GrowType: 1},
CompletionTicketSpec{ActionType: "[quest clear coupon]"},
time.Now().UTC(),
)
if !errors.Is(err, ErrCompletionTicketSelectionRequired) {
t.Fatalf("error=%v want selection required", err)
}
}
func TestPlanCompletionTicketRejectsLevelBoundsWithoutMutation(t *testing.T) {
catalog := completionTicketTestCatalog()
_, err := catalog.PlanCompletionTicket(
dnfrepo.QuestRecord{CharacterID: "77"},
dnfrepo.CharacterRecord{CharacterID: "77", Job: "2", Level: 89, Stats: map[string]int64{"grow_type": 1}},
CharacterEligibility{Level: 89, Job: 2, GrowType: 1},
CompletionTicketSpec{ActionType: "[gm quest clear]", MinimumLevel: 90},
time.Now().UTC(),
)
if !errors.Is(err, ErrCompletionTicketLevelInvalid) {
t.Fatalf("error=%v want level invalid", err)
}
}
func TestPlanCompletionTicketCanPreserveSideStoriesAndHellPartyUnlocks(t *testing.T) {
definitions := []Definition{
{ID: 100, Path: "n_quest/main.qst", Grade: "[epic]", LevelMin: 1, LevelMax: 99, Job: "[all]", ExposedByNPC: -1},
{ID: 200, Path: "n_quest/side.qst", Grade: "[side]", LevelMin: 1, LevelMax: 99, Job: "[all]", ExposedByNPC: -1},
{ID: 250, Path: "n_quest/external.qst", Grade: "[common unique]", LevelMin: 1, LevelMax: 99, Job: "[all]", ExposedByNPC: -1},
{ID: 260, Path: "n_quest/slot.qst", Grade: "[epic]", LevelMin: 1, LevelMax: 99, Job: "[all]", ExposedByNPC: -1, RewardType: "[slot expansion]", RewardIntData: []int64{2}},
{ID: 300, Path: "n_quest/hell.qst", Grade: "[epic]", LevelMin: 1, LevelMax: 99, Job: "[all]", ExposedByNPC: -1, RewardType: "[hell challenge]", RewardIntData: []int64{1}},
}
catalog := &Catalog{ordered: definitions, byID: make(map[int64]Definition, len(definitions))}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
character := dnfrepo.CharacterRecord{CharacterID: "77", Job: "2", Level: 90, Stats: map[string]int64{"grow_type": 1}}
eligibility := CharacterEligibility{Level: 90, Job: 2, GrowType: 1}
now := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC)
plan, err := catalog.PlanCompletionTicket(
dnfrepo.QuestRecord{CharacterID: "77"},
character,
eligibility,
CompletionTicketSpec{ActionType: "[quest clear]", MaximumLevel: 90, PreserveSideQuests: true, PreserveHellPartyUnlocks: true},
now,
)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(plan.CompletedQuestIDs, []int64{100}) {
t.Fatalf("protected completion=%v want only main progression", plan.CompletedQuestIDs)
}
plan, err = catalog.PlanCompletionTicket(
dnfrepo.QuestRecord{CharacterID: "77"},
character,
eligibility,
CompletionTicketSpec{ActionType: "[quest clear]", MaximumLevel: 90},
now,
)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(plan.CompletedQuestIDs, []int64{100, 200, 260, 300}) {
t.Fatalf("ordinary ticket completion=%v want unrestricted ticket policy", plan.CompletedQuestIDs)
}
plan, err = catalog.PlanCompletionTicket(
dnfrepo.QuestRecord{CharacterID: "77"},
character,
eligibility,
CompletionTicketSpec{
ActionType: "[any quest clear]",
ActionValues: []int64{100, 200, 250, 260},
MaximumLevel: 90,
PreserveSideQuests: true,
},
now,
)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(plan.CompletedQuestIDs, []int64{100}) {
t.Fatalf("protected explicit completion=%v want only main progression", plan.CompletedQuestIDs)
}
}
func completionTicketTestCatalog() *Catalog {
definitions := []Definition{
{ID: 100, Path: "n_quest/main1.qst", Grade: "[epic]", LevelMin: 1, LevelMax: 99, Job: "[all]", ExposedByNPC: -1},
{ID: 101, Path: "n_quest/main2.qst", Grade: "[epic]", LevelMin: 90, LevelMax: 99, Job: "[all]", ExposedByNPC: -1, PreRequiredGroups: [][]int64{{100}}},
{ID: 200, Path: "n_quest/achievement.qst", Grade: "[achievement]", LevelMin: 10, LevelMax: 99, Job: "[all]", ExposedByNPC: -1},
{ID: 201, Path: "n_quest/side.qst", Grade: "[side]", LevelMin: 10, LevelMax: 99, Job: "[all]", ExposedByNPC: -1},
{ID: 300, Path: "n_quest/ordinary.qst", Grade: "[normal]", LevelMin: 10, LevelMax: 99, Job: "[all]", ExposedByNPC: -1},
{ID: 400, Path: "n_quest/awaken1.qst", Grade: "[achievement]", LevelMin: 50, LevelMax: 99, Job: "[gunner]", GrowType: 1, HasGrowType: true, JobChangeQuest: 2, ExposedByNPC: -1},
{ID: 401, Path: "n_quest/awaken2.qst", Grade: "[achievement]", LevelMin: 75, LevelMax: 99, Job: "[gunner]", GrowType: 1, HasGrowType: true, JobChangeQuest: 3, ExposedByNPC: -1},
}
catalog := &Catalog{ordered: definitions, byID: make(map[int64]Definition, len(definitions))}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
return catalog
}
@@ -0,0 +1,117 @@
package quest
import (
"context"
"sort"
"time"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// DailyResetResult describes one idempotent daily-quest reconciliation.
type DailyResetResult struct {
CharacterID string
ServiceDayID int64
Changed bool
// ResetQuestIDs are the repeatable quests whose stored state belonged to
// an earlier service day and was cleared back to "not taken".
ResetQuestIDs []int64
}
// IsDailyRepeatable reports whether a quest's PVF grade makes it a daily task
// that resets on the 06:00 service-day boundary. The whole daily family counts -
// `daily`, `special daily`, `daily mission`, `auto daily mission` and the legacy
// `daily random` - because the client's task manual groups them as one class.
// `normaly repeat` is deliberately excluded: it repeats with no day boundary, so
// its stored row is never cleared.
func (c *Catalog) IsDailyRepeatable(questID int64) bool {
if c == nil {
return false
}
definition, found := c.Find(questID)
if !found {
return false
}
return isDailyGrade(normalizeQuestTag(definition.Grade))
}
// ReconcileDailyReset clears every repeatable quest state that was last
// written in an earlier service day, so a daily finished yesterday is takeable
// again today.
//
// This is a catch-up reconciliation rather than a scheduled 06:00 sweep on
// purpose: this deployment is not running continuously, and a timer that only
// fires while the process is alive silently skips every boundary the server
// slept through - which is exactly how yesterday's dailies stayed completed.
// Running it from the login path makes the reset depend on the calendar, not
// on server uptime, and it is idempotent, so the two can coexist.
//
// The boundary is premium.ServiceDayID, the 06:00 Beijing reset the fatigue,
// daily-dungeon and delete-guard paths already share.
func (o *Owner) ReconcileDailyReset(
ctx context.Context,
catalog *Catalog,
characterID string,
now time.Time,
) (DailyResetResult, error) {
if o == nil || o.quests == nil {
return DailyResetResult{}, ErrOwnerUnavailable
}
if characterID == "" {
return DailyResetResult{}, ErrCharacterRequired
}
if catalog == nil {
return DailyResetResult{CharacterID: characterID, ServiceDayID: premium.ServiceDayID(now.UTC())}, nil
}
record, found, err := o.quests.Load(ctx, characterID)
if err != nil {
return DailyResetResult{}, err
}
result := DailyResetResult{CharacterID: characterID, ServiceDayID: premium.ServiceDayID(now.UTC())}
if !found {
return result, nil
}
fields := make([]dnfrepo.QuestField, 0, 2)
if cleared := clearStaleDailyQuestStates(record.States, catalog, result.ServiceDayID, &result.ResetQuestIDs); cleared {
fields = append(fields, dnfrepo.QuestFieldStates)
}
if cleared := clearStaleDailyQuestStates(record.Progress, catalog, result.ServiceDayID, &result.ResetQuestIDs); cleared {
fields = append(fields, dnfrepo.QuestFieldProgress)
}
if len(fields) == 0 {
return result, nil
}
sort.Slice(result.ResetQuestIDs, func(i, j int) bool { return result.ResetQuestIDs[i] < result.ResetQuestIDs[j] })
record.CharacterID = characterID
record.UpdatedAt = now.UTC()
if err := dnfrepo.SaveQuestFields(ctx, o.quests, record, fields...); err != nil {
return DailyResetResult{}, err
}
result.Changed = true
return result, nil
}
// clearStaleDailyQuestStates drops daily quest rows written before the current
// service day. A row with no timestamp is left alone: without a completion time
// there is no evidence it belongs to an earlier day, and deleting it would wipe
// progress the player just made.
func clearStaleDailyQuestStates(
states map[int64]dnfrepo.QuestState,
catalog *Catalog,
serviceDayID int64,
resetQuestIDs *[]int64,
) bool {
changed := false
for questID, state := range states {
if state.UpdatedAt.IsZero() ||
premium.ServiceDayID(state.UpdatedAt.UTC()) >= serviceDayID ||
!catalog.IsDailyRepeatable(questID) {
continue
}
delete(states, questID)
*resetQuestIDs = append(*resetQuestIDs, questID)
changed = true
}
return changed
}
@@ -0,0 +1,86 @@
package quest
import (
"context"
"fmt"
"os"
"sort"
"strings"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// TestRealScriptPVFDailyFamilyClassification pins the daily rule against the
// live archive.
//
// The rule is "the grade contains daily", so this guard fails the moment the
// archive carries a daily-family grade the predicate stops recognising - which is
// exactly how `daily mission` and `auto daily mission` silently stayed one-shot.
// It also checks that every daily the server can now offer carries a parsable
// reward: these grades become selectable under the same rule, so a malformed one
// would hand the player a quest that cannot be settled.
func TestRealScriptPVFDailyFamilyClassification(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the live daily family")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
counts := map[string]int{}
malformed := make([]string, 0)
repeatableMisclassified := make([]string, 0)
for _, definition := range catalog.Definitions() {
grade := normalizeQuestTag(definition.Grade)
wantDaily := strings.Contains(grade, "daily")
if got := catalog.IsDailyRepeatable(definition.ID); got != wantDaily {
t.Fatalf("quest %d grade=%q resets at the boundary=%t, want %t",
definition.ID, grade, got, wantDaily)
}
// `normaly repeat` repeats without a boundary; it must never be swept.
if grade == "normaly repeat" && catalog.IsDailyRepeatable(definition.ID) {
repeatableMisclassified = append(repeatableMisclassified, fmt.Sprint(definition.ID))
}
if !wantDaily {
continue
}
counts[grade]++
if definition.RewardParseError != "" {
malformed = append(malformed, fmt.Sprintf("%d(%s): %s", definition.ID, grade, definition.RewardParseError))
}
}
if len(counts) == 0 {
t.Fatal("live PVF carries no daily-grade quest")
}
grades := make([]string, 0, len(counts))
for grade := range counts {
grades = append(grades, grade)
}
sort.Strings(grades)
for _, grade := range grades {
t.Logf("daily grade %q: %d quests", grade, counts[grade])
}
for _, grade := range []string{"daily", "daily mission"} {
if counts[grade] == 0 {
t.Fatalf("live PVF no longer carries grade %q; the daily rule must be re-derived", grade)
}
}
if len(repeatableMisclassified) != 0 {
t.Fatalf("normaly repeat quests treated as daily: %v", repeatableMisclassified)
}
if len(malformed) != 0 {
t.Fatalf("daily-grade quests with reward parse errors: %v", malformed)
}
}
@@ -0,0 +1,278 @@
package quest
import (
"context"
"errors"
"slices"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/premium"
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"
)
// dailyQuestResetTestCatalog is the shared fixture for the daily-reset specs. It
// carries one quest per grade class the 06:00 boundary must distinguish:
//
// 1 daily reset
// 2 special daily reset
// 3 normaly repeat never reset (repeatable, not a daily)
// 4 epic never reset (one-shot)
// 5 daily mission reset (the mission tab's daily)
// 6 auto daily mission reset
func dailyQuestResetTestCatalog(t *testing.T) *Catalog {
t.Helper()
source := catalogTestSource{
DefaultList: "1 `daily.qst`\n2 `special.qst`\n3 `repeat.qst`\n4 `epic.qst`\n" +
"5 `dailymission.qst`\n6 `autodailymission.qst`\n",
"n_quest/daily.qst": questCatalogTestDefinition("[daily]", 1, 90, "[gunner]", ""),
"n_quest/special.qst": questCatalogTestDefinition("[special daily]", 1, 90, "[gunner]", ""),
"n_quest/repeat.qst": questCatalogTestDefinition("[normaly repeat]", 1, 90, "[gunner]", ""),
"n_quest/epic.qst": questCatalogTestDefinition("[epic]", 1, 90, "[gunner]", ""),
"n_quest/dailymission.qst": questCatalogTestDefinition("[daily mission]", 1, 90, "[gunner]", ""),
"n_quest/autodailymission.qst": questCatalogTestDefinition("[auto daily mission]", 1, 90, "[gunner]", ""),
}
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
}
// TestDailyResetClassificationCoversTheWholeDailyFamily pins which grades the
// 06:00 boundary clears. Every grade the client's task manual classes as a daily
// counts - including `daily mission` (the mission tab's daily) and
// `auto daily mission`, which the earlier exact-match predicate missed and which
// therefore stayed one-shot forever. `normaly repeat` is repeatable rather than
// daily, so its row survives the boundary.
func TestDailyResetClassificationCoversTheWholeDailyFamily(t *testing.T) {
catalog := dailyQuestResetTestCatalog(t)
for _, probe := range []struct {
name string
questID int64
want bool
}{
{"daily", 1, true},
{"special daily", 2, true},
{"normaly repeat", 3, false},
{"epic", 4, false},
{"daily mission", 5, true},
{"auto daily mission", 6, true},
{"unknown quest id", 99, false},
} {
if got := catalog.IsDailyRepeatable(probe.questID); got != probe.want {
t.Fatalf("%s: resets at the daily boundary=%t, want %t", probe.name, got, probe.want)
}
}
}
// TestCatalogCompletedDailyLeavesAcceptableListForTheRestOfTheServiceDay pins
// the client-visible half of the rule: finishing a daily removes it from the
// acceptable list immediately, and only the 06:00 reset - which clears the row -
// brings it back. It replaces the earlier behaviour where a completed daily was
// re-acceptable at once, which made every daily an unlimited same-day repeat.
func TestCatalogCompletedDailyLeavesAcceptableListForTheRestOfTheServiceDay(t *testing.T) {
catalog := dailyQuestResetTestCatalog(t)
character := CharacterEligibility{Level: 90, Job: 2, GrowType: 0x12}
completedDaily := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
1: {Status: "completed"},
5: {Status: "completed"},
6: {Status: "completed"},
}}
listed := catalog.QuestList(character, completedDaily).IDs
for _, questID := range []int32{1, 5, 6} {
if slices.Contains(listed, questID) {
t.Fatalf("completed daily quest %d is still in the acceptable list", questID)
}
}
// Re-accepting is refused, not merely hidden from the window.
if _, err := catalog.PlanAccept(character, completedDaily, 1); !errors.Is(err, ErrQuestNotAcceptable) {
t.Fatalf("re-accepting a completed daily err=%v, want ErrQuestNotAcceptable", err)
}
// A repeatable quest must stay acceptable after completion.
completedRepeat := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
3: {Status: "completed"},
}}
if !slices.Contains(catalog.QuestList(character, completedRepeat).IDs, int32(3)) {
t.Fatal("a completed normaly-repeat quest disappeared from the acceptable list")
}
// A one-shot quest never returns.
completedEpic := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
4: {Status: "completed"},
}}
if slices.Contains(catalog.QuestList(character, completedEpic).IDs, int32(4)) {
t.Fatal("a completed one-shot quest came back to the acceptable list")
}
}
// TestReconcileDailyResetClearsYesterdaysDailyQuests covers the report that a
// daily finished yesterday stays finished when the server was not running at
// 06:00 - including `daily mission`, which used to never reset at all. The
// reconciliation is driven by the stored completion timestamp rather than by a
// tick, so it catches up however long the process was down.
func TestReconcileDailyResetClearsYesterdaysDailyQuests(t *testing.T) {
ctx := context.Background()
catalog := dailyQuestResetTestCatalog(t)
now := time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)
yesterday := now.Add(-24 * time.Hour)
if premium.ServiceDayID(yesterday) >= premium.ServiceDayID(now) {
t.Fatal("probe timestamps are inside the same service day")
}
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "1",
States: map[int64]dnfrepo.QuestState{
1: {Status: "completed", UpdatedAt: yesterday},
2: {Status: "completed", UpdatedAt: now.Add(-10 * time.Minute)},
3: {Status: "active", ProgressValue: 3, UpdatedAt: yesterday},
4: {Status: "completed", UpdatedAt: yesterday},
5: {Status: "completed", UpdatedAt: yesterday},
6: {Status: "completed", UpdatedAt: yesterday},
},
Progress: map[int64]dnfrepo.QuestState{
1: {Status: "completed", ProgressValue: 1, UpdatedAt: yesterday},
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.ReconcileDailyReset(ctx, catalog, "1", now)
if err != nil {
t.Fatal(err)
}
if !result.Changed {
t.Fatal("reconcile reported no change")
}
// Quest 1 is cleared once from States and once from Progress, so the sorted
// list carries its id twice.
wantReset := []int64{1, 1, 5, 6}
if !slices.Equal(result.ResetQuestIDs, wantReset) {
t.Fatalf("reset ids=%v, want %v", result.ResetQuestIDs, wantReset)
}
record, found, err := repositories.Quest.Load(ctx, "1")
if err != nil || !found {
t.Fatalf("load quest record found=%t err=%v", found, err)
}
for _, questID := range []int64{1, 5, 6} {
if _, exists := record.States[questID]; exists {
t.Fatalf("yesterday's daily quest %d is still stored", questID)
}
}
if _, exists := record.Progress[1]; exists {
t.Fatal("yesterday's daily kept its progress row")
}
if state, exists := record.States[2]; !exists || state.Status != "completed" {
t.Fatalf("today's daily was reset: %+v", record.States[2])
}
if state, exists := record.States[3]; !exists || state.Status != "active" || state.ProgressValue != 3 {
t.Fatalf("the repeatable quest was reset: %+v", record.States[3])
}
if state, exists := record.States[4]; !exists || state.Status != "completed" {
t.Fatalf("a one-shot quest was reset: %+v", record.States[4])
}
// Running it again on the same day changes nothing.
second, err := owner.ReconcileDailyReset(ctx, catalog, "1", now)
if err != nil || second.Changed {
t.Fatalf("second reconcile changed=%t err=%v", second.Changed, err)
}
}
// TestReconcileDailyResetReturnsYesterdaysDailyMissionToTheAcceptableList is the
// end-to-end guard for the reported defect: a `[daily mission]` behaved as a
// permanent one-shot because no boundary ever cleared its row, while `[daily]`
// behaved as an unlimited same-day repeat because a completed row never blocked
// re-acceptance. Both must now land on the same rule - one completion per
// service day, reset at 06:00.
func TestReconcileDailyResetReturnsYesterdaysDailyMissionToTheAcceptableList(t *testing.T) {
ctx := context.Background()
catalog := dailyQuestResetTestCatalog(t)
character := CharacterEligibility{Level: 90, Job: 2, GrowType: 0x12}
now := time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)
yesterday := now.Add(-24 * time.Hour)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "1",
States: map[int64]dnfrepo.QuestState{
1: {Status: "completed", UpdatedAt: yesterday},
5: {Status: "completed", UpdatedAt: yesterday},
6: {Status: "completed", UpdatedAt: yesterday},
},
}); err != nil {
t.Fatal(err)
}
// Before the reset, yesterday's rows still hide all three quests.
stale, found, err := repositories.Quest.Load(ctx, "1")
if err != nil || !found {
t.Fatalf("load quest record found=%t err=%v", found, err)
}
for _, questID := range []int32{1, 5, 6} {
if slices.Contains(catalog.QuestList(character, stale).IDs, questID) {
t.Fatalf("quest %d with a stale row was already acceptable", questID)
}
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
if _, err := owner.ReconcileDailyReset(ctx, catalog, "1", now); err != nil {
t.Fatal(err)
}
reconciled, found, err := repositories.Quest.Load(ctx, "1")
if err != nil || !found {
t.Fatalf("reload quest record found=%t err=%v", found, err)
}
listed := catalog.QuestList(character, reconciled).IDs
for _, questID := range []int32{1, 5, 6} {
if !slices.Contains(listed, questID) {
t.Fatalf("daily quest %d did not return to the acceptable list after the reset", questID)
}
}
}
// TestReconcileDailyResetKeepsRowsWithoutATimestamp protects quest rows that
// predate timestamped states: with no completion time there is no evidence
// they belong to an earlier day, and deleting them would wipe real progress.
func TestReconcileDailyResetKeepsRowsWithoutATimestamp(t *testing.T) {
ctx := context.Background()
source := catalogTestSource{
DefaultList: "1 `daily.qst`\n",
"n_quest/daily.qst": questCatalogTestDefinition("[daily]", 1, 90, "[gunner]", ""),
}
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)
}
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "1",
States: map[int64]dnfrepo.QuestState{1: {Status: "completed"}},
}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
result, err := owner.ReconcileDailyReset(ctx, catalog, "1", time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC))
if err != nil || result.Changed {
t.Fatalf("untimestamped row was reset: result=%+v err=%v", result, err)
}
}
+2
View File
@@ -0,0 +1,2 @@
// Package quest 承接 DNF 主线、支线和隐藏任务的 C2S 状态变更协议。
package quest
@@ -0,0 +1,57 @@
package quest
import dnfrepo "longheng.io/server/internal/modules/dnf/repository"
// MainContinuityLevelFloor returns the minimum level required by the next
// currently reachable PVF epic quest after the supplied quests are completed.
// It applies the same job, grow-type, prerequisite, answer, collision, and
// visibility predicates as op21 while evaluating the successor at its own
// minimum level. Different eligible target levels are treated as an ambiguous
// branch and fail closed.
func (c *Catalog) MainContinuityLevelFloor(
character CharacterEligibility,
record dnfrepo.QuestRecord,
completedQuestIDs ...int64,
) (int, bool) {
if c == nil || len(completedQuestIDs) == 0 {
return 0, false
}
primary, found := c.Find(completedQuestIDs[0])
if !found || normalizeQuestTag(primary.Grade) != "epic" ||
!definitionMatchesCharacter(primary, character) {
return 0, false
}
completed, active := questStateSets(record)
for _, questID := range completedQuestIDs {
if questID <= 0 {
return 0, false
}
completed[questID] = struct{}{}
delete(active, questID)
}
answers := questAnswerSelections(record)
targetLevel := 0
seen := make(map[int64]struct{})
for _, questID := range completedQuestIDs {
for _, successor := range c.Successors(questID) {
if _, duplicate := seen[successor.ID]; duplicate {
continue
}
seen[successor.ID] = struct{}{}
if normalizeQuestTag(successor.Grade) != "epic" ||
successor.LevelMin <= character.Level {
continue
}
atGate := character
atGate.Level = successor.LevelMin
if !c.definitionAcceptable(successor, atGate, completed, active, answers, true) {
continue
}
if targetLevel != 0 && targetLevel != successor.LevelMin {
return 0, false
}
targetLevel = successor.LevelMin
}
}
return targetLevel, targetLevel > 0
}
@@ -0,0 +1,76 @@
package quest
import (
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestMainContinuityLevelFloorUsesReachablePVFEpicSuccessor(t *testing.T) {
definitions := []Definition{
{ID: 3148, Grade: "[epic]", LevelMin: 8, LevelMax: 99, ExposedByNPC: -1},
{ID: 3149, Grade: "[epic]", LevelMin: 11, LevelMax: 99, ExposedByNPC: -1, PreRequiredGroups: [][]int64{{3148}}},
{ID: 3150, Grade: "[epic]", LevelMin: 13, LevelMax: 99, ExposedByNPC: -1, PreRequiredGroups: [][]int64{{3149}}},
}
catalog := testContinuityCatalog(definitions)
record := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
3148: {Status: "active"},
}}
got, ok := catalog.MainContinuityLevelFloor(CharacterEligibility{Level: 10, Job: 0, GrowType: 0}, record, 3148)
if !ok || got != 11 {
t.Fatalf("floor=(%d,%t), want (11,true)", got, ok)
}
if got, ok := catalog.MainContinuityLevelFloor(CharacterEligibility{Level: 11, Job: 0, GrowType: 0}, record, 3148); ok || got != 0 {
t.Fatalf("already-at-gate floor=(%d,%t)", got, ok)
}
}
func TestMainContinuityLevelFloorRequiresAllPVFPrerequisites(t *testing.T) {
definitions := []Definition{
{ID: 3520, Grade: "[epic]", LevelMin: 63, LevelMax: 99, ExposedByNPC: -1},
{ID: 9000, Grade: "[epic]", LevelMin: 63, LevelMax: 99, ExposedByNPC: -1},
{ID: 3521, Grade: "[epic]", LevelMin: 64, LevelMax: 99, ExposedByNPC: -1, PreRequiredGroups: [][]int64{{3520, 9000}}},
}
catalog := testContinuityCatalog(definitions)
character := CharacterEligibility{Level: 63, Job: 0, GrowType: 0}
record := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{3520: {Status: "active"}}}
if got, ok := catalog.MainContinuityLevelFloor(character, record, 3520); ok || got != 0 {
t.Fatalf("missing prerequisite floor=(%d,%t)", got, ok)
}
record.States[9000] = dnfrepo.QuestState{Status: "completed"}
if got, ok := catalog.MainContinuityLevelFloor(character, record, 3520); !ok || got != 64 {
t.Fatalf("complete prerequisites floor=(%d,%t), want (64,true)", got, ok)
}
}
func TestMainContinuityLevelFloorFiltersGrowBranchAndFailsClosedOnDifferentTargets(t *testing.T) {
definitions := []Definition{
{ID: 3356, Grade: "[epic]", LevelMin: 49, LevelMax: 99, ExposedByNPC: -1},
{ID: 3367, Grade: "[epic]", LevelMin: 50, LevelMax: 99, ExposedByNPC: -1, GrowType: 0, HasGrowType: true, PreRequiredGroups: [][]int64{{3356}}},
{ID: 3368, Grade: "[epic]", LevelMin: 50, LevelMax: 99, ExposedByNPC: -1, GrowType: 1, HasGrowType: true, PreRequiredGroups: [][]int64{{3356}}},
}
catalog := testContinuityCatalog(definitions)
record := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{3356: {Status: "active"}}}
if got, ok := catalog.MainContinuityLevelFloor(CharacterEligibility{Level: 49, Job: 0, GrowType: 0}, record, 3356); !ok || got != 50 {
t.Fatalf("grow branch floor=(%d,%t), want (50,true)", got, ok)
}
ambiguous := append([]Definition(nil), definitions...)
ambiguous = append(ambiguous, Definition{
ID: 9999, Grade: "[epic]", LevelMin: 51, LevelMax: 99, ExposedByNPC: -1,
GrowType: 0, HasGrowType: true, PreRequiredGroups: [][]int64{{3356}},
})
if got, ok := testContinuityCatalog(ambiguous).MainContinuityLevelFloor(
CharacterEligibility{Level: 49, Job: 0, GrowType: 0}, record, 3356,
); ok || got != 0 {
t.Fatalf("ambiguous target floor=(%d,%t)", got, ok)
}
}
func testContinuityCatalog(definitions []Definition) *Catalog {
catalog := &Catalog{ordered: definitions, byID: make(map[int64]Definition, len(definitions))}
for _, definition := range definitions {
catalog.byID[definition.ID] = definition
}
return catalog
}
@@ -0,0 +1,89 @@
package quest
import (
"context"
"os"
"reflect"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestExternalProfessionQuestPiecePrerequisitesUseUnlistedPVFGates(t *testing.T) {
index, err := dnfpvf.Build(context.Background(), externalProfessionQuestPieceSource{
DefaultList: "100 `ordinary.qst`\n2680 `time_piece/side_job01.qst`\n",
"n_quest/ordinary.qst": "[grade]\n`[epic]`\n[level]\n1 99\n",
"n_quest/time_piece/side_job01.qst": "[level]\n50 99\n[target character]\n`[demonic swordman]` -1 -1 `[creator mage]` -1 -1\n[/target character]\n[pre required quest]\n3427\n[/pre required quest]\n[job change quest]\n2\n",
"n_quest/time_piece/lorien_1.qst": "[level]\n1 99\n[target character]\n`[demonic swordman]` -1 -1 `[creator mage]` -1 -1\n[/target character]\n[pre required quest]\n1777\n[/pre required quest]\n[quest piece]\n1\n",
"n_quest/time_piece/powerstation_1.qst": "[level]\n79 99\n[target character]\n`[demonic swordman]` -1 -1 `[creator mage]` -1 -1\n[/target character]\n[pre required quest]\n4488\n[/pre required quest]\n[quest piece]\n1\n",
}, dnfpvf.BuildOptions{Lists: []string{DefaultList}, Prefixes: []string{"n_quest/"}})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
if got := catalog.ExternalProfessionQuestPiecePrerequisites(CharacterEligibility{Level: 90, Job: 9}); !reflect.DeepEqual(got, []int64{1777, 4488}) {
t.Fatalf("dark-knight quest-piece gates=%v", got)
}
if got := catalog.ExternalProfessionQuestPiecePrerequisites(CharacterEligibility{Level: 50, Job: 10}); !reflect.DeepEqual(got, []int64{1777}) {
t.Fatalf("creator level-gated quest-piece gates=%v", got)
}
if got := catalog.ExternalProfessionQuestPiecePrerequisites(CharacterEligibility{Level: 90, Job: 12}); len(got) != 0 {
t.Fatalf("ordinary profession received external gates=%v", got)
}
}
func TestRealScriptPVFExternalProfessionQuestAndAwakeningContract(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify external-profession quests")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, dnfpvf.BuildOptions{
Lists: []string{DefaultList}, Prefixes: []string{"n_quest/"},
})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
wantGates := []int64{1777, 3044, 4475, 4477, 4478, 4479, 4480, 4481, 4482, 4483, 4484, 4485, 4486, 4487, 4488, 4489}
for _, job := range []int{9, 10} {
if got := catalog.ExternalProfessionQuestPiecePrerequisites(CharacterEligibility{Level: 90, Job: job}); !reflect.DeepEqual(got, wantGates) {
t.Fatalf("job %d real quest-piece gates=%v want=%v", job, got, wantGates)
}
}
completed3427 := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{3427: {Status: "completed"}}}
for _, job := range []int{9, 10} {
if !containsCatalogQuestID(catalog.Acceptable(CharacterEligibility{Level: 50, Job: job}, completed3427).IDs, 2680) {
t.Fatalf("job %d first-awakening quest 2680 is not acceptable after 3427", job)
}
}
}
type externalProfessionQuestPieceSource map[string]string
func (s externalProfessionQuestPieceSource) ReadText(path string) (string, error) {
value, ok := s[path]
if !ok {
return "", os.ErrNotExist
}
return value, nil
}
func (s externalProfessionQuestPieceSource) RangePaths(yield func(string) bool) {
for path := range s {
if !yield(path) {
return
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,192 @@
package quest
import (
"context"
"fmt"
"strings"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
"longheng.io/server/internal/modules/dnf/progression"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 满级角色的任务经验以前被整笔丢掉:角色经验被钉在满级入口阈值上,
// 熟练等级没写、骁勇结晶也没写。现在越过上限的部分必须与角色经验同事务
// 记进账号级冒险团状态(骁勇结晶 / 成长胶囊槽 / 荣誉经验),重放不重复记。
func TestOwnerApplyFinishSettlementCreditsAdventureGroupAtLevelCap(t *testing.T) {
ctx := context.Background()
repos := finishOwnerSeed(t, ctx)
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc"}); err != nil {
t.Fatal(err)
}
tables := finishOwnerLevelCapProgression(t)
entryExperience, err := tables.ThresholdToNext(progression.MaximumCharacterLevel - 1)
if err != nil {
t.Fatal(err)
}
character, found, err := repos.Character.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("character found=%t err=%v", found, err)
}
character.Level = progression.MaximumCharacterLevel
character.Stats = map[string]int64{"exp": int64(entryExperience), "grow_type": 0}
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
skill, found, err := repos.Skill.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("skill found=%t err=%v", found, err)
}
skill.Points = dnfrepo.SkillPointState{SyncedLevel: progression.MaximumCharacterLevel}
if err := repos.Skill.Save(ctx, skill); err != nil {
t.Fatal(err)
}
// 30 点任务经验 = 1 个结晶(换算比例按测试配置,便于直接断言)。
config := adventuregroup.RuntimeConfig{
ShopCategories: []adventuregroup.ShopCategory{
{
Index: adventuregroup.ShopPointBrave,
MaxPoint: 250,
ExperiencePerPoint: 30,
PointPerExperience: 1,
},
{Index: adventuregroup.ShopPointGlory, MaxPoint: 9999, ResetPointMonthly: true},
{Index: adventuregroup.ShopPointPure},
},
Capsule: adventuregroup.CapsuleConfig{
MinimumExperience: 10,
MaximumExperience: 500,
MaximumCount: 10,
MinimumLevel: 50,
MaximumLevel: 86,
GrantedExperience: 1_000_000,
},
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
input := finishOwnerInput(tables, finishOwnerAllocator(nil))
input.AdventureRuntime = &config
first, err := owner.ApplyFinishSettlement(ctx, finishOwnerCatalog(), input)
if err != nil {
t.Fatal(err)
}
if first.ExperienceDelta != 30 {
t.Fatalf("experience delta=%d want=30", first.ExperienceDelta)
}
account, found, err := repos.Account.Load(ctx, "acc")
if err != nil || !found {
t.Fatalf("account found=%t err=%v", found, err)
}
if account.HonorExp != 30 {
t.Fatalf("honor_exp=%d want=30", account.HonorExp)
}
state, err := adventuregroup.ParseRuntimeState(account, config, input.CommittedAt)
if err != nil {
t.Fatal(err)
}
if state.ShopPoints[adventuregroup.ShopPointBrave] != 1 || state.GrowthExperience != 30 {
t.Fatalf("adventure state=%+v", state)
}
// 重放(同一 completion key)复用收据,不能再记一次结晶。
second, err := owner.ApplyFinishSettlement(ctx, finishOwnerCatalog(), input)
if err != nil {
t.Fatal(err)
}
if !second.Replayed {
t.Fatalf("second result=%+v", second)
}
replayed, found, err := repos.Account.Load(ctx, "acc")
if err != nil || !found {
t.Fatalf("replayed account found=%t err=%v", found, err)
}
if replayed.HonorExp != 30 {
t.Fatalf("replay double-credited honor_exp=%d", replayed.HonorExp)
}
}
// 未满级角色做任务:任务经验照常进角色经验,**同时全额镜像进骁勇结晶**
// (2026-09-24 服主口径:没满级也要能攒结晶),但成长胶囊槽与名誉经验
// 都是满级专属,一格都不动。
//
// 这条用例原先钉的是"未满级一分不动"的旧口径,随规则变更一起改写 ——
// 断言从"没有账号状态"改成"只有结晶、没有胶囊/名誉"。
func TestOwnerApplyFinishSettlementCreditsCrystalsOnlyBelowCap(t *testing.T) {
ctx := context.Background()
repos := finishOwnerSeed(t, ctx)
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc"}); err != nil {
t.Fatal(err)
}
config := adventuregroup.RuntimeConfig{
ShopCategories: []adventuregroup.ShopCategory{{
Index: adventuregroup.ShopPointBrave,
MaxPoint: 250,
ExperiencePerPoint: 30,
PointPerExperience: 1,
}},
Capsule: adventuregroup.CapsuleConfig{
MinimumExperience: 10,
MaximumExperience: 100,
MaximumCount: 10,
MinimumLevel: 50,
MaximumLevel: 86,
GrantedExperience: 1_000_000,
},
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
input := finishOwnerInput(finishOwnerProgression(t), finishOwnerAllocator(nil))
input.AdventureRuntime = &config
if _, err := owner.ApplyFinishSettlement(ctx, finishOwnerCatalog(), input); err != nil {
t.Fatal(err)
}
account, found, err := repos.Account.Load(ctx, "acc")
if err != nil || !found {
t.Fatalf("account found=%t err=%v", found, err)
}
if account.HonorExp != 0 {
t.Fatalf("below-cap quest finish wrote honor_exp=%d, want 0 (名誉等级是满级专属)", account.HonorExp)
}
state, err := adventuregroup.ParseRuntimeState(account, config, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if state.ShopPoints[adventuregroup.ShopPointBrave] != 3 || state.BraveExperience != 10 {
t.Fatalf("below-cap quest crystals=%+v, want 3 points + 10 remainder", state)
}
if state.GrowthExperience != 0 {
t.Fatalf("below-cap quest experience entered the capsule slot: %+v", state)
}
}
// 满级专用表:ExpTable 每级 +1000(90 级入口 = 90000),SP 表覆盖到 90 级。
func finishOwnerLevelCapProgression(t *testing.T) *progression.Tables {
t.Helper()
thresholds := make([]string, progression.MaximumCharacterLevel)
for index := range thresholds {
thresholds[index] = fmt.Sprintf("%d", (index+1)*1000)
}
var sp strings.Builder
sp.WriteString("[sp table]\n")
for level := 1; level <= progression.MaximumCharacterLevel; level++ {
fmt.Fprintf(&sp, "%d %d\n", level, level*3)
}
sp.WriteString("[/sp table]\n[tp table]\n50 1\n[/tp table]\n")
tables, err := progression.Load(context.Background(), finishProgressionSource{
progression.ExperienceTablePath: strings.Join(thresholds, " ") + "\n",
progression.SkillPointTablePath: sp.String(),
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
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,108 @@
package quest
import (
"testing"
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
)
// 任务奖励里的代币券 / 欢乐代币券处理(纯函数部分)。
//
// 券在道具 PVF 里是普通 `[material]`、**没有 `[action type]`**,客户端根本没有任何
// 途径把它们换成钱包余额;当普通道具发下去,玩家只会拿到一件永远用不掉的"材料"。
// 所以发放前先把它们从奖励规则里摘出来,改成入账号钱包。
func finishVoucherTestRule(itemID, count int64) RewardItemRule {
return RewardItemRule{ItemID: itemID, Count: count}
}
// 拆分必须只摘出券,并且**保持非券规则的原有顺序**——顺序变了会改变落格结果,
// 那是"修了 A 坏了 B"的典型来源。
func TestSplitFinishVoucherRulesKeepsOrdinaryRulesInOrder(t *testing.T) {
rules := []RewardItemRule{
finishVoucherTestRule(100050, 1),
finishVoucherTestRule(dnfcerashop.TokenCeraVoucherItemID, 100),
finishVoucherTestRule(100056, 3),
finishVoucherTestRule(dnfcerashop.HappyTokenCeraVoucherItemID, 7),
finishVoucherTestRule(100057, 2),
}
vouchers, items := splitFinishVoucherRules(rules)
if len(vouchers) != 2 || len(items) != 3 {
t.Fatalf("vouchers=%+v items=%+v", vouchers, items)
}
for index, want := range []int64{100050, 100056, 100057} {
if items[index].ItemID != want {
t.Fatalf("非券规则顺序被改变: items[%d]=%d want=%d", index, items[index].ItemID, want)
}
}
if vouchers[0].ItemID != dnfcerashop.TokenCeraVoucherItemID ||
vouchers[1].ItemID != dnfcerashop.HappyTokenCeraVoucherItemID {
t.Fatalf("券的顺序被改变: %+v", vouchers)
}
}
// 没有券时必须原样返回(不得产生任何副作用)。
func TestSplitFinishVoucherRulesLeavesVoucherFreeRewardsUntouched(t *testing.T) {
if vouchers, items := splitFinishVoucherRules([]RewardItemRule{finishVoucherTestRule(1, 1)}); len(vouchers) != 0 || len(items) != 1 {
t.Fatalf("无券时不该有变化: vouchers=%+v items=%+v", vouchers, items)
}
if vouchers, items := splitFinishVoucherRules(nil); len(vouchers) != 0 || len(items) != 0 {
t.Fatalf("空输入: vouchers=%+v items=%+v", vouchers, items)
}
}
func TestFinishVoucherCountsSumsPerKind(t *testing.T) {
counts, err := finishVoucherCounts([]RewardItemRule{
finishVoucherTestRule(dnfcerashop.TokenCeraVoucherItemID, 100),
finishVoucherTestRule(dnfcerashop.HappyTokenCeraVoucherItemID, 7),
finishVoucherTestRule(dnfcerashop.TokenCeraVoucherItemID, 5),
})
if err != nil {
t.Fatal(err)
}
if counts.TokenCera != 105 || counts.HappyTokenCera != 7 {
t.Fatalf("counts=%+v want token=105 happy=7", counts)
}
}
// 坏值必须被挡住:券既然不走落格路径,就没有别的东西替我们校验这些字段了
// (allocateFinishRewardItems 的那套校验对券不再执行)。
func TestFinishVoucherCountsRejectsMalformedRules(t *testing.T) {
cases := map[string]RewardItemRule{
"zero_count": finishVoucherTestRule(dnfcerashop.TokenCeraVoucherItemID, 0),
"negative": finishVoucherTestRule(dnfcerashop.TokenCeraVoucherItemID, -1),
"non_voucher": finishVoucherTestRule(100050, 1),
"zero_item": finishVoucherTestRule(0, 1),
"oversized": finishVoucherTestRule(dnfcerashop.TokenCeraVoucherItemID, 1<<33),
}
for name, rule := range cases {
t.Run(name, func(t *testing.T) {
if _, err := finishVoucherCounts([]RewardItemRule{rule}); err == nil {
t.Fatalf("坏值必须报错: %+v", rule)
}
})
}
}
// 汇总后超过客户端可读范围(i32)时必须报错,而不是让它截断成负数。
func TestFinishVoucherCountsRejectsPerKindOverflow(t *testing.T) {
if _, err := finishVoucherCounts([]RewardItemRule{
finishVoucherTestRule(dnfcerashop.TokenCeraVoucherItemID, 1<<31-1),
finishVoucherTestRule(dnfcerashop.TokenCeraVoucherItemID, 1),
}); err == nil {
t.Fatal("代币券汇总溢出必须报错")
}
if _, err := finishVoucherCounts([]RewardItemRule{
finishVoucherTestRule(dnfcerashop.HappyTokenCeraVoucherItemID, 1<<31-1),
finishVoucherTestRule(dnfcerashop.HappyTokenCeraVoucherItemID, 1),
}); err == nil {
t.Fatal("欢乐代币券汇总溢出必须报错")
}
// 刚好到上限是允许的。
counts, err := finishVoucherCounts([]RewardItemRule{
finishVoucherTestRule(dnfcerashop.TokenCeraVoucherItemID, 1<<31-1),
})
if err != nil || counts.TokenCera != 1<<31-1 {
t.Fatalf("刚好到上限应当允许: err=%v counts=%+v", err, counts)
}
}
@@ -0,0 +1,244 @@
// 放弃任务的道具回收:只回收"属于这个任务"的道具。
//
// 参考端(_86ref/Server/DfoServer/Game/Quests/QuestGiveupItemRecoveryPolicy.cs
// + QuestGiveupApplicationService.cs)的口径:
//
// 1. 候选集 = 本任务的 `[depend give item]` 事件道具 ∪ 本任务的需求道具
// (`[seeking]` / `[seek n meet npc]` 的 `[int data]`);
// 2. 只有"是该任务的事件道具"**或**"PVF 家族是 `[quest]`(任务专属)"才真的删;
// **普通材料(`[material]` 等)一律不动** —— 那些是玩家自己的东西,
// 任务只是数背包里的数量,交付时才扣(见 finish_owner.go 的
// consumeFinishRequiredItems);
// 3. 还要给"其它仍在进行中的任务"留够需求量(retainCount),只删超出的部分。
//
// 现场依据(2026-09-22 角色 1111 日常任务 8557「进攻准备」):
// `[type] [seeking]`、`[int data] 10094602 10`、无 `[depend give item]`,
// 而 10094602(未精炼的念气结晶)是 `[material]` + `[trade]` 的可交易普通材料
// ⇒ 它的回收名单为空:放弃应当成功,且一件都不删。
package quest
import (
"sort"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// GiveUpReclaimEntry 是一件"放弃该任务后应当回收"的道具及其保留量。
type GiveUpReclaimEntry struct {
ItemID int64
// RetainCount 是其它仍在进行中的任务对该道具的需求量:只删超出这一数量的部分。
RetainCount int64
}
// GiveUpReclaimedItem 记录一次真实发生的扣减,供日志与后续背包刷新使用。
type GiveUpReclaimedItem struct {
SlotKey string
SlotIndex uint16
ItemID int64
Delta int64
PostCount int64
RawEntry []byte
}
// PlanGiveUpReclaim 计算放弃 abandonedQuestID 时应回收的道具。
// isQuestItem 判定某道具是否属于 PVF `[quest]` 家族(任务专属、落在任务栏
// 177..232 且不可交易)。nil 视为"没有任何任务专属道具":放弃仍然成功,只是
// 不回收任何东西 —— 这与参考端在物品元数据不可用时的降级一致。
func PlanGiveUpReclaim(
catalog *Catalog,
record dnfrepo.QuestRecord,
abandonedQuestID int64,
isQuestItem func(int64) bool,
) []GiveUpReclaimEntry {
if catalog == nil || abandonedQuestID <= 0 {
return nil
}
definition, known := catalog.Find(abandonedQuestID)
if !known {
return nil
}
activationCounts := giveUpItemRuleCounts(itemPairRulesFromInts(definition.DependGiveItemData))
candidates := make(map[int64]struct{}, len(activationCounts)+2)
for itemID, count := range activationCounts {
if count > 0 {
candidates[itemID] = struct{}{}
}
}
for _, rule := range finishDefinitionSeekingRules(definition) {
if rule.ItemID > 0 && rule.Count > 0 {
candidates[rule.ItemID] = struct{}{}
}
}
if len(candidates) == 0 {
return nil
}
plan := make([]GiveUpReclaimEntry, 0, len(candidates))
for itemID := range candidates {
if _, isActivationItem := activationCounts[itemID]; !isActivationItem &&
(isQuestItem == nil || !isQuestItem(itemID)) {
// 普通材料:不是这个任务发的、也不是任务专属 ⇒ 不回收。
continue
}
plan = append(plan, GiveUpReclaimEntry{
ItemID: itemID,
RetainCount: retainedGiveUpItemCount(catalog, record, abandonedQuestID, itemID),
})
}
sort.Slice(plan, func(left, right int) bool { return plan[left].ItemID < plan[right].ItemID })
return plan
}
// retainedGiveUpItemCount 取"其它进行中任务"对该道具的最大需求量。
func retainedGiveUpItemCount(
catalog *Catalog,
record dnfrepo.QuestRecord,
abandonedQuestID int64,
itemID int64,
) int64 {
if catalog == nil || itemID <= 0 {
return 0
}
retain := int64(0)
for _, questID := range activeGiveUpQuestIDs(record, abandonedQuestID) {
definition, known := catalog.Find(questID)
if !known {
continue
}
eventCount := giveUpItemRuleCounts(itemPairRulesFromInts(definition.DependGiveItemData))[itemID]
seekingCount := giveUpItemRuleCounts(finishDefinitionSeekingRules(definition))[itemID]
need := eventCount
if seekingCount > need {
need = seekingCount
}
if need > retain {
retain = need
}
}
return retain
}
// activeGiveUpQuestIDs 返回除被放弃任务之外的进行中任务 id(States 与 Progress
// 的并集;同一任务在两处出现时只算一次)。
func activeGiveUpQuestIDs(record dnfrepo.QuestRecord, abandonedQuestID int64) []int64 {
seen := make(map[int64]struct{}, len(record.States)+len(record.Progress))
ids := make([]int64, 0, len(record.States)+len(record.Progress))
collect := func(states map[int64]dnfrepo.QuestState) {
for questID, state := range states {
if questID <= 0 || questID == abandonedQuestID {
continue
}
if !isActiveQuestStatus(state.Status) {
continue
}
if _, exists := seen[questID]; exists {
continue
}
seen[questID] = struct{}{}
ids = append(ids, questID)
}
}
collect(record.States)
collect(record.Progress)
sort.Slice(ids, func(left, right int) bool { return ids[left] < ids[right] })
return ids
}
func giveUpItemRuleCounts(rules []RewardItemRule) map[int64]int64 {
if len(rules) == 0 {
return nil
}
counts := make(map[int64]int64, len(rules))
for _, rule := range rules {
if rule.ItemID <= 0 || rule.Count <= 0 {
continue
}
counts[rule.ItemID] += rule.Count
}
return counts
}
// applyGiveUpReclaim 按计划扣减角色背包主容器(list 0 非账号共享格)里的道具。
// 只处理计数为正的普通行:计数为 0 的原始行(数量只存在于 0x77 原始行里)不动,
// 避免误删来源不明的行。扣到 0 的行删除,否则改写计数并同步 0x77 原始行。
func applyGiveUpReclaim(
record *dnfrepo.InventoryRecord,
plan []GiveUpReclaimEntry,
) []GiveUpReclaimedItem {
if record == nil || len(plan) == 0 {
return nil
}
type reclaimSlot struct {
key string
index uint16
stack dnfrepo.ItemStack
}
byItem := make(map[int64][]reclaimSlot, len(plan))
for key, stack := range record.Slots {
index, ok := parseFinishMainInventorySlotKey(key)
if !ok || finishSlotIsAccountShared(index) {
continue
}
if stack.ItemID <= 0 || stack.Count <= 0 {
continue
}
byItem[stack.ItemID] = append(byItem[stack.ItemID], reclaimSlot{key: key, index: index, stack: stack})
}
for itemID := range byItem {
slots := byItem[itemID]
sort.Slice(slots, func(left, right int) bool { return slots[left].index < slots[right].index })
byItem[itemID] = slots
}
reclaimed := make([]GiveUpReclaimedItem, 0, len(plan))
for _, entry := range plan {
if entry.ItemID <= 0 {
continue
}
slots := byItem[entry.ItemID]
if len(slots) == 0 {
continue
}
held := int64(0)
for _, candidate := range slots {
held += candidate.stack.Count
}
remaining := held - entry.RetainCount
if remaining <= 0 {
continue
}
for _, candidate := range slots {
if remaining <= 0 {
break
}
take := candidate.stack.Count
if take > remaining {
take = remaining
}
post := candidate.stack.Count - take
receipt := GiveUpReclaimedItem{
SlotKey: candidate.key,
SlotIndex: candidate.index,
ItemID: entry.ItemID,
Delta: take,
PostCount: post,
}
if post <= 0 {
delete(record.Slots, candidate.key)
} else {
stack := candidate.stack
stack.Count = post
stack = finishStackWithUpdatedCount(stack, candidate.index, entry.ItemID, post)
record.Slots[candidate.key] = stack
receipt.RawEntry = append([]byte(nil), stack.RawEntry...)
}
reclaimed = append(reclaimed, receipt)
remaining -= take
}
}
if len(reclaimed) == 0 {
return nil
}
return reclaimed
}
@@ -0,0 +1,362 @@
package quest
import (
"context"
"errors"
"testing"
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"
)
// buildGiveUpCatalog 造两个任务:3145(被放弃的收集类任务)+ 3146(另一个进行中任务)。
func buildGiveUpCatalog(t *testing.T, abandonedExtra, otherExtra string) *Catalog {
t.Helper()
source := catalogTestSource{
DefaultList: "3145 `giveup.qst`\n3146 `other.qst`\n",
"n_quest/giveup.qst": questCatalogTestDefinition("[daily]", 1, 99, "[all]", abandonedExtra),
"n_quest/other.qst": questCatalogTestDefinition("[daily]", 1, 99, "[all]", otherExtra),
}
return buildCatalogFromSource(t, source)
}
func buildCatalogFromSource(t *testing.T, source catalogTestSource) *Catalog {
t.Helper()
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
}
// 普通材料永远不会进回收名单:这正是 2026-09-22 角色 1111 / 任务 8557 的形状
// (`[seeking]` + `[int data] 10094602 10`,而 10094602 是 `[material]` 可交易材料)。
func TestPlanGiveUpReclaimSkipsOrdinaryMaterials(t *testing.T) {
catalog := buildGiveUpCatalog(t, "[type]\n`[seeking]`\n[int data]\n10094602 10\n", "")
record := dnfrepo.QuestRecord{CharacterID: "18", States: map[int64]dnfrepo.QuestState{
3145: {Status: "active"},
}}
if plan := PlanGiveUpReclaim(catalog, record, 3145, nil); len(plan) != 0 {
t.Fatalf("ordinary material reclaim plan = %+v, want empty", plan)
}
if plan := PlanGiveUpReclaim(catalog, record, 3145, func(int64) bool { return false }); len(plan) != 0 {
t.Fatalf("classifier-false reclaim plan = %+v, want empty", plan)
}
}
// 任务专属(`[quest]` 家族)道具与 `[depend give item]` 事件道具要进回收名单。
func TestPlanGiveUpReclaimIncludesQuestFamilyAndActivationItems(t *testing.T) {
catalog := buildGiveUpCatalog(t, "[type]\n`[seeking]`\n[int data]\n1001 10\n", "")
record := dnfrepo.QuestRecord{CharacterID: "18", States: map[int64]dnfrepo.QuestState{
3145: {Status: "active"},
}}
plan := PlanGiveUpReclaim(catalog, record, 3145, func(itemID int64) bool { return itemID == 1001 })
if len(plan) != 1 || plan[0].ItemID != 1001 || plan[0].RetainCount != 0 {
t.Fatalf("quest-family reclaim plan = %+v, want item 1001 retain 0", plan)
}
catalogEvent := buildGiveUpCatalog(t, "[type]\n`[clear map]`\n[depend give item]\n2002 1\n", "")
planEvent := PlanGiveUpReclaim(catalogEvent, record, 3145, nil)
if len(planEvent) != 1 || planEvent[0].ItemID != 2002 {
t.Fatalf("activation-item reclaim plan = %+v, want item 2002", planEvent)
}
}
// 其它仍在进行中的任务需要的数量必须留下(参考端的 retainCount)。
func TestPlanGiveUpReclaimRetainsOtherActiveQuestRequirements(t *testing.T) {
catalog := buildGiveUpCatalog(t,
"[type]\n`[seeking]`\n[int data]\n1001 10\n",
"[type]\n`[seeking]`\n[int data]\n1001 3\n")
record := dnfrepo.QuestRecord{
CharacterID: "18",
States: map[int64]dnfrepo.QuestState{
3145: {Status: "active"},
3146: {Status: "active"},
3147: {Status: "completed"}, // 已完成的任务不占额度
},
}
plan := PlanGiveUpReclaim(catalog, record, 3145, func(int64) bool { return true })
if len(plan) != 1 || plan[0].RetainCount != 3 {
t.Fatalf("retain plan = %+v, want item 1001 retain 3", plan)
}
record.States[3146] = dnfrepo.QuestState{Status: "completed"}
plan = PlanGiveUpReclaim(catalog, record, 3145, func(int64) bool { return true })
if len(plan) != 1 || plan[0].RetainCount != 0 {
t.Fatalf("no-active-successor plan = %+v, want retain 0", plan)
}
}
// `[cant giveup]` 依然是唯一的拒绝理由;收集类任务不再被拦(本次修复的核心)。
func TestOwnerApplyGiveUpHonorsPVFBoundaryAndAllowsCollectionQuests(t *testing.T) {
for _, test := range []struct {
name string
extra string
wantErr error
wantRemoved bool
}{
{name: "cant giveup", extra: "[type]\n`[disjoint item]`\n[cant giveup]\n", wantErr: ErrQuestCannotGiveUp},
{name: "seeking", extra: "[type]\n`[seeking]`\n[int data]\n1001 10\n", wantRemoved: true},
{name: "get item", extra: "[type]\n`[get item]`\n[int data]\n1001 10\n", wantRemoved: true},
{name: "seek n meet npc", extra: "[type]\n`[seek n meet npc]`\n[int data]\n1001 10\n", wantRemoved: true},
{name: "depend give item", extra: "[type]\n`[clear map]`\n[depend give item]\n1001 1\n", wantRemoved: true},
} {
t.Run(test.name, func(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{3145: {Status: "active", ProgressValue: 1}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
_, err = owner.ApplyGiveUp(
ctx,
testAcceptCatalog(t, test.extra),
NewQuestIDCommand(alignedRequest("acc-quest", 18), "giveup_quest", QuestIDRequest{QuestID: 3145}),
)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ApplyGiveUp() error = %v, want %v", err, test.wantErr)
}
stored, ok, loadErr := repos.Quest.Load(ctx, "18")
if loadErr != nil || !ok {
t.Fatalf("load quest ok=%t err=%v", ok, loadErr)
}
_, exists := stored.States[3145]
if !test.wantRemoved {
if !exists || stored.States[3145].Status != "active" {
t.Fatalf("rejected give-up mutated quest: %+v", stored.States[3145])
}
return
}
if exists {
t.Fatalf("allowed give-up left quest active: %+v", stored.States[3145])
}
})
}
}
// 收集类任务 + 普通材料:放弃成功,且背包一件都不动(1111/8557 的现场)。
func TestOwnerApplyGiveUpKeepsOrdinaryMaterialsInBag(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{3145: {Status: "active"}},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "18",
Slots: map[string]dnfrepo.ItemStack{"0:121": {ItemID: 10094602, Count: 10}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyGiveUpSettlement(ctx, buildGiveUpCatalog(t, "[type]\n`[seeking]`\n[int data]\n10094602 10\n", ""),
GiveUpInput{
AccountID: "acc-quest", SelectedCharacterID: 18, QuestID: 3145,
// 物品目录判定 10094602 是 `[material]`:不是任务专属 ⇒ 不回收。
IsQuestItem: func(int64) bool { return false },
})
if err != nil {
t.Fatalf("ApplyGiveUpSettlement() error = %v", err)
}
if len(result.Reclaimed) != 0 || result.ReclaimSkipped || result.HasPostCommitInventory {
t.Fatalf("ordinary-material reclaim = %+v, want none", result)
}
stored, found, err := repos.Quest.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load quest found=%t err=%v", found, err)
}
if _, exists := stored.States[3145]; exists {
t.Fatalf("quest 3145 still active: %+v", stored.States[3145])
}
inventory, found, err := repos.Inventory.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if stack := inventory.Slots["0:121"]; stack.ItemID != 10094602 || stack.Count != 10 {
t.Fatalf("ordinary material changed: %+v", stack)
}
}
// 任务专属道具:放弃成功 + 同事务扣减 + 给出提交后快照;其它任务需要的量要留下。
func TestOwnerApplyGiveUpReclaimsQuestItemsInSameSettlement(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{
3145: {Status: "active"},
3146: {Status: "active"}, // 还需要 1001×3
},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "18",
Slots: map[string]dnfrepo.ItemStack{"0:177": {ItemID: 1001, Count: 6}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
catalog := buildGiveUpCatalog(t,
"[type]\n`[seeking]`\n[int data]\n1001 10\n",
"[type]\n`[seeking]`\n[int data]\n1001 3\n")
result, err := owner.ApplyGiveUpSettlement(ctx, catalog, GiveUpInput{
AccountID: "acc-quest", SelectedCharacterID: 18, QuestID: 3145,
IsQuestItem: func(itemID int64) bool { return itemID == 1001 },
})
if err != nil {
t.Fatalf("ApplyGiveUpSettlement() error = %v", err)
}
if len(result.Reclaimed) != 1 {
t.Fatalf("reclaimed = %+v, want one row", result.Reclaimed)
}
receipt := result.Reclaimed[0]
if receipt.SlotKey != "0:177" || receipt.ItemID != 1001 || receipt.Delta != 3 || receipt.PostCount != 3 {
t.Fatalf("reclaim receipt = %+v, want slot 0:177 item 1001 delta 3 post 3", receipt)
}
if !result.HasPostCommitInventory {
t.Fatal("reclaim without post-commit inventory snapshot")
}
if stack := result.PostCommitInventory.Slots["0:177"]; stack.ItemID != 1001 || stack.Count != 3 {
t.Fatalf("post-commit snapshot = %+v, want item 1001 count 3", stack)
}
inventory, found, err := repos.Inventory.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if stack := inventory.Slots["0:177"]; stack.ItemID != 1001 || stack.Count != 3 {
t.Fatalf("persisted inventory = %+v, want item 1001 count 3", stack)
}
stored, found, err := repos.Quest.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load quest found=%t err=%v", found, err)
}
if _, exists := stored.States[3145]; exists {
t.Fatalf("quest 3145 still active: %+v", stored.States[3145])
}
if stored.States[3146].Status != "active" {
t.Fatalf("other active quest changed: %+v", stored.States[3146])
}
}
// 整行扣完时该行必须从背包里消失。
func TestOwnerApplyGiveUpDeletesEmptiedQuestItemRow(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{3145: {Status: "active"}},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "18",
Slots: map[string]dnfrepo.ItemStack{"0:177": {ItemID: 1001, Count: 2}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyGiveUpSettlement(ctx, buildGiveUpCatalog(t, "[type]\n`[seeking]`\n[int data]\n1001 2\n", ""),
GiveUpInput{
AccountID: "acc-quest", SelectedCharacterID: 18, QuestID: 3145,
IsQuestItem: func(int64) bool { return true },
})
if err != nil {
t.Fatalf("ApplyGiveUpSettlement() error = %v", err)
}
if len(result.Reclaimed) != 1 || result.Reclaimed[0].Delta != 2 || result.Reclaimed[0].PostCount != 0 {
t.Fatalf("reclaim receipt = %+v, want delta 2 post 0", result.Reclaimed)
}
inventory, found, err := repos.Inventory.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if _, exists := inventory.Slots["0:177"]; exists {
t.Fatalf("emptied quest-item row survived: %+v", inventory.Slots["0:177"])
}
}
// 没有事务单元时:放弃仍然成功(绝不挡住玩家),只是不回收并如实上报。
func TestOwnerApplyGiveUpWithoutSettlementStillAbandons(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{3145: {Status: "active"}},
}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "18",
Slots: map[string]dnfrepo.ItemStack{"0:177": {ItemID: 1001, Count: 4}},
}); err != nil {
t.Fatal(err)
}
repos.CharacterSettlement = nil
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyGiveUpSettlement(ctx, buildGiveUpCatalog(t, "[type]\n`[seeking]`\n[int data]\n1001 10\n", ""),
GiveUpInput{
AccountID: "acc-quest", SelectedCharacterID: 18, QuestID: 3145,
IsQuestItem: func(int64) bool { return true },
})
if err != nil {
t.Fatalf("ApplyGiveUpSettlement() error = %v", err)
}
if !result.ReclaimSkipped || result.ReclaimSkipReason != "character_settlement_transaction_unavailable" {
t.Fatalf("reclaim skip = %t/%q", result.ReclaimSkipped, result.ReclaimSkipReason)
}
stored, found, err := repos.Quest.Load(ctx, "18")
if err != nil || !found {
t.Fatalf("load quest found=%t err=%v", found, err)
}
if _, exists := stored.States[3145]; exists {
t.Fatalf("degraded give-up left quest active: %+v", stored.States[3145])
}
inventory, _, err := repos.Inventory.Load(ctx, "18")
if err != nil {
t.Fatal(err)
}
if stack := inventory.Slots["0:177"]; stack.Count != 4 {
t.Fatalf("degraded give-up mutated inventory: %+v", stack)
}
}
@@ -0,0 +1,116 @@
package quest
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
type handler struct{}
func NewHandler() alignedcmd.Handler {
return handler{}
}
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainQuest
}
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
switch dnfenum.CmdPacket(req.Opcode) {
case dnfenum.CmdPacketAcceptQuest:
parsed, err := DecodeQuestIDRequest(req.Body)
cmd := NewQuestIDCommand(req, "accept_quest", parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketGiveupQuest:
parsed, err := DecodeQuestIDRequest(req.Body)
cmd := NewQuestIDCommand(req, "giveup_quest", parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketSetQuestTrigger:
parsed, err := DecodeSetTriggerRequest(req.Body)
cmd := NewTriggerCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketFinishQuest:
parsed, err := DecodeFinishQuestRequest(req.Body)
cmd := NewFinishCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("quest module has no opcode %d handler", req.Opcode),
}, nil
}
}
func applyOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
if parseErr != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd, err)
}
var result PlanResult
if cmd.Operation == "set_quest_trigger" {
result, err = owner.ApplySetTrigger(ctx, cmd)
} else {
result, err = owner.Plan(ctx, cmd)
}
if err != nil {
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
}
return ownerPlannedResult(cmd, result)
}
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("%s body parse failed: %v; success ACK is blocked", operation, err),
}
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("parsed %s: %s; quest state, rewards, and NOTI order are not closed, success ACK is blocked", operation, summary),
}
}
func ownerBlockedResult(cmd Command, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf("parsed %s: %s; quest owner preflight failed: %v; success ACK is blocked", cmd.Operation, cmd.String(), err),
}
}
func ownerPlannedResult(cmd Command, result PlanResult) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"parsed %s: %s; quest owner verified account=%s char=%s quest=%d known=%t status=%s triggerType=%d progress=%d rewardSelect=%d hasReward=%t multiplier=%d; reward/USERINFO/NOTI order is not closed, success ACK is blocked",
cmd.Operation,
cmd.String(),
result.AccountID,
result.CharacterID,
result.QuestID,
result.Known,
result.Status,
result.TriggerType,
result.ProgressValue,
result.RewardSelectIndex,
result.HasRewardSelect,
result.Multiplier,
),
}
}
@@ -0,0 +1,202 @@
package quest
import (
"context"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestDecodeQuestIDRequestStripsEcho(t *testing.T) {
got, err := DecodeQuestIDRequest([]byte{0x1F, 0x00, 0x34, 0x12})
if err != nil {
t.Fatalf("DecodeQuestIDRequest error = %v", err)
}
if got.QuestID != 0x1234 {
t.Fatalf("quest = %d", got.QuestID)
}
}
func TestDecodeSetTriggerRequestKeepsLegacySixBytePackedCompatibility(t *testing.T) {
got, err := DecodeSetTriggerRequest([]byte{0x21, 0x00, 0x34, 0x12, 0x10, 0x01})
if err != nil {
t.Fatalf("DecodeSetTriggerRequest error = %v", err)
}
if got.QuestID != 0x1234 || got.TriggerType != 0x10 || !got.IsIncrement {
t.Fatalf("got = %+v", got)
}
}
func TestDecodeSetTriggerRequestAcceptsCurrentMaskedSevenByteForm(t *testing.T) {
got, err := DecodeSetTriggerRequest([]byte{0x21, 0x00, 0x34, 0x12, 0x20, 0x00, 0x00})
if err != nil {
t.Fatalf("DecodeSetTriggerRequest error = %v", err)
}
if got.QuestID != 0x1234 || got.TriggerType != 0x20 || got.IsIncrement || got.TriggerArgument != 0 {
t.Fatalf("got = %+v", got)
}
for _, malformed := range [][]byte{
{0x21, 0x00, 0x34, 0x12, 0x20, 0x00, 0x01},
{0x21, 0x00, 0x34, 0x12, 0x30, 0x00, 0x00},
} {
if _, err := DecodeSetTriggerRequest(malformed); err == nil {
t.Fatalf("DecodeSetTriggerRequest(%x) succeeded, want rejection", malformed)
}
}
}
func TestDecodeFinishQuestRequestMatchesCurrentEXELiveTenByteBody(t *testing.T) {
got, err := DecodeFinishQuestRequest([]byte{0x22, 0x00, 0x49, 0x0C, 0xFF, 0xFF, 0x01, 0x00, 0xFF, 0xFF})
if err != nil {
t.Fatalf("DecodeFinishQuestRequest error = %v", err)
}
if got.QuestID != 3145 || got.RewardSelectIndex != ^uint16(0) || got.HasRewardSelect ||
got.Multiplier != 1 || got.Reserved != CurrentFinishQuestObservedTailMarker {
t.Fatalf("got = %+v", got)
}
}
func TestDecodeFinishQuestRequestRejectsProtectedOrMalformedBodies(t *testing.T) {
for _, body := range [][]byte{
make([]byte, 16),
{0x21, 0x00, 0x49, 0x0C, 0xFF, 0xFF, 0x01, 0x00, 0xFF, 0xFF},
} {
if _, err := DecodeFinishQuestRequest(body); err == nil {
t.Fatalf("DecodeFinishQuestRequest(%x) succeeded, want strict rejection", body)
}
}
}
func TestHandlerBlocksQuestAck(t *testing.T) {
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketFinishQuest),
Body: []byte{0x22, 0x00, 0x34, 0x12, 0xFF, 0xFF, 0x01, 0x00, 0xFF, 0xFF},
AccountID: " acc-quest ",
SelectedCharacterID: 46,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
if got.Operation != "finish_quest" {
t.Fatalf("operation = %q", got.Operation)
}
if !strings.Contains(got.Reason, `account="acc-quest"`) || !strings.Contains(got.Reason, "char=46") || !strings.Contains(got.Reason, "quest=4660") || !strings.Contains(got.Reason, "mutation id") {
t.Fatalf("reason should include command plan, got %q", got.Reason)
}
}
func TestHandlerFinishQuestUsesOwnerButStillBlocksAck(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: 9},
},
}); err != nil {
t.Fatalf("save quest: %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketFinishQuest),
Body: []byte{0x22, 0x00, 0x34, 0x12, 0x02, 0x00, 0x01, 0x00, 0xFF, 0xFF},
AccountID: "acc-quest",
SelectedCharacterID: 46,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
if !strings.Contains(got.Reason, "quest owner verified") || !strings.Contains(got.Reason, "known=true") || !strings.Contains(got.Reason, "status=active") || !strings.Contains(got.Reason, "progress=9") {
t.Fatalf("reason should include owner preflight, got %q", got.Reason)
}
}
func TestHandlerSetTriggerType1WritesRecomputedProgressButStillBlocksAck(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.Fatalf("save character: %v", err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "49",
States: map[int64]dnfrepo.QuestState{
0x2233: {
Status: "active",
ProgressValue: 1,
Extra: map[string]string{"seeking_item_ids": "9010"},
},
},
}); err != nil {
t.Fatalf("save quest: %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "49",
Slots: map[string]dnfrepo.ItemStack{
"0:12": {ItemID: 9010, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketSetQuestTrigger),
Body: []byte{0x21, 0x00, 0x33, 0x22, 0x01, 0x01},
AccountID: "acc-quest",
SelectedCharacterID: 49,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
if !strings.Contains(got.Reason, "progress=0") || !strings.Contains(got.Reason, "success ACK is blocked") {
t.Fatalf("reason should include recomputed progress and ACK block, got %q", got.Reason)
}
loaded, ok, err := repos.Quest.Load(ctx, "49")
if err != nil || !ok {
t.Fatalf("load quest ok=%v err=%v", ok, err)
}
if got := loaded.States[0x2233].ProgressValue; got != 0 {
t.Fatalf("stored progress = %d, want 0", got)
}
}
func TestFinishCommandRecordsRewardOwnerGap(t *testing.T) {
cmd := NewFinishCommand(alignedcmd.Request{
AccountID: " acc-quest-2 ",
SelectedCharacterID: 77,
}, FinishQuestRequest{
QuestID: 0x1234,
RewardSelectIndex: 2,
HasRewardSelect: true,
Multiplier: 3,
})
if cmd.AccountID != "acc-quest-2" || cmd.SelectedCharacterID != 77 || cmd.QuestID != 0x1234 || cmd.RewardSelectIndex != 2 || !cmd.HasRewardSelect || cmd.Multiplier != 3 {
t.Fatalf("cmd = %+v", cmd)
}
if !strings.Contains(cmd.String(), "USERINFO/NOTI order") {
t.Fatalf("command plan must name response/order gap: %s", cmd.String())
}
}
@@ -0,0 +1,314 @@
package quest
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrHuntEnemyTargetRequired = errors.New("dnf hunt-enemy quest target is required")
ErrHuntEnemyCompletionKeyRequired = errors.New("dnf hunt-enemy completion key is required")
ErrHuntEnemyCompletionTimeRequired = errors.New("dnf hunt-enemy completion time is required")
)
const (
huntEnemyCompletionKind = "hunt_enemy"
defaultHuntEnemyType = int64(1)
huntEnemyStride = 5
)
// HuntEnemyKillInput contains only authoritative dungeon-combat facts. It ports
// the 86JP/Quest::CheckKillMonster domain match for PVF [hunt enemy] int-data:
//
// dungeonId, difficulty, enemyCode, enemyType, count.
//
// dungeonId/difficulty allow -1 wildcards, enemyCode is exact, and missing or
// non-positive enemyType defaults to 1.
//
// Packet shape remains owned by the bridge; this is only PVF/DB state.
type HuntEnemyKillInput struct {
DungeonID int64
Difficulty int64
EnemyCode int64
EnemyType int64
CompletionKey string
CompletedAt time.Time
}
// ActiveHuntEnemyTarget is one still-pending PVF hunt-enemy channel. EnemyType
// is the quest-domain type, not the dungeon actor packet type. In 86JP's
// domain table type 3 is a passive object; it must never be inferred from a
// boss actor's wire type.
type ActiveHuntEnemyTarget struct {
QuestID int64
PVFPath string
DungeonID int64
Difficulty int64
EnemyCode int64
EnemyType int64
}
// ActiveHuntEnemyTargets returns the exact active PVF targets matching the
// current dungeon scope and quest-domain enemy type. It is intentionally
// read-only and does not fabricate a missing quest row.
func (c *Catalog) ActiveHuntEnemyTargets(
record dnfrepo.QuestRecord,
dungeonID int64,
difficulty int64,
enemyType int64,
) []ActiveHuntEnemyTarget {
if c == nil || enemyType <= 0 {
return nil
}
seenQuest := make(map[int64]struct{})
result := make([]ActiveHuntEnemyTarget, 0, 1)
visit := func(states map[int64]dnfrepo.QuestState) {
ids := make([]int64, 0, len(states))
for questID := range states {
ids = append(ids, questID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, questID := range ids {
if _, duplicate := seenQuest[questID]; duplicate {
continue
}
state := states[questID]
if !isActiveQuestStatus(state.Status) || state.ProgressValue <= 0 {
continue
}
definition, ok := c.byID[questID]
if !ok || normalizeQuestTag(definition.Type) != "hunt enemy" {
continue
}
for offset := 0; offset+huntEnemyStride <= len(definition.IntData); offset += huntEnemyStride {
targetDungeonID := definition.IntData[offset]
targetDifficulty := definition.IntData[offset+1]
targetEnemyCode := definition.IntData[offset+2]
targetEnemyType := definition.IntData[offset+3]
if targetEnemyType <= 0 {
targetEnemyType = defaultHuntEnemyType
}
if targetEnemyType != enemyType || targetEnemyCode <= 0 ||
(targetDungeonID != -1 && targetDungeonID != dungeonID) ||
(targetDifficulty >= 0 && targetDifficulty != difficulty) {
continue
}
result = append(result, ActiveHuntEnemyTarget{
QuestID: questID, PVFPath: definition.Path,
DungeonID: targetDungeonID, Difficulty: targetDifficulty,
EnemyCode: targetEnemyCode, EnemyType: targetEnemyType,
})
seenQuest[questID] = struct{}{}
break
}
}
}
visit(record.States)
visit(record.Progress)
return result
}
type HuntEnemyCompletion struct {
QuestID int64
Field dnfrepo.QuestField
PreviousTrigger int64
CurrentTrigger int64
PVFPath string
Completed bool
Idempotent bool
}
type HuntEnemyKillPlan struct {
Record dnfrepo.QuestRecord
Completions []HuntEnemyCompletion
ChangedFields []dnfrepo.QuestField
}
func (c *Catalog) PlanHuntEnemyKill(record dnfrepo.QuestRecord, input HuntEnemyKillInput) (HuntEnemyKillPlan, error) {
if c == nil {
return HuntEnemyKillPlan{}, ErrCatalogEmpty
}
if input.EnemyCode <= 0 {
return HuntEnemyKillPlan{}, ErrHuntEnemyTargetRequired
}
input.CompletionKey = strings.TrimSpace(input.CompletionKey)
if input.CompletionKey == "" {
return HuntEnemyKillPlan{}, ErrHuntEnemyCompletionKeyRequired
}
if input.CompletedAt.IsZero() {
return HuntEnemyKillPlan{}, ErrHuntEnemyCompletionTimeRequired
}
if input.EnemyType <= 0 {
input.EnemyType = defaultHuntEnemyType
}
plan := HuntEnemyKillPlan{Record: dnfrepo.CloneQuest(record)}
statesChanged := false
progressChanged := false
_, activationFields := c.activateMissingLinkedNoRewardSubQuests(&plan.Record, input.CompletedAt, "")
for _, field := range activationFields {
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
seen := make(map[int64]struct{})
visit := func(states map[int64]dnfrepo.QuestState, field dnfrepo.QuestField) {
ids := make([]int64, 0, len(states))
for questID := range states {
ids = append(ids, questID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, questID := range ids {
if _, duplicate := seen[questID]; duplicate {
continue
}
seen[questID] = struct{}{}
state := states[questID]
if !isActiveQuestStatus(state.Status) || state.ProgressValue < 0 {
continue
}
definition, ok := c.byID[questID]
if !ok || normalizeQuestTag(definition.Type) != "hunt enemy" {
continue
}
channel, ok := huntEnemyMatchedChannel(definition.IntData, input)
if !ok {
continue
}
completion := HuntEnemyCompletion{
QuestID: questID,
Field: field,
PreviousTrigger: state.ProgressValue,
PVFPath: definition.Path,
}
if state.ProgressValue == 0 {
completion.CurrentTrigger = 0
completion.Completed = true
completion.Idempotent = true
if autoCompleteNoRewardSubQuestState(&state, definition, input.CompletedAt) {
states[questID] = state
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
plan.Completions = append(plan.Completions, completion)
continue
}
nextTrigger := decrementPackedTriggerChannel(uint32(state.ProgressValue), channel)
if int64(nextTrigger) == state.ProgressValue {
continue
}
state.ProgressValue = int64(nextTrigger)
state.UpdatedAt = input.CompletedAt
completion.CurrentTrigger = int64(nextTrigger)
completion.Completed = nextTrigger == 0
if state.Extra == nil {
state.Extra = make(map[string]string, 8)
}
state.Extra["pvf_path"] = definition.Path
state.Extra["quest_type"] = definition.Type
state.Extra["last_hunt_enemy_code"] = strconv.FormatInt(input.EnemyCode, 10)
state.Extra["last_hunt_enemy_type"] = strconv.FormatInt(input.EnemyType, 10)
state.Extra["last_hunt_enemy_dungeon_id"] = strconv.FormatInt(input.DungeonID, 10)
state.Extra["last_hunt_enemy_difficulty"] = strconv.FormatInt(input.Difficulty, 10)
if nextTrigger == 0 {
state.Extra["completion_kind"] = huntEnemyCompletionKind
state.Extra["completion_key"] = input.CompletionKey
state.Extra["completion_enemy_code"] = strconv.FormatInt(input.EnemyCode, 10)
state.Extra["completion_enemy_type"] = strconv.FormatInt(input.EnemyType, 10)
state.Extra["completion_dungeon_id"] = strconv.FormatInt(input.DungeonID, 10)
state.Extra["completion_difficulty"] = strconv.FormatInt(input.Difficulty, 10)
state.Extra["reward_state"] = clearMapRewardPending
autoCompleteNoRewardSubQuestState(&state, definition, input.CompletedAt)
}
plan.Completions = append(plan.Completions, completion)
states[questID] = state
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
}
visit(plan.Record.States, dnfrepo.QuestFieldStates)
visit(plan.Record.Progress, dnfrepo.QuestFieldProgress)
for _, field := range syncActiveQuestClearParentProgress(c, &plan.Record, input.CompletedAt) {
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
if statesChanged || progressChanged {
plan.Record.UpdatedAt = input.CompletedAt
}
if statesChanged {
plan.ChangedFields = append(plan.ChangedFields, dnfrepo.QuestFieldStates)
}
if progressChanged {
plan.ChangedFields = append(plan.ChangedFields, dnfrepo.QuestFieldProgress)
}
return plan, nil
}
func huntEnemyMatchedChannel(values []int64, input HuntEnemyKillInput) (int, bool) {
for index, offset := 0, 0; offset+huntEnemyStride <= len(values) && index < 3; index, offset = index+1, offset+huntEnemyStride {
dungeonID := values[offset]
difficulty := values[offset+1]
enemyCode := values[offset+2]
enemyType := values[offset+3]
if enemyType <= 0 {
enemyType = defaultHuntEnemyType
}
if dungeonID != -1 && dungeonID != input.DungeonID {
continue
}
if difficulty >= 0 && difficulty != input.Difficulty {
continue
}
if enemyCode != input.EnemyCode {
continue
}
if enemyType != input.EnemyType {
continue
}
return index, true
}
return 0, false
}
func decrementPackedTriggerChannel(trigger uint32, channel int) uint32 {
if channel < 0 || channel > 2 {
return trigger
}
shift := channel * 9
current := (trigger >> shift) & 0x1ff
if current == 0 {
return trigger
}
next := current - 1
trigger &^= 0x1ff << shift
trigger |= next << shift
return trigger
}
func (p HuntEnemyKillPlan) ValidateCharacter(characterID string) error {
if strings.TrimSpace(p.Record.CharacterID) != strings.TrimSpace(characterID) {
return fmt.Errorf("dnf hunt-enemy quest owner mismatch: record=%q selected=%q", p.Record.CharacterID, characterID)
}
return nil
}
@@ -0,0 +1,90 @@
package quest
import (
"context"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type HuntEnemyPersistResult struct {
CharacterID string
Completions []HuntEnemyCompletion
ChangedFields []dnfrepo.QuestField
Idempotent bool
}
// ApplyHuntEnemyKill persists only the active quest trigger update for a real
// dungeon kill. Rewards stay behind the normal FinishQuest settlement owner.
func (o *Owner) ApplyHuntEnemyKill(
ctx context.Context,
catalog *Catalog,
characterID string,
input HuntEnemyKillInput,
) (HuntEnemyPersistResult, error) {
if o == nil || o.characters == nil || o.quests == nil {
return HuntEnemyPersistResult{}, ErrOwnerUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return HuntEnemyPersistResult{}, ErrCharacterRequired
}
if _, ok, err := o.characters.Load(ctx, characterID); err != nil {
return HuntEnemyPersistResult{}, err
} else if !ok {
return HuntEnemyPersistResult{}, ErrCharacterNotFound
}
record, ok, err := o.quests.Load(ctx, characterID)
if err != nil {
return HuntEnemyPersistResult{}, err
}
if !ok {
record = dnfrepo.QuestRecord{CharacterID: characterID}
} else {
record = dnfrepo.CloneQuest(record)
}
if strings.TrimSpace(record.CharacterID) == "" {
record.CharacterID = characterID
}
plan, err := catalog.PlanHuntEnemyKill(record, input)
if err != nil {
return HuntEnemyPersistResult{}, err
}
if err := plan.ValidateCharacter(characterID); err != nil {
return HuntEnemyPersistResult{}, err
}
result := HuntEnemyPersistResult{
CharacterID: characterID,
Completions: append([]HuntEnemyCompletion(nil), plan.Completions...),
ChangedFields: append([]dnfrepo.QuestField(nil), plan.ChangedFields...),
Idempotent: len(plan.Completions) > 0 && len(plan.ChangedFields) == 0,
}
if len(plan.ChangedFields) == 0 {
return result, nil
}
if err := dnfrepo.SaveQuestFields(ctx, o.quests, plan.Record, plan.ChangedFields...); err != nil {
return HuntEnemyPersistResult{}, err
}
persisted, ok, err := o.quests.Load(ctx, characterID)
if err != nil {
return HuntEnemyPersistResult{}, err
}
if !ok {
return HuntEnemyPersistResult{}, ErrQuestPersistVerify
}
for _, completion := range result.Completions {
state, known := questStateFor(persisted, completion.QuestID)
if !known || (!isActiveQuestStatus(state.Status) && !isCompletedQuestStatus(state.Status)) ||
state.ProgressValue != completion.CurrentTrigger {
return HuntEnemyPersistResult{}, ErrQuestPersistVerify
}
if completion.Completed && !completion.Idempotent &&
(state.Extra["completion_key"] != input.CompletionKey || state.Extra["reward_state"] != clearMapRewardPending) {
if !isCompletedQuestStatus(state.Status) || state.Extra["reward_state"] != finishRewardGranted ||
state.Extra["completion_key"] != input.CompletionKey {
return HuntEnemyPersistResult{}, ErrQuestPersistVerify
}
}
}
return result, nil
}
@@ -0,0 +1,283 @@
package quest
import (
"context"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestActiveHuntEnemyTargetsSeparatesPassiveObjectTypeFromMonsterType(t *testing.T) {
catalog := &Catalog{byID: map[int64]Definition{
3157: {ID: 3157, Path: "n_quest/elvengard_epic_13.qst", Grade: "[sub]", Type: "[hunt enemy]", MainQuestID: 3146, IntData: []int64{3, -1, 13099, 3, 1}},
4000: {ID: 4000, Path: "n_quest/monster.qst", Type: "[hunt enemy]", IntData: []int64{3, -1, 107000908, 1, 1}},
}}
record := dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
3157: {Status: "active", ProgressValue: 1},
4000: {Status: "active", ProgressValue: 1},
}}
passive := catalog.ActiveHuntEnemyTargets(record, 3, 0, 3)
if len(passive) != 1 || passive[0].QuestID != 3157 || passive[0].EnemyCode != 13099 || passive[0].EnemyType != 3 {
t.Fatalf("passive targets=%+v", passive)
}
monsters := catalog.ActiveHuntEnemyTargets(record, 3, 0, 1)
if len(monsters) != 1 || monsters[0].QuestID != 4000 || monsters[0].EnemyCode != 107000908 || monsters[0].EnemyType != 1 {
t.Fatalf("monster targets=%+v", monsters)
}
record.States[3157] = dnfrepo.QuestState{Status: "completed", ProgressValue: 0}
if completed := catalog.ActiveHuntEnemyTargets(record, 3, 0, 3); len(completed) != 0 {
t.Fatalf("completed passive target remained active=%+v", completed)
}
}
func TestPlanHuntEnemyKillUsesPVFStrideFiveAndMarksPendingReward(t *testing.T) {
completedAt := time.Date(2026, 7, 16, 22, 10, 0, 0, time.UTC)
catalog := &Catalog{byID: map[int64]Definition{
3157: {ID: 3157, Path: "n_quest/hunt.qst", Type: "[hunt enemy]", IntData: []int64{
-1, -1, 77110, 1, 1,
}},
}}
record := dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3157: {Status: "active", ProgressValue: 1, Extra: map[string]string{"kept": "yes"}},
},
}
plan, err := catalog.PlanHuntEnemyKill(record, HuntEnemyKillInput{
DungeonID: 3,
Difficulty: 0,
EnemyCode: 77110,
EnemyType: 1,
CompletionKey: "run-19/op39/77110",
CompletedAt: completedAt,
})
if err != nil {
t.Fatal(err)
}
if len(plan.ChangedFields) != 1 || plan.ChangedFields[0] != dnfrepo.QuestFieldStates {
t.Fatalf("changed fields=%v", plan.ChangedFields)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 3157 || plan.Completions[0].PreviousTrigger != 1 {
t.Fatalf("completions=%+v", plan.Completions)
}
state := plan.Record.States[3157]
if state.ProgressValue != 0 || state.Extra["reward_state"] != "pending" ||
state.Extra["completion_kind"] != "hunt_enemy" ||
state.Extra["completion_key"] != "run-19/op39/77110" ||
state.Extra["kept"] != "yes" {
t.Fatalf("state=%+v", state)
}
if original := record.States[3157]; original.ProgressValue != 1 || original.Extra["reward_state"] != "" {
t.Fatalf("input record mutated=%+v", original)
}
}
func TestPlanHuntEnemyKillIgnoresScopeMismatch(t *testing.T) {
catalog := &Catalog{byID: map[int64]Definition{
3157: {ID: 3157, Type: "[hunt enemy]", IntData: []int64{3, 1, 77110, 1, 1}},
}}
record := dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{3157: {Status: "active", ProgressValue: 1}},
}
plan, err := catalog.PlanHuntEnemyKill(record, HuntEnemyKillInput{
DungeonID: 4,
Difficulty: 1,
EnemyCode: 77110,
EnemyType: 1,
CompletionKey: "run",
CompletedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if len(plan.ChangedFields) != 0 || plan.Record.States[3157].ProgressValue != 1 {
t.Fatalf("unexpected change fields=%v state=%+v", plan.ChangedFields, plan.Record.States[3157])
}
}
func TestPlanHuntEnemyKillAutoCompletesNoRewardSubAndSyncsQuestClearParent(t *testing.T) {
completedAt := time.Date(2026, 7, 17, 12, 35, 0, 0, time.UTC)
catalog := &Catalog{byID: map[int64]Definition{
3157: {ID: 3157, Path: "n_quest/elvengard_epic_13.qst", Grade: "[sub]", Type: "[hunt enemy]", IntData: []int64{3, -1, 13099, 3, 1}},
3146: {ID: 3146, Path: "n_quest/elvengard_epic_02.qst", Grade: "[epic]", Type: "[quest clear]", IntData: []int64{3157, 3054}},
}}
record := dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3157: {Status: "active", ProgressValue: 1},
3054: {Status: "completed", ProgressValue: 0},
3146: {Status: "active", ProgressValue: 1},
},
}
plan, err := catalog.PlanHuntEnemyKill(record, HuntEnemyKillInput{
DungeonID: 3,
Difficulty: 0,
EnemyCode: 13099,
EnemyType: 3,
CompletionKey: "run-3146/3157",
CompletedAt: completedAt,
})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 3157 || !plan.Completions[0].Completed {
t.Fatalf("completions=%+v", plan.Completions)
}
child := plan.Record.States[3157]
if child.Status != "completed" || child.ProgressValue != 0 ||
child.Extra["reward_state"] != "granted" ||
child.Extra["completion_key"] != "run-3146/3157" ||
child.Extra["auto_completed"] != "true" {
t.Fatalf("auto-completed child=%+v", child)
}
parent := plan.Record.States[3146]
if parent.Status != "active" || parent.ProgressValue != 0 {
t.Fatalf("quest-clear parent=%+v", parent)
}
if len(plan.ChangedFields) != 1 || plan.ChangedFields[0] != dnfrepo.QuestFieldStates {
t.Fatalf("changed fields=%v", plan.ChangedFields)
}
}
func TestPlanHuntEnemyKillDerivesMissingNoRewardSubFromActiveQuestClearParent(t *testing.T) {
completedAt := time.Date(2026, 7, 17, 13, 25, 0, 0, time.UTC)
catalog := &Catalog{byID: map[int64]Definition{
3157: {ID: 3157, Path: "n_quest/elvengard_epic_13.qst", Grade: "[sub]", Type: "[hunt enemy]", MainQuestID: 3146, IntData: []int64{3, -1, 13099, 3, 1}},
3146: {ID: 3146, Path: "n_quest/elvengard_epic_02.qst", Grade: "[epic]", Type: "[quest clear]", IntData: []int64{3157, 3054}},
}}
record := dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3054: {Status: "completed", ProgressValue: 0},
3146: {Status: "active", ProgressValue: 2},
},
}
plan, err := catalog.PlanHuntEnemyKill(record, HuntEnemyKillInput{
DungeonID: 3,
Difficulty: 0,
EnemyCode: 13099,
EnemyType: 3,
CompletionKey: "run-3146/stale-parent-only/3157",
CompletedAt: completedAt,
})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 3157 ||
plan.Completions[0].PreviousTrigger != 1 || !plan.Completions[0].Completed {
t.Fatalf("completions=%+v", plan.Completions)
}
child := plan.Record.States[3157]
if child.Status != "completed" || child.ProgressValue != 0 ||
child.Extra["reward_state"] != "granted" ||
child.Extra["main_quest_id"] != "3146" ||
child.Extra["auto_activated_by_main_quest"] != "true" ||
child.Extra["completion_key"] != "run-3146/stale-parent-only/3157" ||
child.Extra["auto_completed"] != "true" {
t.Fatalf("derived child=%+v", child)
}
if parent := plan.Record.States[3146]; parent.Status != "active" || parent.ProgressValue != 0 {
t.Fatalf("quest-clear parent=%+v", parent)
}
if len(plan.ChangedFields) != 1 || plan.ChangedFields[0] != dnfrepo.QuestFieldStates {
t.Fatalf("changed fields=%v", plan.ChangedFields)
}
}
func TestPlanHuntEnemyKillReconcilesAllMissingNoRewardSubTasksBeforeCompletion(t *testing.T) {
completedAt := time.Date(2026, 7, 17, 14, 10, 0, 0, time.UTC)
catalog := &Catalog{byID: map[int64]Definition{
3157: {ID: 3157, Path: "n_quest/elvengard_epic_13.qst", Grade: "[sub]", Type: "[hunt enemy]", MainQuestID: 3146, IntData: []int64{3, -1, 13099, 3, 1}},
3054: {ID: 3054, Path: "n_quest/elvengard_epic_13_1.qst", Grade: "[sub]", Type: "[clear map]", MainQuestID: 3146, IntData: []int64{76136}},
3146: {ID: 3146, Path: "n_quest/elvengard_epic_02.qst", Grade: "[epic]", Type: "[quest clear]", IntData: []int64{3157, 3054}},
}}
record := dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
3146: {Status: "active", ProgressValue: 2},
},
}
plan, err := catalog.PlanHuntEnemyKill(record, HuntEnemyKillInput{
DungeonID: 3,
Difficulty: 0,
EnemyCode: 13099,
EnemyType: 3,
CompletionKey: "run-3146/only-parent/3157",
CompletedAt: completedAt,
})
if err != nil {
t.Fatal(err)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 3157 || !plan.Completions[0].Completed {
t.Fatalf("completions=%+v", plan.Completions)
}
huntChild := plan.Record.States[3157]
if huntChild.Status != "completed" || huntChild.ProgressValue != 0 ||
huntChild.Extra["auto_activated_by_main_quest"] != "true" ||
huntChild.Extra["completion_key"] != "run-3146/only-parent/3157" {
t.Fatalf("hunt child=%+v", huntChild)
}
clearChild := plan.Record.States[3054]
if clearChild.Status != "active" || clearChild.ProgressValue != 1 ||
clearChild.Extra["auto_activated_by_main_quest"] != "true" ||
clearChild.Extra["auto_activation_reason"] != "active_quest_clear_parent_reconcile" {
t.Fatalf("clear child=%+v", clearChild)
}
if parent := plan.Record.States[3146]; parent.Status != "active" || parent.ProgressValue != 1 {
t.Fatalf("quest-clear parent=%+v", parent)
}
if len(plan.ChangedFields) != 1 || plan.ChangedFields[0] != dnfrepo.QuestFieldStates {
t.Fatalf("changed fields=%v", plan.ChangedFields)
}
}
func TestOwnerApplyHuntEnemyKillPersistsAndVerifiesPendingReward(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "acc", Job: "2", Level: 1}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{3157: {Status: "active", ProgressValue: 1}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
catalog := &Catalog{byID: map[int64]Definition{
3157: {ID: 3157, Path: "n_quest/hunt.qst", Type: "[hunt enemy]", IntData: []int64{-1, -1, 77110, 1, 1}},
}}
result, err := owner.ApplyHuntEnemyKill(ctx, catalog, "19", HuntEnemyKillInput{
DungeonID: 3,
Difficulty: 0,
EnemyCode: 77110,
EnemyType: 1,
CompletionKey: "run-19/op39/77110",
CompletedAt: time.Date(2026, 7, 16, 22, 10, 0, 0, time.UTC),
})
if err != nil {
t.Fatal(err)
}
if len(result.Completions) != 1 || result.Completions[0].QuestID != 3157 {
t.Fatalf("result=%+v", result)
}
persisted, ok, err := repos.Quest.Load(ctx, "19")
if err != nil || !ok {
t.Fatalf("load persisted ok=%t err=%v", ok, err)
}
state := persisted.States[3157]
if state.ProgressValue != 0 || state.Extra["reward_state"] != "pending" || state.Extra["completion_key"] != "run-19/op39/77110" {
t.Fatalf("persisted state=%+v", state)
}
}
@@ -0,0 +1,210 @@
package quest
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrHuntMonsterTargetRequired = errors.New("dnf hunt-monster quest target is required")
ErrHuntMonsterCompletionKeyRequired = errors.New("dnf hunt-monster completion key is required")
ErrHuntMonsterCompletionTimeRequired = errors.New("dnf hunt-monster completion time is required")
)
const (
huntMonsterCompletionKind = "hunt_monster"
huntMonsterStride = 4
)
// HuntMonsterKillInput contains the real dungeon-combat facts required by PVF
// [hunt monster] quests. The PVF int-data shape is:
//
// dungeonId, difficulty, monsterCode, count.
//
// dungeonId/difficulty allow the same wildcards as the 86JP domain logic:
// dungeonId -1 means any dungeon, difficulty < 0 means any difficulty.
type HuntMonsterKillInput struct {
DungeonID int64
Difficulty int64
MonsterCode int64
CompletionKey string
CompletedAt time.Time
}
type HuntMonsterCompletion struct {
QuestID int64
Field dnfrepo.QuestField
PreviousTrigger int64
CurrentTrigger int64
PVFPath string
Completed bool
Idempotent bool
}
type HuntMonsterKillPlan struct {
Record dnfrepo.QuestRecord
Completions []HuntMonsterCompletion
ChangedFields []dnfrepo.QuestField
}
func (c *Catalog) PlanHuntMonsterKill(record dnfrepo.QuestRecord, input HuntMonsterKillInput) (HuntMonsterKillPlan, error) {
if c == nil {
return HuntMonsterKillPlan{}, ErrCatalogEmpty
}
if input.MonsterCode <= 0 {
return HuntMonsterKillPlan{}, ErrHuntMonsterTargetRequired
}
input.CompletionKey = strings.TrimSpace(input.CompletionKey)
if input.CompletionKey == "" {
return HuntMonsterKillPlan{}, ErrHuntMonsterCompletionKeyRequired
}
if input.CompletedAt.IsZero() {
return HuntMonsterKillPlan{}, ErrHuntMonsterCompletionTimeRequired
}
plan := HuntMonsterKillPlan{Record: dnfrepo.CloneQuest(record)}
statesChanged := false
progressChanged := false
_, activationFields := c.activateMissingLinkedNoRewardSubQuests(&plan.Record, input.CompletedAt, "")
for _, field := range activationFields {
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
seen := make(map[int64]struct{})
visit := func(states map[int64]dnfrepo.QuestState, field dnfrepo.QuestField) {
ids := make([]int64, 0, len(states))
for questID := range states {
ids = append(ids, questID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, questID := range ids {
if _, duplicate := seen[questID]; duplicate {
continue
}
seen[questID] = struct{}{}
state := states[questID]
if !isActiveQuestStatus(state.Status) || state.ProgressValue < 0 {
continue
}
definition, ok := c.byID[questID]
if !ok || normalizeQuestTag(definition.Type) != "hunt monster" {
continue
}
channel, ok := huntMonsterMatchedChannel(definition.IntData, input)
if !ok {
continue
}
completion := HuntMonsterCompletion{
QuestID: questID,
Field: field,
PreviousTrigger: state.ProgressValue,
PVFPath: definition.Path,
}
if state.ProgressValue == 0 {
completion.CurrentTrigger = 0
completion.Completed = true
completion.Idempotent = true
if autoCompleteNoRewardSubQuestState(&state, definition, input.CompletedAt) {
states[questID] = state
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
plan.Completions = append(plan.Completions, completion)
continue
}
nextTrigger := decrementPackedTriggerChannel(uint32(state.ProgressValue), channel)
if int64(nextTrigger) == state.ProgressValue {
continue
}
state.ProgressValue = int64(nextTrigger)
state.UpdatedAt = input.CompletedAt
completion.CurrentTrigger = int64(nextTrigger)
completion.Completed = nextTrigger == 0
if state.Extra == nil {
state.Extra = make(map[string]string, 8)
}
state.Extra["pvf_path"] = definition.Path
state.Extra["quest_type"] = definition.Type
state.Extra["last_hunt_monster_code"] = strconv.FormatInt(input.MonsterCode, 10)
state.Extra["last_hunt_monster_dungeon_id"] = strconv.FormatInt(input.DungeonID, 10)
state.Extra["last_hunt_monster_difficulty"] = strconv.FormatInt(input.Difficulty, 10)
if nextTrigger == 0 {
state.Extra["completion_kind"] = huntMonsterCompletionKind
state.Extra["completion_key"] = input.CompletionKey
state.Extra["completion_monster_code"] = strconv.FormatInt(input.MonsterCode, 10)
state.Extra["completion_dungeon_id"] = strconv.FormatInt(input.DungeonID, 10)
state.Extra["completion_difficulty"] = strconv.FormatInt(input.Difficulty, 10)
state.Extra["reward_state"] = clearMapRewardPending
autoCompleteNoRewardSubQuestState(&state, definition, input.CompletedAt)
}
plan.Completions = append(plan.Completions, completion)
states[questID] = state
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
}
visit(plan.Record.States, dnfrepo.QuestFieldStates)
visit(plan.Record.Progress, dnfrepo.QuestFieldProgress)
for _, field := range syncActiveQuestClearParentProgress(c, &plan.Record, input.CompletedAt) {
switch field {
case dnfrepo.QuestFieldStates:
statesChanged = true
case dnfrepo.QuestFieldProgress:
progressChanged = true
}
}
if statesChanged || progressChanged {
plan.Record.UpdatedAt = input.CompletedAt
}
if statesChanged {
plan.ChangedFields = append(plan.ChangedFields, dnfrepo.QuestFieldStates)
}
if progressChanged {
plan.ChangedFields = append(plan.ChangedFields, dnfrepo.QuestFieldProgress)
}
return plan, nil
}
func huntMonsterMatchedChannel(values []int64, input HuntMonsterKillInput) (int, bool) {
for index, offset := 0, 0; offset+huntMonsterStride <= len(values) && index < 3; index, offset = index+1, offset+huntMonsterStride {
dungeonID := values[offset]
difficulty := values[offset+1]
monsterCode := values[offset+2]
if dungeonID != -1 && dungeonID != input.DungeonID {
continue
}
if difficulty >= 0 && difficulty != input.Difficulty {
continue
}
if monsterCode != input.MonsterCode {
continue
}
return index, true
}
return 0, false
}
func (p HuntMonsterKillPlan) ValidateCharacter(characterID string) error {
if strings.TrimSpace(p.Record.CharacterID) != strings.TrimSpace(characterID) {
return fmt.Errorf("dnf hunt-monster quest owner mismatch: record=%q selected=%q", p.Record.CharacterID, characterID)
}
return nil
}
@@ -0,0 +1,91 @@
package quest
import (
"context"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type HuntMonsterPersistResult struct {
CharacterID string
Completions []HuntMonsterCompletion
ChangedFields []dnfrepo.QuestField
Idempotent bool
}
// ApplyHuntMonsterKill persists only the active trigger change caused by a
// real dungeon monster death. Reward settlement remains behind FinishQuest,
// exactly like the existing hunt-enemy path.
func (o *Owner) ApplyHuntMonsterKill(
ctx context.Context,
catalog *Catalog,
characterID string,
input HuntMonsterKillInput,
) (HuntMonsterPersistResult, error) {
if o == nil || o.characters == nil || o.quests == nil {
return HuntMonsterPersistResult{}, ErrOwnerUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return HuntMonsterPersistResult{}, ErrCharacterRequired
}
if _, ok, err := o.characters.Load(ctx, characterID); err != nil {
return HuntMonsterPersistResult{}, err
} else if !ok {
return HuntMonsterPersistResult{}, ErrCharacterNotFound
}
record, ok, err := o.quests.Load(ctx, characterID)
if err != nil {
return HuntMonsterPersistResult{}, err
}
if !ok {
record = dnfrepo.QuestRecord{CharacterID: characterID}
} else {
record = dnfrepo.CloneQuest(record)
}
if strings.TrimSpace(record.CharacterID) == "" {
record.CharacterID = characterID
}
plan, err := catalog.PlanHuntMonsterKill(record, input)
if err != nil {
return HuntMonsterPersistResult{}, err
}
if err := plan.ValidateCharacter(characterID); err != nil {
return HuntMonsterPersistResult{}, err
}
result := HuntMonsterPersistResult{
CharacterID: characterID,
Completions: append([]HuntMonsterCompletion(nil), plan.Completions...),
ChangedFields: append([]dnfrepo.QuestField(nil), plan.ChangedFields...),
Idempotent: len(plan.Completions) > 0 && len(plan.ChangedFields) == 0,
}
if len(plan.ChangedFields) == 0 {
return result, nil
}
if err := dnfrepo.SaveQuestFields(ctx, o.quests, plan.Record, plan.ChangedFields...); err != nil {
return HuntMonsterPersistResult{}, err
}
persisted, ok, err := o.quests.Load(ctx, characterID)
if err != nil {
return HuntMonsterPersistResult{}, err
}
if !ok {
return HuntMonsterPersistResult{}, ErrQuestPersistVerify
}
for _, completion := range result.Completions {
state, known := questStateFor(persisted, completion.QuestID)
if !known || (!isActiveQuestStatus(state.Status) && !isCompletedQuestStatus(state.Status)) ||
state.ProgressValue != completion.CurrentTrigger {
return HuntMonsterPersistResult{}, ErrQuestPersistVerify
}
if completion.Completed && !completion.Idempotent &&
(state.Extra["completion_key"] != input.CompletionKey || state.Extra["reward_state"] != clearMapRewardPending) {
if !isCompletedQuestStatus(state.Status) || state.Extra["reward_state"] != finishRewardGranted ||
state.Extra["completion_key"] != input.CompletionKey {
return HuntMonsterPersistResult{}, ErrQuestPersistVerify
}
}
}
return result, nil
}
@@ -0,0 +1,122 @@
package quest
import (
"context"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestPlanHuntMonsterKillUsesPVFStrideFourAndMarksPendingReward(t *testing.T) {
completedAt := time.Date(2026, 7, 23, 22, 20, 0, 0, time.UTC)
catalog := &Catalog{byID: map[int64]Definition{
2635: {ID: 2635, Path: "n_quest/earring.qst", Type: "[hunt monster]", IntData: []int64{
311, 2, 9600, 1,
311, 2, 9601, 1,
311, 2, 9602, 1,
}},
}}
record := dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
2635: {Status: "active", ProgressValue: int64(packTriggerChannels(0, 0, 1)), Extra: map[string]string{"kept": "yes"}},
},
}
plan, err := catalog.PlanHuntMonsterKill(record, HuntMonsterKillInput{
DungeonID: 311,
Difficulty: 2,
MonsterCode: 9602,
CompletionKey: "run-19/op39/9602",
CompletedAt: completedAt,
})
if err != nil {
t.Fatal(err)
}
if len(plan.ChangedFields) != 1 || plan.ChangedFields[0] != dnfrepo.QuestFieldStates {
t.Fatalf("changed fields=%v", plan.ChangedFields)
}
if len(plan.Completions) != 1 || plan.Completions[0].QuestID != 2635 ||
plan.Completions[0].PreviousTrigger != int64(packTriggerChannels(0, 0, 1)) ||
!plan.Completions[0].Completed {
t.Fatalf("completions=%+v", plan.Completions)
}
state := plan.Record.States[2635]
if state.ProgressValue != 0 || state.Extra["reward_state"] != "pending" ||
state.Extra["completion_kind"] != "hunt_monster" ||
state.Extra["completion_monster_code"] != "9602" ||
state.Extra["completion_key"] != "run-19/op39/9602" ||
state.Extra["kept"] != "yes" {
t.Fatalf("state=%+v", state)
}
if original := record.States[2635]; original.ProgressValue != int64(packTriggerChannels(0, 0, 1)) || original.Extra["reward_state"] != "" {
t.Fatalf("input record mutated=%+v", original)
}
}
func TestPlanHuntMonsterKillIgnoresScopeMismatch(t *testing.T) {
catalog := &Catalog{byID: map[int64]Definition{
2635: {ID: 2635, Type: "[hunt monster]", IntData: []int64{311, 2, 9602, 1}},
}}
record := dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{2635: {Status: "active", ProgressValue: 1}},
}
plan, err := catalog.PlanHuntMonsterKill(record, HuntMonsterKillInput{
DungeonID: 312,
Difficulty: 2,
MonsterCode: 9602,
CompletionKey: "run",
CompletedAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
if len(plan.ChangedFields) != 0 || plan.Record.States[2635].ProgressValue != 1 {
t.Fatalf("unexpected change fields=%v state=%+v", plan.ChangedFields, plan.Record.States[2635])
}
}
func TestOwnerApplyHuntMonsterKillPersistsAndVerifiesPendingReward(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "acc", Job: "2", Level: 90}); err != nil {
t.Fatal(err)
}
if err := repos.Quest.Save(ctx, dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{2635: {Status: "active", ProgressValue: 1}},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
catalog := &Catalog{byID: map[int64]Definition{
2635: {ID: 2635, Path: "n_quest/earring.qst", Type: "[hunt monster]", IntData: []int64{-1, -1, 9602, 1}},
}}
result, err := owner.ApplyHuntMonsterKill(ctx, catalog, "19", HuntMonsterKillInput{
DungeonID: 311,
Difficulty: 2,
MonsterCode: 9602,
CompletionKey: "run-19/op39/9602",
CompletedAt: time.Date(2026, 7, 23, 22, 25, 0, 0, time.UTC),
})
if err != nil {
t.Fatal(err)
}
if len(result.Completions) != 1 || result.Completions[0].QuestID != 2635 {
t.Fatalf("result=%+v", result)
}
persisted, ok, err := repos.Quest.Load(ctx, "19")
if err != nil || !ok {
t.Fatalf("load persisted ok=%t err=%v", ok, err)
}
state := persisted.States[2635]
if state.ProgressValue != 0 || state.Extra["reward_state"] != "pending" || state.Extra["completion_key"] != "run-19/op39/9602" {
t.Fatalf("persisted state=%+v", state)
}
}
@@ -0,0 +1,70 @@
package quest
import (
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// ImageCommunicationTarget identifies the remote NPC object that the current
// client must materialize after the player uses Mia Richter's communication
// equipment. The NPC ID comes from the active PVF meet-NPC quest's [int data].
type ImageCommunicationTarget struct {
QuestID int64
NPCID uint32
}
// ActiveImageCommunicationTarget resolves one unambiguous active remote-call
// quest. Canonical States override legacy Progress rows, matching the other
// quest projections; an ambiguous record fails closed instead of showing the
// wrong remote NPC.
func (c *Catalog) ActiveImageCommunicationTarget(record dnfrepo.QuestRecord) (ImageCommunicationTarget, bool) {
if c == nil {
return ImageCommunicationTarget{}, false
}
target := ImageCommunicationTarget{}
foundTarget := false
consider := func(questID int64, state dnfrepo.QuestState) bool {
if !isActiveQuestStatus(state.Status) {
return true
}
definition, found := c.Find(questID)
if !found || !isImageCommunicationDefinition(definition) {
return true
}
npcID := definition.IntData[0]
if npcID <= 0 || npcID > int64(^uint32(0)) {
return true
}
candidate := ImageCommunicationTarget{QuestID: questID, NPCID: uint32(npcID)}
if foundTarget && candidate != target {
return false
}
target = candidate
foundTarget = true
return true
}
for questID, state := range record.States {
if !consider(questID, state) {
return ImageCommunicationTarget{}, false
}
}
for questID, state := range record.Progress {
if _, canonical := record.States[questID]; canonical {
continue
}
if !consider(questID, state) {
return ImageCommunicationTarget{}, false
}
}
return target, foundTarget
}
func isImageCommunicationDefinition(definition Definition) bool {
if normalizeQuestTag(definition.Type) != "meet npc" || len(definition.IntData) == 0 {
return false
}
message := definition.DependMessage + "\n" + definition.ConditionMessage + "\n" + definition.SolveMessage
return strings.Contains(message, "远程通话装置") || strings.Contains(message, "视频通信装置")
}
@@ -0,0 +1,56 @@
package quest
import (
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestActiveImageCommunicationTargetUsesActivePVFMeetNPC(t *testing.T) {
remote := Definition{
ID: 3734,
Type: "[meet npc]",
IntData: []int64{2000},
ConditionMessage: "利用通信装备与梅尔文联系。(远程通话装置只能在相关任务存在的情况下才能使用)",
}
ordinary := Definition{ID: 3735, Type: "[meet npc]", IntData: []int64{59}, ConditionMessage: "与梅尔文对话。"}
catalog := &Catalog{byID: map[int64]Definition{remote.ID: remote, ordinary.ID: ordinary}}
target, ok := catalog.ActiveImageCommunicationTarget(dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
3734: {Status: "active"},
3735: {Status: "active"},
}})
if !ok || target != (ImageCommunicationTarget{QuestID: 3734, NPCID: 2000}) {
t.Fatalf("target=%+v ok=%t", target, ok)
}
}
func TestActiveImageCommunicationTargetCanonicalStateOverridesStaleProgress(t *testing.T) {
remote := Definition{
ID: 3734,
Type: "meet npc",
IntData: []int64{2000},
ConditionMessage: "远程通话装置只能在相关任务存在的情况下使用。",
}
catalog := &Catalog{byID: map[int64]Definition{remote.ID: remote}}
record := dnfrepo.QuestRecord{
States: map[int64]dnfrepo.QuestState{3734: {Status: "completed"}},
Progress: map[int64]dnfrepo.QuestState{3734: {Status: "active"}},
}
if target, ok := catalog.ActiveImageCommunicationTarget(record); ok {
t.Fatalf("completed canonical state resolved stale target=%+v", target)
}
}
func TestActiveImageCommunicationTargetRejectsAmbiguousRemoteQuests(t *testing.T) {
first := Definition{ID: 3734, Type: "meet npc", IntData: []int64{2000}, ConditionMessage: "远程通话装置只能使用。"}
second := Definition{ID: 3741, Type: "meet npc", IntData: []int64{2001}, ConditionMessage: "视频通信装置只能使用。"}
catalog := &Catalog{byID: map[int64]Definition{first.ID: first, second.ID: second}}
record := dnfrepo.QuestRecord{States: map[int64]dnfrepo.QuestState{
3734: {Status: "active"},
3741: {Status: "active"},
}}
if target, ok := catalog.ActiveImageCommunicationTarget(record); ok {
t.Fatalf("ambiguous target=%+v", target)
}
}
@@ -0,0 +1,148 @@
package quest
import (
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const legacySaturatedTrigger = int64(0x1ff)
type LegacyActiveTriggerRepair struct {
QuestID int64
Field dnfrepo.QuestField
PreviousTrigger int64
CurrentTrigger int64
PVFPath string
}
type LegacyActiveTriggerRepairPlan struct {
Record dnfrepo.QuestRecord
Repairs []LegacyActiveTriggerRepair
ChangedFields []dnfrepo.QuestField
}
// PlanLegacySaturatedActiveTriggerRepair repairs one historical persistence
// shape: an already-cleared hunt target was stored as the signed -1 sentinel
// masked into its nine-bit channel (0x1ff). The current client interprets the
// channel as an unsigned remaining count, so it displays required-511. Clear
// that sentinel to zero; do not reset the quest to its PVF initial counts.
//
// It also repairs the same corruption after a few additional decrements. A
// polluted channel may no longer be exactly 0x1ff, for example 505 while PVF
// requires only one kill. The current client displays that as 1-505 = -504.
// Clamp only the oversaturated channel(s) to zero and preserve ordinary
// remaining channels such as an unfinished third target.
func (c *Catalog) PlanLegacySaturatedActiveTriggerRepair(record dnfrepo.QuestRecord, repairedAt time.Time) (LegacyActiveTriggerRepairPlan, error) {
if c == nil {
return LegacyActiveTriggerRepairPlan{}, ErrCatalogEmpty
}
plan := LegacyActiveTriggerRepairPlan{Record: dnfrepo.CloneQuest(record)}
changed := make(map[dnfrepo.QuestField]bool, 2)
repair := func(states map[int64]dnfrepo.QuestState, field dnfrepo.QuestField) {
for questID, state := range states {
if !strings.EqualFold(strings.TrimSpace(state.Status), "active") {
continue
}
definition, ok := c.byID[questID]
if !ok {
continue
}
tag := normalizeQuestTag(definition.Type)
if tag != "hunt monster" && tag != "hunt enemy" {
continue
}
expected := definitionInitialTrigger(definition, nil)
if !isLegacySaturatedMultiTargetTrigger(expected) {
continue
}
previous := state.ProgressValue
current := previous
repairKind := ""
expectedText := "0"
if state.ProgressValue == legacySaturatedTrigger || isPriorIncorrectLegacyTriggerRepair(state, expected) {
current = 0
repairKind = "pvf_multitarget_completed_sentinel_0x1ff_to_zero"
} else if state.ProgressValue >= 0 && state.ProgressValue <= int64(^uint32(0)) {
if repaired, changed := repairOversaturatedPackedTriggerChannels(uint32(state.ProgressValue), expected); changed {
current = int64(repaired)
repairKind = "pvf_multitarget_oversaturated_channel_to_zero"
expectedText = strconv.FormatInt(current, 10)
}
}
if repairKind == "" {
continue
}
state.ProgressValue = current
if !repairedAt.IsZero() {
state.UpdatedAt = repairedAt
}
if state.Extra == nil {
state.Extra = make(map[string]string, 5)
}
state.Extra["legacy_trigger_repair_kind"] = repairKind
state.Extra["legacy_trigger_repair_previous"] = strconv.FormatInt(previous, 10)
state.Extra["legacy_trigger_repair_expected"] = expectedText
state.Extra["legacy_trigger_pvf_initial"] = strconv.FormatUint(uint64(expected), 10)
state.Extra["pvf_path"] = definition.Path
state.Extra["quest_type"] = definition.Type
states[questID] = state
changed[field] = true
plan.Repairs = append(plan.Repairs, LegacyActiveTriggerRepair{
QuestID: questID,
Field: field,
PreviousTrigger: previous,
CurrentTrigger: current,
PVFPath: definition.Path,
})
}
}
repair(plan.Record.States, dnfrepo.QuestFieldStates)
repair(plan.Record.Progress, dnfrepo.QuestFieldProgress)
for _, field := range []dnfrepo.QuestField{dnfrepo.QuestFieldStates, dnfrepo.QuestFieldProgress} {
if changed[field] {
plan.ChangedFields = append(plan.ChangedFields, field)
}
}
if len(plan.ChangedFields) > 0 && !repairedAt.IsZero() {
plan.Record.UpdatedAt = repairedAt
}
return plan, nil
}
func isPriorIncorrectLegacyTriggerRepair(state dnfrepo.QuestState, expected uint32) bool {
return state.ProgressValue == int64(expected) &&
state.Extra["legacy_trigger_repair_kind"] == "pvf_multitarget_saturated_0x1ff" &&
state.Extra["legacy_trigger_repair_previous"] == strconv.FormatInt(legacySaturatedTrigger, 10)
}
func isLegacySaturatedMultiTargetTrigger(expected uint32) bool {
channel0 := expected & 0x1ff
channel1 := (expected >> 9) & 0x1ff
channel2 := (expected >> 18) & 0x1ff
return channel0 > 0 && channel0 < uint32(legacySaturatedTrigger) && (channel1 > 0 || channel2 > 0)
}
func repairOversaturatedPackedTriggerChannels(current uint32, expected uint32) (uint32, bool) {
repaired := current
changed := false
for channel := 0; channel < 3; channel++ {
shift := channel * 9
want := (expected >> shift) & 0x1ff
if want == 0 || want >= uint32(legacySaturatedTrigger) {
continue
}
got := (repaired >> shift) & 0x1ff
if got <= want {
continue
}
repaired &^= 0x1ff << shift
changed = true
}
return repaired, changed
}
@@ -0,0 +1,164 @@
package quest
import (
"context"
"reflect"
"strconv"
"testing"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestPlanLegacySaturatedActiveTriggerRepairUsesPVFMultiTargetCounts(t *testing.T) {
source := catalogTestSource{
DefaultList: "2635 `earring.qst`\n2637 `single.qst`\n2638 `other.qst`\n",
"n_quest/earring.qst": questCatalogTestDefinition("[side]", 90, 90, "[all]", `
[type]
`+"`[hunt monster]`"+`
[int data]
311 2 63767 1 314 2 63796 1
`),
"n_quest/single.qst": questCatalogTestDefinition("[side]", 90, 90, "[all]", `
[type]
`+"`[hunt monster]`"+`
[int data]
311 2 63767 511
`),
"n_quest/other.qst": questCatalogTestDefinition("[side]", 90, 90, "[all]", `
[type]
`+"`[meet npc]`"+`
`),
}
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)
}
repairedAt := time.Date(2026, 7, 23, 2, 30, 0, 0, time.UTC)
record := dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
2635: {Status: "active", ProgressValue: 511},
2637: {Status: "active", ProgressValue: 511},
2638: {Status: "active", ProgressValue: 511},
},
Progress: map[int64]dnfrepo.QuestState{
2635: {Status: "active", ProgressValue: 512},
},
}
plan, err := catalog.PlanLegacySaturatedActiveTriggerRepair(record, repairedAt)
if err != nil {
t.Fatal(err)
}
if got := plan.Record.States[2635].ProgressValue; got != 0 {
t.Fatalf("quest 2635 repaired trigger=%d, want completed trigger 0", got)
}
if got := plan.Record.Progress[2635].ProgressValue; got != 512 {
t.Fatalf("legitimate partial progress changed to %d", got)
}
if got := plan.Record.States[2637].ProgressValue; got != 511 {
t.Fatalf("legitimate single-channel 511 changed to %d", got)
}
if got := plan.Record.States[2638].ProgressValue; got != 511 {
t.Fatalf("unrelated quest changed to %d", got)
}
if !reflect.DeepEqual(plan.ChangedFields, []dnfrepo.QuestField{dnfrepo.QuestFieldStates}) || len(plan.Repairs) != 1 {
t.Fatalf("repair plan=%+v", plan)
}
state := plan.Record.States[2635]
if !state.UpdatedAt.Equal(repairedAt) || state.Extra["legacy_trigger_repair_previous"] != "511" || state.Extra["legacy_trigger_repair_expected"] != "0" || state.Extra["legacy_trigger_pvf_initial"] != "513" {
t.Fatalf("repair metadata=%+v", state)
}
}
func TestPlanLegacySaturatedActiveTriggerRepairCorrectsPriorReset(t *testing.T) {
source := catalogTestSource{
DefaultList: "2635 `earring.qst`\n",
"n_quest/earring.qst": questCatalogTestDefinition("[side]", 90, 90, "[all]", `
[type]
`+"`[hunt monster]`"+`
[int data]
311 2 63767 1 314 2 63796 1
`),
}
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.PlanLegacySaturatedActiveTriggerRepair(dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
2635: {
Status: "active",
ProgressValue: 513,
Extra: map[string]string{
"legacy_trigger_repair_kind": "pvf_multitarget_saturated_0x1ff",
"legacy_trigger_repair_previous": "511",
},
},
},
}, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if len(plan.Repairs) != 1 || plan.Record.States[2635].ProgressValue != 0 {
t.Fatalf("prior reset correction plan=%+v", plan)
}
}
func TestPlanLegacySaturatedActiveTriggerRepairClampsOnlyOversaturatedChannel(t *testing.T) {
source := catalogTestSource{
DefaultList: "2635 `earring.qst`\n",
"n_quest/earring.qst": questCatalogTestDefinition("[side]", 90, 90, "[all]", `
[type]
`+"`[hunt monster]`"+`
[int data]
311 2 9600 1 311 2 9601 1 311 2 9602 1
`),
}
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)
}
repairedAt := time.Date(2026, 7, 23, 22, 15, 0, 0, time.UTC)
polluted := int64(packTriggerChannels(505, 0, 1))
record := dnfrepo.QuestRecord{
CharacterID: "19",
States: map[int64]dnfrepo.QuestState{
2635: {Status: "active", ProgressValue: polluted},
},
}
plan, err := catalog.PlanLegacySaturatedActiveTriggerRepair(record, repairedAt)
if err != nil {
t.Fatal(err)
}
want := int64(packTriggerChannels(0, 0, 1))
if got := plan.Record.States[2635].ProgressValue; got != want {
t.Fatalf("repaired trigger=%d, want %d", got, want)
}
if len(plan.Repairs) != 1 || plan.Repairs[0].PreviousTrigger != polluted || plan.Repairs[0].CurrentTrigger != want {
t.Fatalf("repairs=%+v", plan.Repairs)
}
state := plan.Record.States[2635]
if !state.UpdatedAt.Equal(repairedAt) ||
state.Extra["legacy_trigger_repair_kind"] != "pvf_multitarget_oversaturated_channel_to_zero" ||
state.Extra["legacy_trigger_repair_previous"] != strconv.FormatInt(polluted, 10) ||
state.Extra["legacy_trigger_repair_expected"] != strconv.FormatInt(want, 10) ||
state.Extra["legacy_trigger_pvf_initial"] != strconv.FormatUint(uint64(packTriggerChannels(1, 1, 1)), 10) {
t.Fatalf("repair metadata=%+v", state)
}
}
@@ -0,0 +1,147 @@
package quest
import (
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// LinkedQuestChainResetPlan is the administrative rollback projection for a
// quest-clear parent and the managed subtasks it owns. A linked child cannot
// be safely made incomplete on its own: the client derives its NPC/dialogue
// ownership from the parent and from every sibling row in the same generation.
type LinkedQuestChainResetPlan struct {
Record dnfrepo.QuestRecord
ParentQuestID int64
ReactivatedQuestIDs []int64
ChangedFields []dnfrepo.QuestField
}
// PlanLinkedQuestChainReset removes every persisted generation of a linked
// quest-clear family and activates a fresh parent/child generation from PVF.
// Ordinary standalone quests deliberately remain outside this operation.
func (c *Catalog) PlanLinkedQuestChainReset(
character CharacterEligibility,
record dnfrepo.QuestRecord,
requestedQuestID int64,
resetAt time.Time,
) (LinkedQuestChainResetPlan, error) {
if c == nil {
return LinkedQuestChainResetPlan{}, ErrCatalogEmpty
}
requested, ok := c.Find(requestedQuestID)
if !ok {
return LinkedQuestChainResetPlan{}, ErrQuestDefinitionMissing
}
if requested.MainQuestID <= 0 {
return LinkedQuestChainResetPlan{}, ErrQuestNotAcceptable
}
parent, ok := c.Find(requested.MainQuestID)
if !ok || !questClearDefinition(parent) {
return LinkedQuestChainResetPlan{}, ErrQuestNotAcceptable
}
familyIDs := c.managedLinkedQuestChainIDs(parent)
requestedManaged := false
for _, questID := range familyIDs[1:] {
if questID == requestedQuestID {
requestedManaged = true
break
}
}
if !requestedManaged {
return LinkedQuestChainResetPlan{}, ErrQuestNotAcceptable
}
reset := dnfrepo.CloneQuest(record)
for _, questID := range familyIDs {
delete(reset.States, questID)
delete(reset.Progress, questID)
}
accept, err := c.PlanAccept(character, reset, parent.ID)
if err != nil {
return LinkedQuestChainResetPlan{}, err
}
if resetAt.IsZero() {
resetAt = time.Now().UTC()
}
applyAcceptPlan(&reset, accept, parent, record.CharacterID, resetAt)
reset.UpdatedAt = resetAt
completed := make(map[int64]struct{})
for _, linked := range accept.LinkedSubQuests {
if c.linkedChildWaitsForEarlierSibling(parent, c.byID[linked.QuestID], completed) {
delete(reset.States, linked.QuestID)
}
}
reactivated := []int64{accept.QuestID}
for _, linked := range accept.LinkedSubQuests {
if _, active := reset.States[linked.QuestID]; active {
reactivated = append(reactivated, linked.QuestID)
}
}
return LinkedQuestChainResetPlan{
Record: reset,
ParentQuestID: parent.ID,
ReactivatedQuestIDs: reactivated,
ChangedFields: []dnfrepo.QuestField{dnfrepo.QuestFieldStates, dnfrepo.QuestFieldProgress},
}, nil
}
func (c *Catalog) managedLinkedQuestChainIDs(parent Definition) []int64 {
ids := []int64{parent.ID}
if c == nil || !questClearDefinition(parent) {
return ids
}
seen := make(map[int64]struct{}, len(parent.IntData))
for _, childID := range parent.IntData {
if childID <= 0 {
continue
}
if _, duplicate := seen[childID]; duplicate {
continue
}
seen[childID] = struct{}{}
child, ok := c.Find(childID)
if !ok || child.MainQuestID != parent.ID || child.HasDependGiveItem || !shouldAutoCompleteNoRewardSubQuest(child) {
continue
}
ids = append(ids, childID)
}
return ids
}
func questClearDefinition(definition Definition) bool {
tag := normalizeQuestTag(definition.Type)
return tag == "quest clear" || tag == "clear quest"
}
func (c *Catalog) linkedChildWaitsForEarlierSibling(parent, child Definition, completed map[int64]struct{}) bool {
if c == nil || child.MainQuestID != parent.ID || !orderedLinkedStoryStep(child) {
return false
}
for _, siblingID := range parent.IntData {
if siblingID == child.ID {
return false
}
if siblingID <= 0 {
continue
}
sibling, ok := c.byID[siblingID]
if !ok || sibling.MainQuestID != parent.ID || sibling.HasDependGiveItem || !shouldAutoCompleteNoRewardSubQuest(sibling) {
continue
}
if _, done := completed[siblingID]; !done {
return true
}
}
return false
}
func orderedLinkedStoryStep(definition Definition) bool {
switch normalizeQuestTag(definition.Type) {
case "meet npc", "seek n meet npc", "reach the range", "look cinematic":
return true
default:
return false
}
}
@@ -0,0 +1,241 @@
package quest
import (
"math"
"sort"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type LinkedObjectiveProgressPlan struct {
Record dnfrepo.QuestRecord
ParentQuestID int64
ParentProgress int64
CompletedQuestIDs []int64
ChangedFields []dnfrepo.QuestField
}
func (c *Catalog) IsLinkedObjective(questID int64) bool {
definition, ok := c.Find(questID)
return ok && linkedObjectiveQuest(definition)
}
// PlanTownLinkedProgress handles the scalar parent callback used by NPC,
// inventory-presence and cinematic objectives. Combat and clear-map children
// remain owned by their dungeon path until the client closes them with op34.
func (c *Catalog) PlanTownLinkedProgress(record dnfrepo.QuestRecord, questID int64, completedAt time.Time) (LinkedObjectiveProgressPlan, error) {
return c.planLinkedObjectiveProgress(record, questID, completedAt, true, false)
}
// PlanLinkedObjectiveFinish closes the requested no-reward child after its
// trigger has reached zero and recomputes its quest-clear parent atomically.
func (c *Catalog) PlanLinkedObjectiveFinish(record dnfrepo.QuestRecord, questID int64, completedAt time.Time) (LinkedObjectiveProgressPlan, error) {
return c.planLinkedObjectiveProgress(record, questID, completedAt, false, true)
}
func (c *Catalog) planLinkedObjectiveProgress(
record dnfrepo.QuestRecord,
questID int64,
completedAt time.Time,
townOnly bool,
requireRequestedChild bool,
) (LinkedObjectiveProgressPlan, error) {
if c == nil {
return LinkedObjectiveProgressPlan{}, ErrCatalogEmpty
}
if completedAt.IsZero() {
completedAt = time.Now().UTC()
}
plan := LinkedObjectiveProgressPlan{Record: dnfrepo.CloneQuest(record)}
requested, ok := c.Find(questID)
if !ok {
return plan, nil
}
parentID := requested.ID
requestedIsChild := linkedObjectiveQuest(requested)
if requestedIsChild {
state, _, active := mutableQuestState(&plan.Record, requested.ID)
if !active || !isActiveQuestStatus(state.Status) || state.ProgressValue != 0 {
return plan, nil
}
parentID = requested.MainQuestID
} else {
if requireRequestedChild {
return plan, nil
}
tag := normalizeQuestTag(requested.Type)
if tag != "quest clear" && tag != "clear quest" {
return plan, nil
}
}
parent, ok := c.Find(parentID)
if !ok {
return plan, nil
}
parentTag := normalizeQuestTag(parent.Type)
if parentTag != "quest clear" && parentTag != "clear quest" {
return plan, nil
}
parentState, parentField, parentActive := mutableQuestState(&plan.Record, parentID)
if !parentActive || !isActiveQuestStatus(parentState.Status) {
return plan, nil
}
completed, _ := questStateSets(plan.Record)
requestedCompleted := !requestedIsChild
seen := make(map[int64]struct{}, len(parent.IntData))
for _, childID := range parent.IntData {
if childID <= 0 {
continue
}
if _, duplicate := seen[childID]; duplicate {
continue
}
seen[childID] = struct{}{}
child, known := c.Find(childID)
if !known || child.MainQuestID != parentID || !linkedObjectiveQuest(child) || (townOnly && !townLinkedObjectiveQuest(child)) {
continue
}
if _, done := completed[childID]; done {
if childID == questID {
requestedCompleted = true
}
continue
}
state, field, active := mutableQuestState(&plan.Record, childID)
if !active || !isActiveQuestStatus(state.Status) || state.ProgressValue != 0 {
continue
}
state.Status = "completed"
state.ProgressValue = 0
state.UpdatedAt = completedAt
if state.Extra == nil {
state.Extra = make(map[string]string, 4)
}
state.Extra["reward_state"] = finishRewardGranted
state.Extra["linked_objective_completed"] = "true"
state.Extra["linked_objective_completion_source"] = "client_callback_without_reward"
switch field {
case dnfrepo.QuestFieldStates:
plan.Record.States[childID] = state
case dnfrepo.QuestFieldProgress:
plan.Record.Progress[childID] = state
}
plan.ChangedFields = mergeQuestChangedField(plan.ChangedFields, field)
plan.CompletedQuestIDs = append(plan.CompletedQuestIDs, childID)
completed[childID] = struct{}{}
if childID == questID {
requestedCompleted = true
}
}
if requireRequestedChild && !requestedCompleted {
return LinkedObjectiveProgressPlan{Record: dnfrepo.CloneQuest(record)}, nil
}
if len(plan.CompletedQuestIDs) == 0 {
return LinkedObjectiveProgressPlan{Record: dnfrepo.CloneQuest(record)}, nil
}
remaining := questClearParentTrigger(parent.IntData, completed)
if parentState.ProgressValue != remaining {
parentState.ProgressValue = remaining
parentState.UpdatedAt = completedAt
switch parentField {
case dnfrepo.QuestFieldStates:
plan.Record.States[parentID] = parentState
case dnfrepo.QuestFieldProgress:
plan.Record.Progress[parentID] = parentState
}
plan.ChangedFields = mergeQuestChangedField(plan.ChangedFields, parentField)
}
plan.ParentQuestID = parentID
plan.ParentProgress = remaining
plan.Record.UpdatedAt = completedAt
sort.Slice(plan.CompletedQuestIDs, func(i, j int) bool { return plan.CompletedQuestIDs[i] < plan.CompletedQuestIDs[j] })
return plan, nil
}
func linkedObjectiveQuest(definition Definition) bool {
return definition.NoExperience && shouldAutoCompleteNoRewardSubQuest(definition)
}
func zeroItemRewardPlaceholder(definition Definition) bool {
return normalizeQuestTag(definition.RewardType) == "item" &&
len(definition.RewardIntData) == 2 && definition.RewardIntData[0] == 0 && definition.RewardIntData[1] == 0 &&
definition.RewardParseError == "" && !definition.HasGoldReward &&
len(definition.RewardItems) == 0 && len(definition.RewardSelectionItems) == 0
}
func rewardBearingParentChild(definition Definition) bool {
if normalizeQuestTag(definition.Grade) != "sub" || !definition.NoExperience ||
normalizeQuestTag(definition.RewardType) != "item" || definition.RewardParseError != "" || definition.HasGoldReward {
return false
}
return !emptyRewardDataValid(definition.RewardIntData) || len(definition.RewardItems) != 0 || len(definition.RewardSelectionItems) != 0
}
func (c *Catalog) parentBoundChildren(parent Definition) ([]Definition, bool) {
if c == nil || (normalizeQuestTag(parent.Type) != "quest clear" && normalizeQuestTag(parent.Type) != "clear quest") || parent.SubType != 2 {
return nil, false
}
children := make([]Definition, 0, len(parent.IntData))
seen := make(map[int64]struct{}, len(parent.IntData))
for _, childID := range parent.IntData {
if childID <= 0 {
continue
}
if _, duplicate := seen[childID]; duplicate {
continue
}
seen[childID] = struct{}{}
child, found := c.byID[childID]
if !found || child.ID > math.MaxUint16 || child.MainQuestID != parent.ID || normalizeQuestTag(child.Grade) != "sub" || !child.NoExperience {
return nil, false
}
children = append(children, cloneDefinition(child))
}
return children, len(children) != 0
}
func (c *Catalog) completedLinkedChildrenForParentFinish(parent Definition, record dnfrepo.QuestRecord) []int64 {
if c == nil || !zeroItemRewardPlaceholder(parent) {
return nil
}
parentState, active := questStateFor(record, parent.ID)
if !active || !isActiveQuestStatus(parentState.Status) || parentState.ProgressValue != 0 {
return nil
}
children, ok := c.parentBoundChildren(parent)
if !ok {
return nil
}
completed, _ := questStateSets(record)
result := make([]int64, 0, len(children))
for _, child := range children {
if !linkedObjectiveQuest(child) {
continue
}
if _, done := completed[child.ID]; done {
continue
}
state, known := questStateFor(record, child.ID)
if known && isActiveQuestStatus(state.Status) && state.ProgressValue == 0 {
result = append(result, child.ID)
}
}
return result
}
func townLinkedObjectiveQuest(definition Definition) bool {
if !linkedObjectiveQuest(definition) {
return false
}
switch normalizeQuestTag(definition.Type) {
case "meet npc", "seek n meet npc", "seeking", "look cinematic":
return true
default:
return false
}
}
@@ -0,0 +1,87 @@
package quest
import (
"context"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type LinkedObjectiveCommitResult struct {
CharacterID string
RequestedQuestID int64
ParentQuestID int64
ParentProgress int64
CompletedQuestIDs []int64
ChangedFields []dnfrepo.QuestField
PostCommitQuest dnfrepo.QuestRecord
Applied bool
}
func (o *Owner) ApplyTownLinkedProgress(ctx context.Context, catalog *Catalog, characterID string, questID int64, completedAt time.Time) (LinkedObjectiveCommitResult, error) {
return o.applyLinkedObjectiveProgress(ctx, catalog, characterID, questID, completedAt, true)
}
func (o *Owner) ApplyLinkedObjectiveFinish(ctx context.Context, catalog *Catalog, characterID string, questID int64, completedAt time.Time) (LinkedObjectiveCommitResult, error) {
return o.applyLinkedObjectiveProgress(ctx, catalog, characterID, questID, completedAt, false)
}
func (o *Owner) applyLinkedObjectiveProgress(
ctx context.Context,
catalog *Catalog,
characterID string,
questID int64,
completedAt time.Time,
townParentCallback bool,
) (LinkedObjectiveCommitResult, error) {
if o == nil || o.repositories.CharacterSettlement == nil || catalog == nil {
return LinkedObjectiveCommitResult{}, ErrOwnerUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return LinkedObjectiveCommitResult{}, ErrCharacterRequired
}
if questID <= 0 {
return LinkedObjectiveCommitResult{}, ErrQuestIDRequired
}
result := LinkedObjectiveCommitResult{CharacterID: characterID, RequestedQuestID: questID}
err := o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
if tx.Quest == nil {
return ErrOwnerUnavailable
}
record, found, err := tx.Quest.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(record.CharacterID) != characterID {
return nil
}
var plan LinkedObjectiveProgressPlan
if townParentCallback {
plan, err = catalog.PlanTownLinkedProgress(record, questID, completedAt)
} else {
plan, err = catalog.PlanLinkedObjectiveFinish(record, questID, completedAt)
}
if err != nil {
return err
}
if len(plan.ChangedFields) == 0 {
return nil
}
if err := dnfrepo.SaveQuestFields(ctx, tx.Quest, plan.Record, plan.ChangedFields...); err != nil {
return err
}
result.ParentQuestID = plan.ParentQuestID
result.ParentProgress = plan.ParentProgress
result.CompletedQuestIDs = append([]int64(nil), plan.CompletedQuestIDs...)
result.ChangedFields = append([]dnfrepo.QuestField(nil), plan.ChangedFields...)
result.PostCommitQuest = dnfrepo.CloneQuest(plan.Record)
result.Applied = true
return nil
})
if err != nil {
return LinkedObjectiveCommitResult{}, err
}
return result, nil
}
@@ -0,0 +1,94 @@
package quest
import (
"context"
"reflect"
"testing"
"time"
"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 linkedObjectiveTestCatalog(t *testing.T) *Catalog {
t.Helper()
source := catalogTestSource{
DefaultList: "3249 `parent3249.qst`\n3609 `meet.qst`\n3610 `seekmeet.qst`\n3347 `parent3347.qst`\n3425 `hunt.qst`\n3426 `seeking.qst`\n",
"n_quest/parent3249.qst": "[grade]\n`[epic]`\n[type]\n`[quest clear]`\n[int data]\n3609 3610\n[reward type]\n`[item]`\n[reward int data]\n0 0\n",
"n_quest/meet.qst": "[grade]\n`[sub]`\n[attribute]\n`not give exp quest`\n[/attribute]\n[type]\n`[meet npc]`\n[main quest]\n3249\n[int data]\n303\n[reward type]\n`[item]`\n[reward int data]\n0 0\n",
"n_quest/seekmeet.qst": "[grade]\n`[sub]`\n[attribute]\n`not give exp quest`\n[/attribute]\n[type]\n`[seek n meet npc]`\n[main quest]\n3249\n[int data]\n3037 1 309\n[reward type]\n`[item]`\n[reward int data]\n0 0\n",
"n_quest/parent3347.qst": "[grade]\n`[epic]`\n[type]\n`[quest clear]`\n[int data]\n3425 3426\n[reward type]\n`[item]`\n[reward int data]\n0 0\n",
"n_quest/hunt.qst": "[grade]\n`[sub]`\n[attribute]\n`not give exp quest`\n[/attribute]\n[type]\n`[hunt enemy]`\n[main quest]\n3347\n[int data]\n3 -1 13099 3 1\n[reward type]\n`[item]`\n[reward int data]\n0 0\n",
"n_quest/seeking.qst": "[grade]\n`[sub]`\n[attribute]\n`not give exp quest`\n[/attribute]\n[type]\n`[seeking]`\n[main quest]\n3347\n[int data]\n3037 1\n[reward type]\n`[item]`\n[reward int data]\n0 0\n",
}
index, err := pvf.Build(context.Background(), source, pvf.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 TestPlanLinkedObjectiveProgressCompletesNoRewardChildrenAndKeepsDungeonOwners(t *testing.T) {
catalog := linkedObjectiveTestCatalog(t)
now := time.Date(2026, 8, 5, 15, 0, 0, 0, time.UTC)
record := dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
3347: {Status: "active", ProgressValue: 1},
3425: {Status: "active", ProgressValue: 1},
3426: {Status: "active", ProgressValue: 0},
}}
town, err := catalog.PlanTownLinkedProgress(record, 3347, now)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(town.CompletedQuestIDs, []int64{3426}) || town.ParentQuestID != 3347 || town.ParentProgress != 1 {
t.Fatalf("town plan=%+v", town)
}
if state := town.Record.States[3426]; state.Status != "completed" || state.Extra["reward_state"] != finishRewardGranted {
t.Fatalf("3426 state=%+v", state)
}
town.Record.States[3425] = dnfrepo.QuestState{Status: "active", ProgressValue: 0}
finish, err := catalog.PlanLinkedObjectiveFinish(town.Record, 3425, now.Add(time.Second))
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(finish.CompletedQuestIDs, []int64{3425}) || finish.ParentProgress != 0 || finish.Record.States[3347].ProgressValue != 0 {
t.Fatalf("finish plan=%+v", finish)
}
}
func TestOwnerApplyLinkedObjectiveFinishCommitsQuestOnly(t *testing.T) {
catalog := linkedObjectiveTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "dnf:1", Level: 60}); err != nil {
t.Fatal(err)
}
if err := repositories.Quest.Save(ctx, dnfrepo.QuestRecord{CharacterID: "19", States: map[int64]dnfrepo.QuestState{
3347: {Status: "active", ProgressValue: 1},
3425: {Status: "active", ProgressValue: 0},
3426: {Status: "completed", ProgressValue: 0},
}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.ApplyLinkedObjectiveFinish(ctx, catalog, "19", 3425, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if !result.Applied || result.ParentProgress != 0 || !reflect.DeepEqual(result.CompletedQuestIDs, []int64{3425}) {
t.Fatalf("commit=%+v", result)
}
stored, found, err := repositories.Quest.Load(ctx, "19")
if err != nil || !found || stored.States[3425].Status != "completed" || stored.States[3347].ProgressValue != 0 {
t.Fatalf("stored=%+v found=%t err=%v", stored, found, err)
}
}
File diff suppressed because it is too large. Load diff
@@ -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}
}
@@ -0,0 +1,64 @@
package quest
import (
"sort"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// ProfessionQuestResetPlan is the quest-ledger side of returning a character
// to its base profession. Profession quests are persisted independently from
// the character's grow type, so changing only the character would otherwise
// leave completed or active class-change/awakening rows behind.
type ProfessionQuestResetPlan struct {
Record dnfrepo.QuestRecord
ResetQuestIDs []int64
ChangedFields []dnfrepo.QuestField
}
// PlanBaseProfessionQuestReset removes every first-class, first-awakening and
// second-awakening quest state known to the active PVF. Expert-job quests use
// job-change type 20 and deliberately remain untouched.
func (c *Catalog) PlanBaseProfessionQuestReset(record dnfrepo.QuestRecord, resetAt time.Time) (ProfessionQuestResetPlan, error) {
if c == nil {
return ProfessionQuestResetPlan{}, ErrCatalogEmpty
}
reset := dnfrepo.CloneQuest(record)
resetIDs := make(map[int64]struct{})
statesChanged := resetProfessionQuestProjection(c, reset.States, resetIDs)
progressChanged := resetProfessionQuestProjection(c, reset.Progress, resetIDs)
ids := make([]int64, 0, len(resetIDs))
for questID := range resetIDs {
ids = append(ids, questID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
fields := make([]dnfrepo.QuestField, 0, 2)
if statesChanged {
fields = append(fields, dnfrepo.QuestFieldStates)
}
if progressChanged {
fields = append(fields, dnfrepo.QuestFieldProgress)
}
if len(fields) != 0 {
if resetAt.IsZero() {
resetAt = time.Now().UTC()
}
reset.UpdatedAt = resetAt
}
return ProfessionQuestResetPlan{Record: reset, ResetQuestIDs: ids, ChangedFields: fields}, nil
}
func resetProfessionQuestProjection(c *Catalog, states map[int64]dnfrepo.QuestState, resetIDs map[int64]struct{}) bool {
changed := false
for questID := range states {
definition, ok := c.Find(questID)
if !ok || definition.JobChangeQuest < 1 || definition.JobChangeQuest > 3 {
continue
}
delete(states, questID)
resetIDs[questID] = struct{}{}
changed = true
}
return changed
}
@@ -0,0 +1,81 @@
package quest
import (
"reflect"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestPlanBaseProfessionQuestResetClearsClassAndAwakeningOnly(t *testing.T) {
catalog := &Catalog{byID: map[int64]Definition{
10: {ID: 10, JobChangeQuest: 1},
11: {ID: 11, JobChangeQuest: 2},
12: {ID: 12, JobChangeQuest: 3},
13: {ID: 13, JobChangeQuest: 20},
14: {ID: 14},
}}
resetAt := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
record := dnfrepo.QuestRecord{
CharacterID: "8",
States: map[int64]dnfrepo.QuestState{
10: {Status: "completed"},
11: {Status: "active"},
13: {Status: "active"},
14: {Status: "completed"},
},
Progress: map[int64]dnfrepo.QuestState{
10: {Status: "active"},
12: {Status: "completed"},
13: {Status: "active"},
},
}
plan, err := catalog.PlanBaseProfessionQuestReset(record, resetAt)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(plan.ResetQuestIDs, []int64{10, 11, 12}) {
t.Fatalf("reset quest IDs = %v", plan.ResetQuestIDs)
}
if !reflect.DeepEqual(plan.ChangedFields, []dnfrepo.QuestField{dnfrepo.QuestFieldStates, dnfrepo.QuestFieldProgress}) {
t.Fatalf("changed fields = %v", plan.ChangedFields)
}
if !plan.Record.UpdatedAt.Equal(resetAt) {
t.Fatalf("updated at = %v", plan.Record.UpdatedAt)
}
for _, questID := range []int64{10, 11, 12} {
if _, ok := plan.Record.States[questID]; ok {
t.Fatalf("profession quest %d remains in states", questID)
}
if _, ok := plan.Record.Progress[questID]; ok {
t.Fatalf("profession quest %d remains in progress", questID)
}
}
if _, ok := plan.Record.States[13]; !ok {
t.Fatal("expert-job state was removed")
}
if _, ok := plan.Record.States[14]; !ok {
t.Fatal("ordinary quest state was removed")
}
if _, ok := record.States[10]; !ok {
t.Fatal("input record was mutated")
}
}
func TestPlanBaseProfessionQuestResetNoopPreservesTimestamp(t *testing.T) {
catalog := &Catalog{byID: map[int64]Definition{14: {ID: 14}}}
updatedAt := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
plan, err := catalog.PlanBaseProfessionQuestReset(dnfrepo.QuestRecord{
CharacterID: "8",
States: map[int64]dnfrepo.QuestState{14: {Status: "completed"}},
UpdatedAt: updatedAt,
}, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if len(plan.ResetQuestIDs) != 0 || len(plan.ChangedFields) != 0 || !plan.Record.UpdatedAt.Equal(updatedAt) {
t.Fatalf("noop plan = %+v", plan)
}
}
+119
View File
@@ -0,0 +1,119 @@
package quest
import (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"math"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func isPVPWinDefinition(definition Definition) bool {
return normalizeQuestTag(definition.Type) == "pvp quest" && definition.SubType == 1 &&
len(definition.IntData) == 2 && definition.IntData[0] > 0 &&
definition.IntData[0] <= math.MaxUint32 && definition.IntData[1] == 0
}
type PVPProgressResult struct {
Quest dnfrepo.QuestRecord
SnapshotRequired bool
}
// ApplyPVPWin consumes one authoritative ranked 1v1 win. The existing quest
// finish transaction remains responsible for granting the quest reward.
func (o *Owner) ApplyPVPWin(ctx context.Context, catalog *Catalog, accountID, characterID, completionKey string, completedAt time.Time) (PVPProgressResult, error) {
if o == nil || o.repositories.CharacterSettlement == nil || catalog == nil {
return PVPProgressResult{}, ErrOwnerUnavailable
}
if strings.TrimSpace(accountID) == "" || strings.TrimSpace(characterID) == "" ||
strings.TrimSpace(completionKey) == "" || completedAt.IsZero() {
return PVPProgressResult{}, fmt.Errorf("PVP quest completion identity/time missing")
}
digest := sha256.Sum256([]byte(completionKey))
var result PVPProgressResult
err := o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Quest == nil {
return ErrOwnerUnavailable
}
character, found, err := tx.Character.Load(ctx, characterID)
if err != nil {
return err
}
if !found || character.CharacterID != characterID || character.AccountID != accountID {
return ErrCharacterNotFound
}
record, found, err := tx.Quest.Load(ctx, characterID)
if err != nil {
return err
}
if found && record.CharacterID != characterID {
return fmt.Errorf("PVP quest character mismatch")
}
result.Quest = dnfrepo.CloneQuest(record)
result.SnapshotRequired = found
replayed := true
for i := 0; i < 4; i++ {
v, exists := character.Stats[fmt.Sprintf("pvp_quest_receipt_%d", i)]
replayed = replayed && exists && v == int64(binary.LittleEndian.Uint64(digest[i*8:]))
}
if replayed {
return nil
}
var fields []dnfrepo.QuestField
seen := make(map[int64]bool)
visit := func(states map[int64]dnfrepo.QuestState, field dnfrepo.QuestField) {
changed := false
for id, state := range states {
if seen[id] {
continue
}
seen[id] = true
definition, exists := catalog.Find(id)
if !exists || !isPVPWinDefinition(definition) || !isActiveQuestStatus(state.Status) ||
state.ProgressValue <= 0 || state.ProgressValue > definition.IntData[0] || state.UpdatedAt.After(completedAt) {
continue
}
state.ProgressValue--
state.UpdatedAt = completedAt
if state.Extra == nil {
state.Extra = make(map[string]string)
}
state.Extra["completion_kind"] = "pvp_win"
state.Extra["completion_key"] = completionKey
state.Extra["pvf_path"] = definition.Path
if state.ProgressValue == 0 {
state.Extra["reward_state"] = "pending"
}
states[id] = state
changed = true
}
if changed {
fields = append(fields, field)
}
}
visit(result.Quest.States, dnfrepo.QuestFieldStates)
visit(result.Quest.Progress, dnfrepo.QuestFieldProgress)
if len(fields) > 0 {
result.Quest.UpdatedAt = completedAt
if err := dnfrepo.SaveQuestFields(ctx, tx.Quest, result.Quest, fields...); err != nil {
return err
}
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
for i := 0; i < 4; i++ {
character.Stats[fmt.Sprintf("pvp_quest_receipt_%d", i)] = int64(binary.LittleEndian.Uint64(digest[i*8:]))
}
return dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats)
})
if err != nil {
return PVPProgressResult{}, err
}
return result, nil
}
@@ -0,0 +1,103 @@
package quest
import (
"context"
"errors"
"reflect"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
type pvpFailCommit struct {
dnfrepo.CharacterSettlementUnitOfWork
}
func (u pvpFailCommit) WithinCharacterSettlement(ctx context.Context, id string, apply func(dnfrepo.Group) error) error {
return u.CharacterSettlementUnitOfWork.WithinCharacterSettlement(ctx, id, func(tx dnfrepo.Group) error {
if err := apply(tx); err != nil {
return err
}
return errors.New("injected commit failure")
})
}
func TestPVPWinProgressTransactionAndReplay(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{CharacterID: "16", AccountID: "a", Stats: map[string]int64{"exp": 555, "pvp_rank_point": 1200}}
if err := repos.Character.Save(ctx, character); err != nil {
t.Fatal(err)
}
definition := Definition{Type: "[pvp quest]", SubType: 1, IntData: []int64{3, 0}, Path: "n_quest/pvp/daily_pvp_02.qst"}
catalog := &Catalog{byID: map[int64]Definition{6688: definition, 6689: definition, 6690: definition, 6691: definition, 6692: definition}}
if got := definitionInitialTrigger(definition, nil); got != 3 {
t.Fatalf("initial trigger=%d", got)
}
invalid := definition
invalid.SubType = 99
if got := definitionInitialTrigger(invalid, nil); got != 1 {
t.Fatalf("unsupported subtype trigger=%d", got)
}
record := dnfrepo.QuestRecord{CharacterID: "16", States: map[int64]dnfrepo.QuestState{
6688: {Status: "active", ProgressValue: 3},
6689: {Status: "completed", ProgressValue: 0},
6691: {Status: "active", ProgressValue: -1},
6692: {Status: "active", ProgressValue: 4},
}, Progress: map[int64]dnfrepo.QuestState{6689: {Status: "active", ProgressValue: 3}}}
if err := repos.Quest.Save(ctx, record); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repos)
at := time.Unix(1700000000, 0).UTC()
failed := repos
failed.CharacterSettlement = pvpFailCommit{repos.CharacterSettlement}
failingOwner, _ := NewOwner(failed)
if result, err := failingOwner.ApplyPVPWin(ctx, catalog, "a", "16", "match1", at); err == nil || result.SnapshotRequired {
t.Fatal("failed transaction exposed a committed result")
}
after, _, _ := repos.Quest.Load(ctx, "16")
charAfter, _, _ := repos.Character.Load(ctx, "16")
if !reflect.DeepEqual(after.States, record.States) || !reflect.DeepEqual(charAfter.Stats, character.Stats) {
t.Fatal("failed commit retained progress or receipt")
}
for _, key := range []string{"match1", "match1", "match2", "match3", "match4"} {
if _, err := owner.ApplyPVPWin(ctx, catalog, "a", "16", key, at); err != nil {
t.Fatal(err)
}
if key == "match1" {
current, _, _ := repos.Quest.Load(ctx, "16")
if current.States[6688].ProgressValue != 2 {
t.Fatal("replayed win decremented twice")
}
}
}
after, _, _ = repos.Quest.Load(ctx, "16")
state := after.States[6688]
if state.ProgressValue != 0 || state.Status != "active" || state.Extra["reward_state"] != "pending" ||
after.States[6689].Status != "completed" || after.Progress[6689].ProgressValue != 3 ||
after.States[6691].ProgressValue != -1 || after.States[6692].ProgressValue != 4 || len(after.States) != len(record.States) {
t.Fatalf("incorrect terminal or unrelated quest states: %+v", after)
}
// Accepting another quest after a commit must not make a replay count again.
after.States[6690] = dnfrepo.QuestState{Status: "active", ProgressValue: 3}
if err := repos.Quest.Save(ctx, after); err != nil {
t.Fatal(err)
}
if _, err := owner.ApplyPVPWin(ctx, catalog, "a", "16", "match4", at); err != nil {
t.Fatal(err)
}
after, _, _ = repos.Quest.Load(ctx, "16")
if after.States[6690].ProgressValue != 3 {
t.Fatal("old match advanced a newly accepted quest")
}
if _, err := owner.ApplyPVPWin(ctx, catalog, "other-account", "16", "match5", at); err == nil {
t.Fatal("wrong account accepted")
}
charAfter, _, _ = repos.Character.Load(ctx, "16")
if charAfter.Stats["exp"] != 555 || charAfter.Stats["pvp_rank_point"] != 1200 {
t.Fatal("quest progress modified character experience or RP")
}
}
@@ -0,0 +1,60 @@
package quest
import (
"context"
"os"
"sort"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
"longheng.io/server/internal/modules/dnf/worldmap"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// TestRuntimePVFHellChallengeDefinitions is an opt-in inspection and
// compatibility guard for the live PVF. It records every terminal quest that
// carries the native [hell challenge] reward instead of assuming one fixed
// quest ID or reward-int-data shape.
func TestRuntimePVFHellChallengeDefinitions(t *testing.T) {
path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if path == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to inspect live Hell Party challenge quests")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
definitions := make([]Definition, 0)
for _, definition := range catalog.Definitions() {
if normalizeQuestTag(definition.RewardType) == "hell challenge" {
definitions = append(definitions, definition)
}
}
sort.Slice(definitions, func(i, j int) bool { return definitions[i].ID < definitions[j].ID })
if len(definitions) == 0 {
t.Fatal("live PVF has no [hell challenge] quest definitions")
}
world, err := worldmap.LoadSource(context.Background(), archive, worldmap.Options{})
if err != nil {
t.Fatal(err)
}
for _, definition := range definitions {
areas := world.HellPartyAreasForQuest(definition.ID)
areaIDs := make([]int64, 0, len(areas))
for _, area := range areas {
areaIDs = append(areaIDs, area.ID)
}
t.Logf("hell challenge id=%d path=%s grade=%q type=%q int_data=%v check_count=%v condition=%v reward_int_data=%v main=%d prerequisites=%v area_ids=%v",
definition.ID, definition.Path, definition.Grade, definition.Type, definition.IntData,
definition.CheckCount, definition.ConditionData, definition.RewardIntData,
definition.MainQuestID, definition.PreRequiredGroups, areaIDs)
}
}
@@ -0,0 +1,135 @@
// 本文件解析任务类 C2S 请求体。
// C# QuestManager 会在 body 长度大于 2 时去掉前 2 字节 wire-type echo,再交给 QuestService。
package quest
import (
"encoding/binary"
"fmt"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
const (
CurrentFinishQuestRequestBodySize = 10
CurrentFinishQuestObservedTailMarker = ^uint16(0)
// CurrentSetTriggerRequestBodySize is the scalar op33 shape:
// echo | quest | add/remove | reserved-or-answer.
CurrentSetTriggerRequestBodySize = 6
// CurrentMaskedSetTriggerRequestBodySize is the current-client packed
// objective shape used by multi-lane tasks such as the Hell Party
// challenges: echo | quest | lane mask | add/remove | reserved.
CurrentMaskedSetTriggerRequestBodySize = 7
)
// QuestIDRequest 描述只携带 questId 的任务请求。
type QuestIDRequest struct {
QuestID uint16
}
// SetTriggerRequest 描述任务触发进度请求。
type SetTriggerRequest struct {
QuestID uint16
TriggerType byte
TriggerArgument byte
IsIncrement bool
}
// FinishQuestRequest 描述完成任务请求。
type FinishQuestRequest struct {
QuestID uint16
RewardSelectIndex uint16
HasRewardSelect bool
Multiplier uint16
Reserved uint16
}
// DecodeQuestIDRequest 解析接取/放弃任务请求。
func DecodeQuestIDRequest(body []byte) (QuestIDRequest, error) {
qBody := stripEcho(body)
if len(qBody) < 2 {
return QuestIDRequest{}, fmt.Errorf("body too short after echo strip: got %d want >= 2", len(qBody))
}
return QuestIDRequest{QuestID: binary.LittleEndian.Uint16(qBody)}, nil
}
// DecodeSetTriggerRequest parses both proven current-client op33 forms. The
// seven-byte form is not a transport trailer: its 0x10/0x20/0x40 mask selects
// one 9-bit lane of the packed quest trigger. Older six-byte masked clones are
// retained only as a compatibility input for an already-supported profile.
func DecodeSetTriggerRequest(body []byte) (SetTriggerRequest, error) {
if len(body) != CurrentSetTriggerRequestBodySize && len(body) != CurrentMaskedSetTriggerRequestBodySize {
return SetTriggerRequest{}, fmt.Errorf("set quest trigger body size: got %d want %d or %d", len(body), CurrentSetTriggerRequestBodySize, CurrentMaskedSetTriggerRequestBodySize)
}
if echo := binary.LittleEndian.Uint16(body[:2]); echo != uint16(dnfenum.CmdPacketSetQuestTrigger) {
return SetTriggerRequest{}, fmt.Errorf("set quest trigger echo: got %d want %d", echo, dnfenum.CmdPacketSetQuestTrigger)
}
request := SetTriggerRequest{QuestID: binary.LittleEndian.Uint16(body[2:4])}
if request.QuestID == 0 {
return SetTriggerRequest{}, fmt.Errorf("set quest trigger has zero quest ID")
}
if len(body) == CurrentMaskedSetTriggerRequestBodySize {
switch body[4] {
case 0x10, 0x20, 0x40:
request.TriggerType = body[4]
default:
return SetTriggerRequest{}, fmt.Errorf("set quest trigger mask: got %#x want 0x10, 0x20, or 0x40", body[4])
}
if body[5] > 1 {
return SetTriggerRequest{}, fmt.Errorf("set quest trigger add/remove: got %d want boolean", body[5])
}
if body[6] != 0 {
return SetTriggerRequest{}, fmt.Errorf("set quest trigger masked reserved: got %#x want 0", body[6])
}
request.IsIncrement = body[5] != 0
return request, nil
}
// Retain the established local six-byte clone unchanged. Its last two
// bytes are trigger type and trigger argument; several existing quest
// families (notably seeking) still use type 1 through that compatibility
// route. The current masked Hell Party form above is unambiguous because it
// is seven bytes.
request.TriggerType = body[4]
request.TriggerArgument = body[5]
request.IsIncrement = body[5] != 0
return request, nil
}
// DecodeFinishQuestRequest 解析完成任务请求。
func DecodeFinishQuestRequest(body []byte) (FinishQuestRequest, error) {
if len(body) != CurrentFinishQuestRequestBodySize {
return FinishQuestRequest{}, fmt.Errorf("current EXE finish-quest body size: got %d want %d", len(body), CurrentFinishQuestRequestBodySize)
}
if echo := binary.LittleEndian.Uint16(body[0:2]); echo != uint16(dnfenum.CmdPacketFinishQuest) {
return FinishQuestRequest{}, fmt.Errorf("current EXE finish-quest echo: got %d want %d", echo, dnfenum.CmdPacketFinishQuest)
}
req := FinishQuestRequest{
QuestID: binary.LittleEndian.Uint16(body[2:4]),
RewardSelectIndex: binary.LittleEndian.Uint16(body[4:6]),
Multiplier: binary.LittleEndian.Uint16(body[6:8]),
Reserved: binary.LittleEndian.Uint16(body[8:10]),
}
req.HasRewardSelect = req.RewardSelectIndex != ^uint16(0)
return req, nil
}
func stripEcho(body []byte) []byte {
if body == nil || len(body) <= 2 {
return body
}
return body[2:]
}
func (r QuestIDRequest) String() string {
return fmt.Sprintf("quest=%d", r.QuestID)
}
func (r SetTriggerRequest) String() string {
return fmt.Sprintf("quest=%d triggerType=%d argument=%d increment=%t", r.QuestID, r.TriggerType, r.TriggerArgument, r.IsIncrement)
}
func (r FinishQuestRequest) String() string {
return fmt.Sprintf("quest=%d rewardSelect=%d hasReward=%t multiplier=%d reserved=%d", r.QuestID, r.RewardSelectIndex, r.HasRewardSelect, r.Multiplier, r.Reserved)
}
+186
View File
@@ -0,0 +1,186 @@
package quest
import (
"fmt"
"math"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const ConnectQuestListPath = "n_quest/connectquestlist.etc"
// StoryEntry is one ordered task/dungeon pair declared by an [episode quest]
// unit in the runtime PVF. A zero dungeon ID is valid for a non-dungeon task.
type StoryEntry struct {
QuestID uint16
DungeonID uint32
}
// StoryEpisode is the PVF-owned presentation sequence for one main-story
// chapter. It carries metadata only; it does not override the active quest,
// dungeon-route, or completion state machines.
type StoryEpisode struct {
ParentKey int
WorldMapIndex int
LevelSection []int64
Name string
Entries []StoryEntry
}
// LoadStoryEpisodes reads the optional main-story episode directory. Callers
// must include ConnectQuestListPath in their PVF index build options.
func LoadStoryEpisodes(index *dnfpvf.Index) ([]StoryEpisode, error) {
if index == nil {
return nil, fmt.Errorf("story episode index is required")
}
document, found := index.Document(ConnectQuestListPath)
if !found || document == nil {
return nil, fmt.Errorf("story episode document %s is missing from PVF index", ConnectQuestListPath)
}
episodes := make([]StoryEpisode, 0)
for offset := 0; offset < len(document.Tokens); offset++ {
if !storySectionEquals(document.Tokens[offset], "connect quest unit") {
continue
}
end := storyUnitEnd(document.Tokens, offset+1)
if end < 0 {
return nil, fmt.Errorf("story episode unit %d is not closed", len(episodes))
}
episode, include, err := parseStoryEpisodeUnit(document.Tokens[offset+1 : end])
if err != nil {
return nil, fmt.Errorf("story episode unit %d: %w", len(episodes), err)
}
if include {
episodes = append(episodes, episode)
}
offset = end
}
if len(episodes) == 0 {
return nil, fmt.Errorf("story episode document %s has no episode quest units", ConnectQuestListPath)
}
return episodes, nil
}
// StoryEntriesFrom returns the requested task and the remaining entries in
// its own episode. It never crosses into a following episode.
func StoryEntriesFrom(episodes []StoryEpisode, questID uint16) ([]StoryEntry, bool) {
if questID == 0 {
return nil, false
}
for _, episode := range episodes {
for index, entry := range episode.Entries {
if entry.QuestID != questID {
continue
}
return append([]StoryEntry(nil), episode.Entries[index:]...), true
}
}
return nil, false
}
func StoryEpisodeName(episodes []StoryEpisode, questID uint16) string {
if questID == 0 {
return ""
}
for _, episode := range episodes {
for _, entry := range episode.Entries {
if entry.QuestID == questID {
return strings.TrimSpace(episode.Name)
}
}
}
return ""
}
func parseStoryEpisodeUnit(tokens []dnfpvf.Token) (StoryEpisode, bool, error) {
sections := storyUnitSections(tokens)
unitType, _ := storyFirstText(sections["type"])
if normalizeQuestTag(unitType) != "episode quest" {
return StoryEpisode{}, false, nil
}
parentKey, parentOK := storyFirstInt(sections["parent key"])
worldMapIndex, worldMapOK := storyFirstInt(sections["worldmap index"])
if !parentOK || !worldMapOK || parentKey <= 0 || parentKey > math.MaxInt || worldMapIndex < 0 || worldMapIndex > math.MaxInt {
return StoryEpisode{}, false, fmt.Errorf("invalid episode identity")
}
sequence := storyInts(sections["sequence"])
if len(sequence) == 0 || len(sequence)%2 != 0 {
return StoryEpisode{}, false, fmt.Errorf("invalid sequence length %d", len(sequence))
}
episode := StoryEpisode{
ParentKey: int(parentKey),
WorldMapIndex: int(worldMapIndex),
LevelSection: storyInts(sections["level section"]),
}
episode.Name, _ = storyFirstText(sections["name"])
for index := 0; index < len(sequence); index += 2 {
questID, dungeonID := sequence[index], sequence[index+1]
if questID <= 0 || questID > math.MaxUint16 || dungeonID < 0 || dungeonID > math.MaxUint32 {
return StoryEpisode{}, false, fmt.Errorf("invalid sequence pair %d/%d", questID, dungeonID)
}
episode.Entries = append(episode.Entries, StoryEntry{QuestID: uint16(questID), DungeonID: uint32(dungeonID)})
}
return episode, true, nil
}
func storyUnitEnd(tokens []dnfpvf.Token, start int) int {
for index := start; index < len(tokens); index++ {
if storySectionEquals(tokens[index], "/connect quest unit") {
return index
}
}
return -1
}
func storyUnitSections(tokens []dnfpvf.Token) map[string][]dnfpvf.Token {
sections := make(map[string][]dnfpvf.Token)
current := ""
for _, token := range tokens {
if token.Kind == dnfpvf.TokenSection {
current = normalizeQuestTag(token.Value)
continue
}
if current != "" {
sections[current] = append(sections[current], token)
}
}
return sections
}
func storySectionEquals(token dnfpvf.Token, value string) bool {
return token.Kind == dnfpvf.TokenSection && normalizeQuestTag(token.Value) == normalizeQuestTag(value)
}
func storyFirstText(tokens []dnfpvf.Token) (string, bool) {
for _, token := range tokens {
if token.Kind != dnfpvf.TokenString && token.Kind != dnfpvf.TokenIdent {
continue
}
value := strings.TrimSpace(token.Value)
if value != "" {
return value, true
}
}
return "", false
}
func storyFirstInt(tokens []dnfpvf.Token) (int64, bool) {
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
return token.Int, true
}
}
return 0, false
}
func storyInts(tokens []dnfpvf.Token) []int64 {
result := make([]int64, 0, len(tokens))
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
result = append(result, token.Int)
}
}
return result
}
@@ -0,0 +1,53 @@
package quest
import (
"context"
"reflect"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
func TestLoadStoryEpisodesUsesEpisodeLocalSequence(t *testing.T) {
index, err := dnfpvf.Build(context.Background(), catalogTestSource{
ConnectQuestListPath: "[connect quest unit]\n" +
"[type]\n`episode quest`\n[parent key]\n7\n[worldmap index]\n7\n" +
"[level section]\n46 0\n[name]\n`first chapter`\n" +
"[sequence]\n3367 7123 3368 0\n[/sequence]\n[/connect quest unit]\n" +
"[connect quest unit]\n[type]\n`episode quest`\n[parent key]\n8\n" +
"[worldmap index]\n21\n[name]\n`second chapter`\n" +
"[sequence]\n3427 0 3431 144\n[/sequence]\n[/connect quest unit]",
}, dnfpvf.BuildOptions{Paths: []string{ConnectQuestListPath}})
if err != nil {
t.Fatal(err)
}
episodes, err := LoadStoryEpisodes(index)
if err != nil {
t.Fatal(err)
}
if len(episodes) != 2 || episodes[1].Name != "second chapter" || episodes[1].ParentKey != 8 {
t.Fatalf("episodes=%+v", episodes)
}
if got, ok := StoryEntriesFrom(episodes, 3427); !ok || !reflect.DeepEqual(got, []StoryEntry{{QuestID: 3427}, {QuestID: 3431, DungeonID: 144}}) {
t.Fatalf("second episode entries=%+v found=%t", got, ok)
}
if got, ok := StoryEntriesFrom(episodes, 3368); !ok || !reflect.DeepEqual(got, []StoryEntry{{QuestID: 3368}}) {
t.Fatalf("first episode tail=%+v found=%t", got, ok)
}
if name := StoryEpisodeName(episodes, 3431); name != "second chapter" {
t.Fatalf("episode name=%q", name)
}
}
func TestLoadStoryEpisodesRejectsMalformedEpisodeUnits(t *testing.T) {
index, err := dnfpvf.Build(context.Background(), catalogTestSource{
ConnectQuestListPath: "[connect quest unit]\n[type]\n`episode quest`\n[parent key]\n7\n" +
"[worldmap index]\n7\n[sequence]\n3367\n[/sequence]\n[/connect quest unit]",
}, dnfpvf.BuildOptions{Paths: []string{ConnectQuestListPath}})
if err != nil {
t.Fatal(err)
}
if _, err := LoadStoryEpisodes(index); err == nil {
t.Fatal("malformed episode sequence was accepted")
}
}
@@ -0,0 +1,82 @@
package quest
import (
"context"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type LinkedSubQuestActivationPersistResult struct {
CharacterID string
Activations []LinkedSubQuestActivation
ChangedFields []dnfrepo.QuestField
Idempotent bool
}
func (o *Owner) ApplyActiveQuestClearLinkedSubQuestActivation(
ctx context.Context,
catalog *Catalog,
characterID string,
activatedAt time.Time,
) (LinkedSubQuestActivationPersistResult, error) {
if o == nil || o.characters == nil || o.quests == nil {
return LinkedSubQuestActivationPersistResult{}, ErrOwnerUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return LinkedSubQuestActivationPersistResult{}, ErrCharacterRequired
}
if _, ok, err := o.characters.Load(ctx, characterID); err != nil {
return LinkedSubQuestActivationPersistResult{}, err
} else if !ok {
return LinkedSubQuestActivationPersistResult{}, ErrCharacterNotFound
}
record, ok, err := o.quests.Load(ctx, characterID)
if err != nil {
return LinkedSubQuestActivationPersistResult{}, err
}
if !ok {
return LinkedSubQuestActivationPersistResult{CharacterID: characterID, Idempotent: true}, nil
}
record = dnfrepo.CloneQuest(record)
if strings.TrimSpace(record.CharacterID) == "" {
record.CharacterID = characterID
}
plan, err := catalog.PlanActiveQuestClearLinkedSubQuestActivation(record, activatedAt)
if err != nil {
return LinkedSubQuestActivationPersistResult{}, err
}
if strings.TrimSpace(plan.Record.CharacterID) != characterID {
return LinkedSubQuestActivationPersistResult{}, ErrQuestPersistVerify
}
result := LinkedSubQuestActivationPersistResult{
CharacterID: characterID,
Activations: append([]LinkedSubQuestActivation(nil), plan.Activations...),
ChangedFields: append([]dnfrepo.QuestField(nil), plan.ChangedFields...),
Idempotent: len(plan.ChangedFields) == 0,
}
if len(plan.ChangedFields) == 0 {
return result, nil
}
if err := dnfrepo.SaveQuestFields(ctx, o.quests, plan.Record, plan.ChangedFields...); err != nil {
return LinkedSubQuestActivationPersistResult{}, err
}
persisted, ok, err := o.quests.Load(ctx, characterID)
if err != nil {
return LinkedSubQuestActivationPersistResult{}, err
}
if !ok {
return LinkedSubQuestActivationPersistResult{}, ErrQuestPersistVerify
}
for _, activation := range result.Activations {
state, known := questStateFor(persisted, activation.QuestID)
if !known || !isActiveQuestStatus(state.Status) || state.ProgressValue != int64(activation.InitTrigger) ||
state.Extra["main_quest_id"] != int64Text(activation.ParentID) ||
state.Extra["auto_activated_by_main_quest"] != "true" {
return LinkedSubQuestActivationPersistResult{}, ErrQuestPersistVerify
}
}
return result, nil
}