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

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

1118 lines
38 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package quest
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrOwnerUnavailable = errors.New("quest owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrQuestIDRequired = errors.New("quest id required")
ErrCharacterNotFound = errors.New("character record not found")
ErrQuestPersistVerify = errors.New("persisted quest state verification failed")
ErrQuestNotActive = errors.New("quest is not active")
ErrQuestAnswerInvalid = errors.New("question quest answer is invalid")
ErrQuestCannotGiveUp = errors.New("quest cannot be given up")
ErrAcceptSettlementUnavailable = errors.New("quest accept settlement is unavailable")
ErrAcceptInventoryUnavailable = errors.New("quest accept inventory is unavailable")
ErrAcceptItemAllocatorRequired = errors.New("quest accept item allocator is required")
)
type Owner struct {
repositories dnfrepo.Group
characters dnfrepo.CharacterRepository
quests dnfrepo.QuestRepository
inventory dnfrepo.InventoryRepository
accountInventory dnfrepo.AccountInventoryRepository
}
type PlanResult struct {
AccountID string
CharacterID string
Operation string
QuestID int64
Known bool
// StateChanged is true only after ApplySetTrigger has durably updated the
// active quest state. A valid replay at the already-persisted state is not
// a new client-success boundary.
StateChanged bool
Status string
TriggerType byte
QuestionAnswer byte
HasQuestionAnswer bool
ProgressValue int64
RewardSelectIndex int64
HasRewardSelect bool
Multiplier int64
}
type AcceptResult struct {
AccountID string
CharacterID string
QuestID uint16
InitTrigger uint32
Idempotent bool
PVFPath string
QuestType string
EventItems []FinishCommittedItem
}
type GiveUpResult struct {
AccountID string
CharacterID string
QuestID uint16
// Reclaimed 是本次真实扣减的任务专属道具。普通材料(`[material]` 等)
// 永远不会出现在这里:它们只在任务交付时被扣。
Reclaimed []GiveUpReclaimedItem
// ReclaimSkipped 表示有计划要回收、但没有执行(事务单元或背包不可用)。
// 放弃本身仍然成功;调用方应当把 SkipReason 记进日志。
ReclaimSkipped bool
ReclaimSkipReason string
// PostCommitInventory 只在真的扣了道具时给出,供桥接层重建客户端行。
PostCommitInventory dnfrepo.InventoryRecord
HasPostCommitInventory bool
}
// GiveUpInput 是"放弃任务"的提交输入。
type GiveUpInput struct {
AccountID string
SelectedCharacterID uint16
QuestID uint16
// IsQuestItem 判定某道具是否属于 PVF `[quest]` 家族(任务专属、落在任务栏
// 177..232 且不可交易)。nil 表示"没有任何任务专属道具":放弃照常成功,
// 只是不回收任何东西。
IsQuestItem func(int64) bool
// CommittedAt 非零时作为任务记录的更新时间,便于测试与重放对齐。
CommittedAt time.Time
}
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
if repos.Character == nil || repos.Quest == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{
repositories: repos, characters: repos.Character, quests: repos.Quest,
inventory: repos.Inventory, accountInventory: repos.AccountInventory,
}, nil
}
func (o *Owner) Plan(ctx context.Context, cmd Command) (PlanResult, error) {
result, _, _, err := o.planWithQuestRecord(ctx, cmd)
return result, err
}
func (o *Owner) planWithQuestRecord(ctx context.Context, cmd Command) (PlanResult, dnfrepo.QuestRecord, bool, error) {
if o == nil || o.characters == nil || o.quests == nil {
return PlanResult{}, dnfrepo.QuestRecord{}, false, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return PlanResult{}, dnfrepo.QuestRecord{}, false, ErrCharacterRequired
}
if cmd.QuestID == 0 {
return PlanResult{}, dnfrepo.QuestRecord{}, false, ErrQuestIDRequired
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
character, ok, err := o.characters.Load(ctx, characterID)
if err != nil {
return PlanResult{}, dnfrepo.QuestRecord{}, false, err
}
if !ok {
return PlanResult{}, dnfrepo.QuestRecord{}, false, ErrCharacterNotFound
}
character = dnfrepo.CloneCharacter(character)
record, ok, err := o.quests.Load(ctx, characterID)
if err != nil {
return PlanResult{}, dnfrepo.QuestRecord{}, false, err
}
state, known := questStateFor(record, int64(cmd.QuestID))
result := PlanResult{
AccountID: character.AccountID,
CharacterID: characterID,
Operation: cmd.Operation,
QuestID: int64(cmd.QuestID),
Known: ok && known,
Status: state.Status,
TriggerType: state.TriggerType,
ProgressValue: state.ProgressValue,
RewardSelectIndex: int64(cmd.RewardSelectIndex),
HasRewardSelect: cmd.HasRewardSelect,
Multiplier: int64(cmd.Multiplier),
}
if result.Multiplier == 0 {
result.Multiplier = 1
}
if result.Status == "" {
result.Status = "unknown"
}
if cmd.Operation == "set_quest_trigger" {
result.TriggerType = cmd.TriggerType
}
return result, record, ok, nil
}
// ApplyAccept preserves the no-item caller API. Event-item quests require
// ApplyAcceptWithItems so the caller supplies the current-PVF slot allocator.
func (o *Owner) ApplyAccept(ctx context.Context, catalog *Catalog, eligibility CharacterEligibility, cmd Command) (AcceptResult, error) {
return o.applyAccept(ctx, catalog, eligibility, cmd, nil)
}
// ApplyAcceptWithItems atomically commits an accepted quest and every PVF
// [depend give item] grant. The allocator mutates only the transaction-scoped
// inventory clone; the owner controls both repository writes and rollback.
func (o *Owner) ApplyAcceptWithItems(
ctx context.Context,
catalog *Catalog,
eligibility CharacterEligibility,
cmd Command,
allocateItem FinishItemAllocator,
) (AcceptResult, error) {
return o.applyAccept(ctx, catalog, eligibility, cmd, allocateItem)
}
func (o *Owner) applyAccept(
ctx context.Context,
catalog *Catalog,
eligibility CharacterEligibility,
cmd Command,
allocateItem FinishItemAllocator,
) (AcceptResult, error) {
if o == nil || o.characters == nil || o.quests == nil {
return AcceptResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return AcceptResult{}, ErrCharacterRequired
}
if cmd.QuestID == 0 {
return AcceptResult{}, ErrQuestIDRequired
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
character, ok, err := o.characters.Load(ctx, characterID)
if err != nil {
return AcceptResult{}, err
}
if !ok {
return AcceptResult{}, ErrCharacterNotFound
}
record, hasRecord, err := o.quests.Load(ctx, characterID)
if err != nil {
return AcceptResult{}, err
}
if !hasRecord {
record = dnfrepo.QuestRecord{CharacterID: characterID}
} else {
record = dnfrepo.CloneQuest(record)
}
definition, definitionKnown := catalog.Find(int64(cmd.QuestID))
if !definitionKnown {
return AcceptResult{}, ErrQuestDefinitionMissing
}
eligibility.Wedding = WeddingEligible(character)
if normalizeQuestTag(definition.Grade) == "wedding" && !eligibility.Wedding {
return AcceptResult{}, ErrQuestNotAcceptable
}
if definition.HasDependGiveItem {
return o.applyAcceptEventItemSettlement(ctx, catalog, eligibility, cmd, allocateItem)
}
if existing, known := questStateFor(record, int64(cmd.QuestID)); known && isActiveQuestStatus(existing.Status) {
if existing.ProgressValue < 0 || existing.ProgressValue > int64(^uint32(0)) {
return AcceptResult{}, ErrQuestPersistVerify
}
return AcceptResult{
AccountID: character.AccountID,
CharacterID: characterID,
QuestID: cmd.QuestID,
InitTrigger: uint32(existing.ProgressValue),
Idempotent: true,
PVFPath: definition.Path,
QuestType: definition.Type,
}, nil
}
plan, err := catalog.PlanAccept(eligibility, record, int64(cmd.QuestID))
if err != nil {
return AcceptResult{}, err
}
applyAcceptPlan(&record, plan, definition, characterID, time.Now())
// SaveFields is one atomic SQL upsert for the complete states JSON column.
// Event-item quests use applyAcceptEventItemSettlement above because their
// inventory and quest rows require the wider character transaction.
if err := dnfrepo.SaveQuestFields(ctx, o.quests, record, dnfrepo.QuestFieldStates); err != nil {
return AcceptResult{}, err
}
persisted, ok, err := o.quests.Load(ctx, characterID)
if err != nil {
return AcceptResult{}, err
}
state, known := questStateFor(persisted, int64(cmd.QuestID))
if !ok || !known || !isActiveQuestStatus(state.Status) || state.ProgressValue != int64(plan.InitTrigger) {
return AcceptResult{}, ErrQuestPersistVerify
}
for _, linked := range plan.LinkedSubQuests {
state, known := questStateFor(persisted, linked.QuestID)
if !known || !isActiveQuestStatus(state.Status) || state.ProgressValue != int64(linked.InitTrigger) {
return AcceptResult{}, ErrQuestPersistVerify
}
}
return AcceptResult{
AccountID: character.AccountID,
CharacterID: characterID,
QuestID: cmd.QuestID,
InitTrigger: plan.InitTrigger,
PVFPath: plan.Path,
QuestType: plan.Type,
}, nil
}
func (o *Owner) applyAcceptEventItemSettlement(
ctx context.Context,
catalog *Catalog,
eligibility CharacterEligibility,
cmd Command,
allocateItem FinishItemAllocator,
) (AcceptResult, error) {
if o == nil || o.repositories.CharacterSettlement == nil {
return AcceptResult{}, ErrAcceptSettlementUnavailable
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
var candidate AcceptResult
err := o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Quest == nil || tx.Inventory == nil {
return ErrAcceptSettlementUnavailable
}
character, found, err := tx.Character.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(character.CharacterID) != characterID ||
(strings.TrimSpace(cmd.AccountID) != "" && strings.TrimSpace(character.AccountID) != strings.TrimSpace(cmd.AccountID)) {
return ErrCharacterNotFound
}
record, found, err := tx.Quest.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
record = dnfrepo.QuestRecord{CharacterID: characterID}
} else {
record = dnfrepo.CloneQuest(record)
}
definition, known := catalog.Find(int64(cmd.QuestID))
if !known {
return ErrQuestDefinitionMissing
}
eligibility.Wedding = WeddingEligible(character)
if normalizeQuestTag(definition.Grade) == "wedding" && !eligibility.Wedding {
return ErrQuestNotAcceptable
}
if existing, known := questStateFor(record, int64(cmd.QuestID)); known && isActiveQuestStatus(existing.Status) {
if existing.ProgressValue < 0 || existing.ProgressValue > int64(^uint32(0)) {
return ErrQuestPersistVerify
}
candidate = AcceptResult{
AccountID: character.AccountID, CharacterID: characterID, QuestID: cmd.QuestID,
InitTrigger: uint32(existing.ProgressValue), Idempotent: true,
PVFPath: definition.Path, QuestType: definition.Type,
}
return nil
}
plan, err := catalog.PlanAccept(eligibility, record, int64(cmd.QuestID))
if err != nil {
return err
}
if len(plan.EventItems) == 0 {
return fmt.Errorf("%w: quest=%d has depend give item without grants", ErrQuestRewardMalformed, cmd.QuestID)
}
if allocateItem == nil {
return ErrAcceptItemAllocatorRequired
}
inventory, found, err := tx.Inventory.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
return ErrAcceptInventoryUnavailable
}
inventory = dnfrepo.CloneInventory(inventory)
now := time.Now().UTC()
completionKey := fmt.Sprintf("quest-accept/%s/%d/%d", characterID, cmd.QuestID, now.UnixNano())
items, err := allocateFinishRewardItems(&inventory, plan.EventItems, allocateItem, FinishItemGrantRequest{
QuestID: int64(cmd.QuestID), CompletionKey: completionKey,
Source: "quest_pvf_accept_event_item",
})
if err != nil {
return err
}
applyAcceptPlan(&record, plan, definition, characterID, now)
inventory.CharacterID = characterID
inventory.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
if err := dnfrepo.SaveQuestFields(ctx, tx.Quest, record, dnfrepo.QuestFieldStates); err != nil {
return err
}
persistedQuest, questFound, err := tx.Quest.Load(ctx, characterID)
if err != nil {
return err
}
state, stateKnown := questStateFor(persistedQuest, int64(cmd.QuestID))
if !questFound || !stateKnown || !isActiveQuestStatus(state.Status) || state.ProgressValue != int64(plan.InitTrigger) {
return ErrQuestPersistVerify
}
for _, linked := range plan.LinkedSubQuests {
linkedState, known := questStateFor(persistedQuest, linked.QuestID)
if !known || !isActiveQuestStatus(linkedState.Status) || linkedState.ProgressValue != int64(linked.InitTrigger) {
return ErrQuestPersistVerify
}
}
persistedInventory, inventoryFound, err := tx.Inventory.Load(ctx, characterID)
if err != nil {
return err
}
if !inventoryFound {
return ErrAcceptInventoryUnavailable
}
for _, item := range items {
stack, exists := persistedInventory.Slots[item.SlotKey]
if !exists || stack.ItemID != item.ItemID || stack.Count != item.PostCount {
return ErrQuestPersistVerify
}
}
candidate = AcceptResult{
AccountID: character.AccountID, CharacterID: characterID, QuestID: cmd.QuestID,
InitTrigger: plan.InitTrigger, PVFPath: plan.Path, QuestType: plan.Type,
EventItems: cloneFinishCommittedItems(items),
}
return nil
})
if err != nil {
return AcceptResult{}, err
}
return candidate, nil
}
func applyAcceptPlan(record *dnfrepo.QuestRecord, plan AcceptPlan, definition Definition, characterID string, now time.Time) {
if record.States == nil {
record.States = make(map[int64]dnfrepo.QuestState, 1+len(plan.LinkedSubQuests))
}
record.States[plan.QuestID] = dnfrepo.QuestState{
Status: "active", ProgressValue: int64(plan.InitTrigger), UpdatedAt: now,
Extra: acceptedQuestStateExtra(plan, definition),
}
for _, linked := range plan.LinkedSubQuests {
record.States[linked.QuestID] = dnfrepo.QuestState{
Status: "active", ProgressValue: int64(linked.InitTrigger), UpdatedAt: now,
Extra: map[string]string{
"pvf_path": linked.Path, "quest_type": linked.Type,
"main_quest_id": strconv.FormatInt(plan.QuestID, 10),
"auto_activated_by_main_quest": "true",
},
}
}
record.CharacterID = characterID
record.UpdatedAt = now
}
func cloneFinishCommittedItems(items []FinishCommittedItem) []FinishCommittedItem {
if len(items) == 0 {
return nil
}
out := make([]FinishCommittedItem, len(items))
copy(out, items)
for index := range out {
out[index].RawEntry = append([]byte(nil), out[index].RawEntry...)
}
return out
}
func isActiveQuestStatus(status string) bool {
switch normalizeStatus(status) {
case "active", "accepted", "inprogress", "progress":
return true
default:
return false
}
}
// ApplyGiveUp 保持旧签名:等价于"没有任务专属道具"的放弃(GM 与既有调用点)。
func (o *Owner) ApplyGiveUp(ctx context.Context, catalog *Catalog, cmd Command) (GiveUpResult, error) {
return o.ApplyGiveUpSettlement(ctx, catalog, GiveUpInput{
AccountID: cmd.AccountID,
SelectedCharacterID: cmd.SelectedCharacterID,
QuestID: cmd.QuestID,
})
}
// ApplyGiveUpSettlement 放弃一个进行中的任务。
//
// 语义对齐参考端 QuestGiveupApplicationService / QuestGiveupItemRecoveryPolicy:
//
// - 只有 PVF 明写 `[cant giveup]` 才拒绝;收集类(`[seeking]` / `[get item]` /
// `[seek n meet npc]`)**不再**被一刀切拒绝 —— 玩家随时可以放弃,
// 见 2026-09-22 角色 1111 的日常任务 8557(普通材料,回收费名单为空)。
// - 任务专属道具的回收与"删除任务状态"在同一个 CharacterSettlement 事务里提交,
// 任一步失败整体回滚;普通材料(`[material]` 等)永不回收。
// - 没有可用事务单元时退回旧路径:只删任务状态、不回收,并置 ReclaimSkipped。
// 放弃本身永远不会因为"回收不可用"而被拒绝。
func (o *Owner) ApplyGiveUpSettlement(ctx context.Context, catalog *Catalog, input GiveUpInput) (GiveUpResult, error) {
if o == nil || o.characters == nil || o.quests == nil {
return GiveUpResult{}, ErrOwnerUnavailable
}
if input.SelectedCharacterID == 0 {
return GiveUpResult{}, ErrCharacterRequired
}
if input.QuestID == 0 {
return GiveUpResult{}, ErrQuestIDRequired
}
if catalog == nil {
return GiveUpResult{}, ErrQuestDefinitionMissing
}
if _, known := catalog.Find(int64(input.QuestID)); !known {
return GiveUpResult{}, ErrQuestDefinitionMissing
}
characterID := strconv.FormatUint(uint64(input.SelectedCharacterID), 10)
settlements := o.repositories.CharacterSettlement
if settlements == nil {
return o.commitGiveUp(ctx, o.repositories, catalog, characterID, input, false, "character_settlement_transaction_unavailable")
}
var result GiveUpResult
err := settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
committed, commitErr := o.commitGiveUp(ctx, tx, catalog, characterID, input, true, "")
if commitErr != nil {
return commitErr
}
result = committed
return nil
})
if err != nil {
if errors.Is(err, dnfrepo.ErrCharacterSettlementTransactionUnavailable) {
// 事务单元存在但这次用不了:退回"只删任务状态"的旧路径,
// 而不是把玩家的放弃请求变成静默失败。
return o.commitGiveUp(ctx, o.repositories, catalog, characterID, input, false,
"character_settlement_transaction_unavailable")
}
return GiveUpResult{}, err
}
return result, nil
}
// commitGiveUp 在给定仓储视图里完成一次放弃:删任务状态 +(可选)回收任务专属道具。
// 同一个函数同时服务事务路径与降级路径,避免两条实现漂移。
func (o *Owner) commitGiveUp(
ctx context.Context,
group dnfrepo.Group,
catalog *Catalog,
characterID string,
input GiveUpInput,
allowReclaim bool,
reclaimSkipReason string,
) (GiveUpResult, error) {
if group.Character == nil || group.Quest == nil {
return GiveUpResult{}, ErrOwnerUnavailable
}
definition, known := catalog.Find(int64(input.QuestID))
if !known {
return GiveUpResult{}, ErrQuestDefinitionMissing
}
if definition.CantGiveUp {
return GiveUpResult{}, ErrQuestCannotGiveUp
}
character, found, err := group.Character.Load(ctx, characterID)
if err != nil {
return GiveUpResult{}, err
}
if !found {
return GiveUpResult{}, ErrCharacterNotFound
}
record, found, err := group.Quest.Load(ctx, characterID)
if err != nil {
return GiveUpResult{}, err
}
if !found {
return GiveUpResult{}, ErrQuestNotActive
}
record = dnfrepo.CloneQuest(record)
questID := int64(input.QuestID)
state, known := questStateFor(record, questID)
if !known || !isActiveQuestStatus(state.Status) {
return GiveUpResult{}, ErrQuestNotActive
}
// 回收计划必须在删除任务状态之前算:它要读"其它进行中任务"的需求量。
plan := PlanGiveUpReclaim(catalog, record, questID, input.IsQuestItem)
changedFields := make([]dnfrepo.QuestField, 0, 2)
if _, exists := record.States[questID]; exists {
delete(record.States, questID)
changedFields = append(changedFields, dnfrepo.QuestFieldStates)
}
if _, exists := record.Progress[questID]; exists {
delete(record.Progress, questID)
changedFields = append(changedFields, dnfrepo.QuestFieldProgress)
}
if len(changedFields) == 0 {
return GiveUpResult{}, ErrQuestNotActive
}
result := GiveUpResult{
AccountID: character.AccountID,
CharacterID: characterID,
QuestID: input.QuestID,
}
var inventory dnfrepo.InventoryRecord
inventoryMutated := false
switch {
case len(plan) == 0:
// 没有任务专属道具(例如 8557 这种只要普通材料的日常):只删任务状态。
case !allowReclaim, group.Inventory == nil:
if reclaimSkipReason == "" {
reclaimSkipReason = "inventory_repository_unavailable"
}
result.ReclaimSkipped = true
result.ReclaimSkipReason = reclaimSkipReason
default:
loaded, inventoryFound, loadErr := group.Inventory.Load(ctx, characterID)
if loadErr != nil {
return GiveUpResult{}, loadErr
}
if !inventoryFound {
result.ReclaimSkipped = true
result.ReclaimSkipReason = "inventory_record_missing"
break
}
inventory = dnfrepo.CloneInventory(loaded)
// 事务内的 scoped 仓储会校验记录归属键(mysql/memory 都要求
// record.CharacterID == 本次事务的角色),这里显式对齐,避免历史行
// 该字段为空时把一次正常的放弃变成失败。
inventory.CharacterID = characterID
reclaimed := applyGiveUpReclaim(&inventory, plan)
if len(reclaimed) == 0 {
break
}
result.Reclaimed = reclaimed
inventoryMutated = true
result.PostCommitInventory = dnfrepo.CloneInventory(inventory)
result.HasPostCommitInventory = true
}
record.CharacterID = characterID
record.UpdatedAt = time.Now()
if !input.CommittedAt.IsZero() {
record.UpdatedAt = input.CommittedAt
}
if err := dnfrepo.SaveQuestFields(ctx, group.Quest, record, changedFields...); err != nil {
return GiveUpResult{}, err
}
if inventoryMutated {
if err := dnfrepo.SaveInventoryFields(ctx, group.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return GiveUpResult{}, err
}
}
persisted, found, err := group.Quest.Load(ctx, characterID)
if err != nil {
return GiveUpResult{}, err
}
if !found {
return GiveUpResult{}, ErrQuestPersistVerify
}
if persistedState, exists := questStateFor(persisted, questID); exists && isActiveQuestStatus(persistedState.Status) {
return GiveUpResult{}, ErrQuestPersistVerify
}
return result, nil
}
// ApplySetTrigger applies trigger arithmetic when no PVF definition is
// available. Runtime handlers should use ApplySetTriggerWithCatalog so
// inventory-backed compound objectives can be resolved from authoritative PVF.
func (o *Owner) ApplySetTrigger(ctx context.Context, cmd Command) (PlanResult, error) {
return o.applySetTrigger(ctx, nil, nil, cmd)
}
// ApplySetTriggerWithCatalog resolves PVF-backed compound objectives before
// falling back to the ordinary packed-channel arithmetic.
func (o *Owner) ApplySetTriggerWithCatalog(ctx context.Context, catalog *Catalog, cmd Command) (PlanResult, error) {
var definition *Definition
if catalog != nil {
if candidate, ok := catalog.Find(int64(cmd.QuestID)); ok {
definition = &candidate
}
}
return o.applySetTrigger(ctx, catalog, definition, cmd)
}
func (o *Owner) applySetTrigger(ctx context.Context, catalog *Catalog, definition *Definition, cmd Command) (PlanResult, error) {
result, record, ok, err := o.planWithQuestRecord(ctx, cmd)
if err != nil {
return PlanResult{}, err
}
if cmd.Operation != "set_quest_trigger" || !result.Known || !isActiveQuestStatus(result.Status) {
return result, nil
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
if !ok {
return result, nil
}
record = dnfrepo.CloneQuest(record)
state, field, known := mutableQuestState(&record, int64(cmd.QuestID))
if !known {
return result, nil
}
now := time.Now()
raidPhaseDayExtraKey := ""
if definition != nil && normalizeQuestTag(definition.Type) == "raid phase clear" {
channel, allowed := raidPhaseClearTriggerChannel(*definition, cmd.TriggerType, cmd.IsIncrement)
if !allowed {
return result, nil
}
raidPhaseDayExtraKey = fmt.Sprintf("raid_phase_clear_day_%d", channel)
if state.Extra[raidPhaseDayExtraKey] == raidPhaseClearCalendarDay(now) {
return result, nil
}
}
oldProgress := state.ProgressValue
questionAnswer, questionAnswerOK := validQuestionAnswer(catalog, definition, cmd.TriggerArgument)
if definition != nil && normalizeQuestTag(definition.Type) == "question" && !questionAnswerOK {
return PlanResult{}, fmt.Errorf("%w: quest=%d answer=%d", ErrQuestAnswerInvalid, definition.ID, cmd.TriggerArgument)
}
newProgress := int64(0)
if !questionAnswerOK {
newProgress, err = o.nextTriggerProgress(ctx, characterID, result.AccountID, state, definition, cmd)
}
if err != nil {
return PlanResult{}, err
}
newProgress = clampQuestProgress(newProgress)
oldAnswer := strings.TrimSpace(state.Extra[questAnswerExtraKey])
wantAnswer := ""
if questionAnswerOK {
wantAnswer = strconv.Itoa(int(questionAnswer))
}
if newProgress == oldProgress && oldAnswer == wantAnswer {
result.ProgressValue = newProgress
return result, nil
}
state.TriggerType = cmd.TriggerType
state.ProgressValue = newProgress
state.UpdatedAt = now
if state.Extra == nil {
state.Extra = make(map[string]string, 2)
}
if raidPhaseDayExtraKey != "" {
state.Extra[raidPhaseDayExtraKey] = raidPhaseClearCalendarDay(now)
}
state.Extra["last_set_trigger_type"] = strconv.Itoa(int(cmd.TriggerType))
state.Extra["last_set_trigger_argument"] = strconv.Itoa(int(cmd.TriggerArgument))
state.Extra["last_set_trigger_increment"] = strconv.FormatBool(cmd.IsIncrement)
if questionAnswerOK {
state.Extra[questAnswerExtraKey] = wantAnswer
result.QuestionAnswer = questionAnswer
result.HasQuestionAnswer = true
}
switch field {
case dnfrepo.QuestFieldStates:
record.States[int64(cmd.QuestID)] = state
case dnfrepo.QuestFieldProgress:
record.Progress[int64(cmd.QuestID)] = state
}
record.UpdatedAt = now
var persistErr error
if writer, supported := o.quests.(dnfrepo.QuestStateEntryWriter); supported {
persistErr = writer.SaveQuestStateEntry(
ctx, characterID, field, int64(cmd.QuestID), state, record.UpdatedAt,
)
} else {
persistErr = dnfrepo.SaveQuestFields(ctx, o.quests, record, field)
}
if persistErr != nil {
return PlanResult{}, persistErr
}
result.StateChanged = true
result.TriggerType = state.TriggerType
result.ProgressValue = state.ProgressValue
return result, nil
}
func validQuestionAnswer(catalog *Catalog, definition *Definition, answer byte) (byte, bool) {
if catalog == nil || definition == nil || normalizeQuestTag(definition.Type) != "question" {
return 0, false
}
for _, candidate := range catalog.Successors(definition.ID) {
for _, requirement := range candidate.PreRequiredAnswers {
if requirement.QuestID == definition.ID && requirement.Answer == int64(answer) {
return answer, true
}
}
}
return 0, false
}
func questStateFor(record dnfrepo.QuestRecord, questID int64) (dnfrepo.QuestState, bool) {
if state, ok := record.States[questID]; ok {
return state, true
}
if state, ok := record.Progress[questID]; ok {
return state, true
}
return dnfrepo.QuestState{}, false
}
func mutableQuestState(record *dnfrepo.QuestRecord, questID int64) (dnfrepo.QuestState, dnfrepo.QuestField, bool) {
if record == nil {
return dnfrepo.QuestState{}, "", false
}
if state, ok := record.States[questID]; ok {
return state, dnfrepo.QuestFieldStates, true
}
if state, ok := record.Progress[questID]; ok {
return state, dnfrepo.QuestFieldProgress, true
}
return dnfrepo.QuestState{}, "", false
}
func (o *Owner) nextTriggerProgress(
ctx context.Context,
characterID string,
accountID string,
state dnfrepo.QuestState,
definition *Definition,
cmd Command,
) (int64, error) {
if definition != nil && normalizeQuestTag(definition.Type) == "seek n meet npc" &&
cmd.TriggerType == 0x10 && !cmd.IsIncrement {
return o.seekAndMeetNPCItemProgress(ctx, characterID, accountID, state.ProgressValue, *definition)
}
if cmd.TriggerType == 1 {
itemIDs := parseSeekingItemIDs(state.Extra)
counts := parseSeekingItemCounts(state.Extra, len(itemIDs))
if len(itemIDs) == 0 {
itemIDs, counts = seekingRequirementItems(definition)
}
if len(itemIDs) > 0 && o.inventory != nil {
return seekingMissingMask(ctx, o.inventory, characterID, itemIDs, counts), nil
}
}
if channel, ok := setTriggerPackedChannel(cmd.TriggerType); ok {
if state.ProgressValue < 0 || state.ProgressValue > int64(^uint32(0)) {
return state.ProgressValue, nil
}
progress := uint32(state.ProgressValue)
if cmd.IsIncrement {
return int64(incrementPackedTriggerChannel(progress, channel)), nil
}
return int64(decrementPackedTriggerChannel(progress, channel)), nil
}
if cmd.IsIncrement {
return state.ProgressValue + 1, nil
}
// [condition under clear] 的进度由服务端在通关结算 phase A 权威地减 1
// (见 clear_map.go 的 decrementClearTriggerChannel),而客户端在每次通关后也会
// 发一条同样的标量 op33 通知。这里必须原样回权威值、**不再动进度**:
// 否则一次通关被减 2 次甚至直接清零。
//
// 实测(2026-10-04 婚约任务 20737):PVF 是 [sub type] 22 + [int data] `-1 -1 5`,
// 即"通关 5 次"。接取时 init_trigger=5 正确,但通关 1 次后 phase A 写 4、紧接着
// 这条标量通知把它清成 0,任务当场"完成",客户端显示 5/5。
if definition != nil && normalizeQuestTag(definition.Type) == "condition under clear" {
return state.ProgressValue, nil
}
// The scalar op33 shape (no lane mask) is the client's "this objective is
// satisfied" notification, not a one-unit counter tick. Live 2026-09-12:
// quest 2900 (n_quest/hell/achievement_86_hell_quake_1) held the PVF initial
// trigger 262657 = packTriggerChannels(1, 1, 1) and every scalar notification
// used to subtract one from the whole packed u32, so the durable value kept
// changing (262656, 262655, ... borrows straight into the neighbouring
// lanes) and the client fed each changed active-quest snapshot back through
// op33. That produced 3767 identical requests at ~30 Hz across seven logins
// (262656 -> 258892) while every town entry stayed frozen. Answering the
// satisfied state (zero) is what the same scalar form means in the healthy
// case (packet_log 2026-09-12T12:56:41Z reached zero in one step and the
// client finished the quest 1.7s later), and it lets the client cross its
// completion transition instead of retrying forever.
return 0, nil
}
func (o *Owner) seekAndMeetNPCItemProgress(
ctx context.Context,
characterID string,
accountID string,
progress int64,
definition Definition,
) (int64, error) {
if len(definition.IntData) < 3 || definition.IntData[0] <= 0 || definition.IntData[1] <= 0 || definition.IntData[2] <= 0 {
return progress, fmt.Errorf("seek n meet npc quest %d has malformed int data", definition.ID)
}
if progress < 0 || progress > int64(^uint32(0)) || o.inventory == nil {
return progress, nil
}
inventory, found, err := o.inventory.Load(ctx, characterID)
if err != nil {
return progress, err
}
if !found {
inventory = dnfrepo.InventoryRecord{CharacterID: characterID}
}
if o.accountInventory != nil && strings.TrimSpace(accountID) != "" {
accountID = strings.TrimSpace(accountID)
account, accountFound, loadErr := o.accountInventory.Load(ctx, accountID)
if loadErr != nil {
return progress, loadErr
}
if accountFound && strings.TrimSpace(account.AccountID) != accountID {
return progress, fmt.Errorf("account inventory owner mismatch: got %q want %q", account.AccountID, accountID)
}
account.AccountID = accountID
inventory = dnfrepo.MergeAccountSharedInventory(inventory, account)
}
if countItemInMainUpTo(inventory, definition.IntData[0], definition.IntData[1]) < definition.IntData[1] {
// The client may probe the item channel before the requirement is met.
// Preserve the PVF trigger and do not create an ACK/snapshot feedback loop.
return progress, nil
}
return int64(setPackedTriggerChannel(uint32(progress), 0, 0)), nil
}
// Current-client op33 uses one-hot trigger types for the three 9-bit channels
// stored in the active quest's packed u32 trigger value. Treating those values
// as one scalar counter pollutes adjacent channels and makes the client feed
// the malformed value back through op33 indefinitely.
func setTriggerPackedChannel(triggerType byte) (int, bool) {
switch triggerType {
case 0x10:
return 0, true
case 0x20:
return 1, true
case 0x40:
return 2, true
default:
return 0, false
}
}
var raidPhaseClearCalendar = time.FixedZone("Asia/Shanghai", 8*60*60)
func raidPhaseClearCalendarDay(now time.Time) string {
// DNF's service day rolls at 06:00 Beijing time, not civil midnight.
return now.In(raidPhaseClearCalendar).Add(-6 * time.Hour).Format("2006-01-02")
}
// raidPhaseClearTriggerChannel accepts only one decrement for a PVF-declared
// phase channel. Increment requests and channels absent from [int data] are
// not authoritative raid-clear evidence.
func raidPhaseClearTriggerChannel(definition Definition, triggerType byte, increment bool) (int, bool) {
if increment || len(definition.IntData) == 0 || len(definition.IntData)%4 != 0 {
return 0, false
}
channel, ok := setTriggerPackedChannel(triggerType)
if !ok {
return 0, false
}
for offset := 0; offset+3 < len(definition.IntData); offset += 4 {
if definition.IntData[offset] == int64(channel) && definition.IntData[offset+2] > 0 {
return channel, true
}
}
return 0, false
}
func incrementPackedTriggerChannel(trigger uint32, channel int) uint32 {
if channel < 0 || channel > 2 {
return trigger
}
shift := channel * 9
current := (trigger >> shift) & 0x1ff
if current == 0x1ff {
return trigger
}
trigger &^= 0x1ff << shift
trigger |= (current + 1) << shift
return trigger
}
func setPackedTriggerChannel(trigger uint32, channel int, value uint32) uint32 {
if channel < 0 || channel > 2 {
return trigger
}
shift := channel * 9
trigger &^= 0x1ff << shift
trigger |= (value & 0x1ff) << shift
return trigger
}
// clampQuestProgress ensures quest progress never goes negative (defensive
// guard against malformed client triggers or dungeon-ID leakage).
func clampQuestProgress(value int64) int64 {
if value < 0 {
return 0
}
return value
}
func parseSeekingItemIDs(extra map[string]string) []int64 {
for _, key := range []string{"seeking_item_ids", "seek_item_ids", "required_item_ids", "item_ids"} {
if values := parseIntList(extra[key]); len(values) > 0 {
return values
}
}
return nil
}
// acceptedQuestStateExtra stores the PVF identity for every accepted quest and
// materialises seeking requirements when they are present. The latter is a
// cache only: nextTriggerProgress also re-derives the rules from the active
// PVF definition so existing characters accepted before this field existed
// finish through the same native item-check path.
func acceptedQuestStateExtra(plan AcceptPlan, definition Definition) map[string]string {
extra := map[string]string{
"pvf_path": plan.Path,
"quest_type": plan.Type,
}
itemIDs, counts := seekingRequirementItems(&definition)
if len(itemIDs) == 0 {
return extra
}
encodedIDs := make([]string, 0, len(itemIDs))
encodedCounts := make([]string, 0, len(counts))
for index, itemID := range itemIDs {
encodedIDs = append(encodedIDs, strconv.FormatInt(itemID, 10))
count := int64(1)
if index < len(counts) && counts[index] > 0 {
count = counts[index]
}
encodedCounts = append(encodedCounts, strconv.FormatInt(count, 10))
}
extra["seeking_item_ids"] = strings.Join(encodedIDs, ",")
extra["seeking_item_counts"] = strings.Join(encodedCounts, ",")
return extra
}
func seekingRequirementItems(definition *Definition) ([]int64, []int64) {
if definition == nil || normalizeQuestTag(definition.Type) != "seeking" {
return nil, nil
}
rules := itemPairRulesFromInts(definition.IntData)
if len(rules) == 0 {
return nil, nil
}
itemIDs := make([]int64, 0, len(rules))
counts := make([]int64, 0, len(rules))
for _, rule := range rules {
if rule.ItemID <= 0 || rule.Count <= 0 {
continue
}
itemIDs = append(itemIDs, rule.ItemID)
counts = append(counts, rule.Count)
}
return itemIDs, counts
}
func parseSeekingItemCounts(extra map[string]string, n int) []int64 {
for _, key := range []string{"seeking_item_counts", "seek_item_counts", "required_item_counts", "item_counts"} {
values := parseIntList(extra[key])
if len(values) > 0 {
for len(values) < n {
values = append(values, 1)
}
return values[:n]
}
}
values := make([]int64, n)
for i := range values {
values[i] = 1
}
return values
}
func parseIntList(raw string) []int64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.FieldsFunc(raw, func(r rune) bool {
switch r {
case ',', ';', '|', ' ', '\t', '\n', '\r':
return true
default:
return false
}
})
out := make([]int64, 0, len(parts))
for _, part := range parts {
value, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err == nil && value > 0 {
out = append(out, value)
}
}
return out
}
func seekingMissingMask(ctx context.Context, inventory dnfrepo.InventoryRepository, characterID string, itemIDs []int64, counts []int64) int64 {
record, ok, err := inventory.Load(ctx, characterID)
if err != nil || !ok {
return 0
}
var mask int64
for index, itemID := range itemIDs {
required := int64(1)
if index < len(counts) && counts[index] > 0 {
required = counts[index]
}
if countItemInMain(record, itemID) < required && index < 63 {
mask |= 1 << index
}
}
return mask
}
func countItemInMain(record dnfrepo.InventoryRecord, itemID int64) int64 {
var total int64
for key, stack := range record.Slots {
if !strings.HasPrefix(key, "0:") || stack.ItemID != itemID {
continue
}
if stack.Count <= 0 {
total++
continue
}
total += stack.Count
}
return total
}
func countItemInMainUpTo(record dnfrepo.InventoryRecord, itemID int64, limit int64) int64 {
if limit <= 0 {
return 0
}
var total int64
for key, stack := range record.Slots {
if !strings.HasPrefix(key, "0:") || stack.ItemID != itemID {
continue
}
count := stack.Count
if count <= 0 {
count = 1
}
if count >= limit-total {
return limit
}
total += count
}
return total
}
func planError(operation string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", operation, err)
}