Files
90CNServer/server/internal/modules/dnf/quest/auto_complete_items.go
T
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

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)
}