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