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