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