DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,418 @@
|
||||
package quest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// Task-manual auto completion (op1429) deliberately grants no rewards. That is
|
||||
// right for experience, gold and ordinary loot, but a handful of epic quests
|
||||
// reward a story item that a later epic requires: 3226 hands over 斯卡迪女王的
|
||||
// 书信 (10164786) and 3610 later asks for it; six mirror-era epics each drop one
|
||||
// 毁灭纪碎片 (10164800) and 3509 asks for ten. A character who auto-completes
|
||||
// the producer and then plays the consumer has no way to obtain the item and
|
||||
// the chain is stuck for good.
|
||||
//
|
||||
// The repair below is a narrow join, not a reward replay: only items that an
|
||||
// outstanding story consumer actually asks for, and that a completed no-reward
|
||||
// producer would have handed over, are granted, and only up to the deficit the
|
||||
// inventory shows. Parent closure (a [quest clear] parent auto-completing its
|
||||
// no-reward children) does not make this redundant - it only reaches children
|
||||
// whose parent itself falls under the level cutoff, whereas the stuck case is
|
||||
// a producer under the cutoff and a consumer above it.
|
||||
|
||||
// AutoCompleteCarryForwardRepairInput repairs only story items whose ordinary
|
||||
// reward was deliberately skipped by the task-manual no-reward path. It does
|
||||
// not replay experience, currency, selectable rewards, or ordinary loot.
|
||||
type AutoCompleteCarryForwardRepairInput struct {
|
||||
CharacterID string
|
||||
RepairedAt time.Time
|
||||
AllocateItem FinishItemAllocator
|
||||
}
|
||||
|
||||
type AutoCompleteCarryForwardRepairResult struct {
|
||||
CharacterID string
|
||||
Items []FinishCommittedItem
|
||||
Inventory dnfrepo.InventoryRecord
|
||||
Idempotent bool
|
||||
}
|
||||
|
||||
type autoCompleteCarryForwardRequirement struct {
|
||||
ConsumerQuestID int64
|
||||
Count int64
|
||||
}
|
||||
|
||||
type autoCompleteCarryForwardGrant struct {
|
||||
ProducerQuestID int64
|
||||
ConsumerQuestID int64
|
||||
PVFPath string
|
||||
ItemID int64
|
||||
Count int64
|
||||
}
|
||||
|
||||
type autoCompleteCarryForwardContribution struct {
|
||||
anchorQuestID int64
|
||||
anchorPath string
|
||||
rewardCount int64
|
||||
consumeCount int64
|
||||
}
|
||||
|
||||
// ApplyAutoCompleteCarryForwardRepair is the reconnect compatibility path for
|
||||
// characters saved before carry-forward item preservation existed. The
|
||||
// completed no-reward producer, active/next consumer, and inventory deficit
|
||||
// are checked again inside one character-settlement transaction.
|
||||
func (o *Owner) ApplyAutoCompleteCarryForwardRepair(
|
||||
ctx context.Context,
|
||||
catalog *Catalog,
|
||||
input AutoCompleteCarryForwardRepairInput,
|
||||
) (AutoCompleteCarryForwardRepairResult, error) {
|
||||
if o == nil || o.repositories.CharacterSettlement == nil || catalog == nil {
|
||||
return AutoCompleteCarryForwardRepairResult{}, ErrAutoCompleteMainUnavailable
|
||||
}
|
||||
input.CharacterID = strings.TrimSpace(input.CharacterID)
|
||||
if input.CharacterID == "" {
|
||||
return AutoCompleteCarryForwardRepairResult{}, ErrCharacterRequired
|
||||
}
|
||||
if input.RepairedAt.IsZero() {
|
||||
input.RepairedAt = time.Now().UTC()
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
var items []FinishCommittedItem
|
||||
var inventory dnfrepo.InventoryRecord
|
||||
err := o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, input.CharacterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Character == nil || tx.Quest == nil || tx.Inventory == nil {
|
||||
return ErrAutoCompleteMainUnavailable
|
||||
}
|
||||
character, found, err := tx.Character.Load(ctx, input.CharacterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(character.CharacterID) != input.CharacterID {
|
||||
return ErrCharacterNotFound
|
||||
}
|
||||
job, err := strconv.Atoi(strings.TrimSpace(character.Job))
|
||||
growType := character.Stats["grow_type"]
|
||||
if err != nil || !definitionCharacterJobValid(CharacterEligibility{Level: character.Level, Job: job}) || growType < 0 || growType > 0xff {
|
||||
return ErrAutoCompleteLevelInvalid
|
||||
}
|
||||
record, found, err := tx.Quest.Load(ctx, input.CharacterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
items, inventory, err = repairAutoCompletedQuestCarryForwardItems(
|
||||
ctx,
|
||||
catalog,
|
||||
tx.Inventory,
|
||||
input.CharacterID,
|
||||
record,
|
||||
CharacterEligibility{Level: character.Level, Job: job, GrowType: int(growType)},
|
||||
input.RepairedAt,
|
||||
input.AllocateItem,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return AutoCompleteCarryForwardRepairResult{}, err
|
||||
}
|
||||
return AutoCompleteCarryForwardRepairResult{
|
||||
CharacterID: input.CharacterID,
|
||||
Items: cloneFinishItems(items),
|
||||
Inventory: dnfrepo.CloneInventory(inventory),
|
||||
Idempotent: len(items) == 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// repairAutoCompletedQuestCarryForwardItems grants the deficit of every
|
||||
// carry-forward item to the main inventory. It is idempotent by construction:
|
||||
// a second pass finds the inventory already holding the required count and
|
||||
// grants nothing.
|
||||
func repairAutoCompletedQuestCarryForwardItems(
|
||||
ctx context.Context,
|
||||
catalog *Catalog,
|
||||
inventoryRepository dnfrepo.InventoryRepository,
|
||||
characterID string,
|
||||
record dnfrepo.QuestRecord,
|
||||
character CharacterEligibility,
|
||||
repairedAt time.Time,
|
||||
allocator FinishItemAllocator,
|
||||
) ([]FinishCommittedItem, dnfrepo.InventoryRecord, error) {
|
||||
grants, err := catalog.autoCompleteCarryForwardGrants(record, character)
|
||||
if err != nil {
|
||||
return nil, dnfrepo.InventoryRecord{}, err
|
||||
}
|
||||
if len(grants) == 0 {
|
||||
return nil, dnfrepo.InventoryRecord{}, nil
|
||||
}
|
||||
if inventoryRepository == nil {
|
||||
return nil, dnfrepo.InventoryRecord{}, ErrAutoCompleteMainUnavailable
|
||||
}
|
||||
inventory, found, err := inventoryRepository.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return nil, dnfrepo.InventoryRecord{}, err
|
||||
}
|
||||
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
|
||||
return nil, dnfrepo.InventoryRecord{}, ErrAutoCompleteMainUnavailable
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
committed := make([]FinishCommittedItem, 0, len(grants))
|
||||
for _, grant := range grants {
|
||||
held := countItemInMain(inventory, grant.ItemID)
|
||||
if held >= grant.Count {
|
||||
continue
|
||||
}
|
||||
if allocator == nil {
|
||||
return nil, dnfrepo.InventoryRecord{}, ErrFinishItemAllocatorRequired
|
||||
}
|
||||
deficit := grant.Count - held
|
||||
completionKey := fmt.Sprintf(
|
||||
"auto-complete-carry-forward/%s/%d/%d/%d",
|
||||
characterID,
|
||||
grant.ProducerQuestID,
|
||||
grant.ConsumerQuestID,
|
||||
grant.ItemID,
|
||||
)
|
||||
item, allocateErr := allocator(&inventory, FinishItemGrantRequest{
|
||||
QuestID: grant.ProducerQuestID,
|
||||
CompletionKey: completionKey,
|
||||
Source: "quest_pvf_auto_complete_carry_forward:" + grant.PVFPath,
|
||||
ItemID: grant.ItemID,
|
||||
Count: deficit,
|
||||
})
|
||||
if allocateErr != nil {
|
||||
return nil, dnfrepo.InventoryRecord{}, allocateErr
|
||||
}
|
||||
if item.ItemID != grant.ItemID || item.Delta != deficit || item.SlotKey == "" || item.PostCount < item.Delta || len(item.RawEntry) == 0 {
|
||||
return nil, dnfrepo.InventoryRecord{}, fmt.Errorf("%w: invalid carry-forward allocator receipt item=%d", ErrQuestRewardMalformed, grant.ItemID)
|
||||
}
|
||||
committed = append(committed, item)
|
||||
}
|
||||
if len(committed) == 0 {
|
||||
return nil, inventory, nil
|
||||
}
|
||||
inventory.UpdatedAt = repairedAt.UTC()
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepository, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return nil, dnfrepo.InventoryRecord{}, err
|
||||
}
|
||||
return committed, inventory, nil
|
||||
}
|
||||
|
||||
// autoCompleteCarryForwardGrants matches the immediate active/acceptable
|
||||
// storyline's required item against a completed task-manual producer whose
|
||||
// ordinary item reward was explicitly suppressed. This narrow join is what
|
||||
// distinguishes a continuity item from the ordinary rewards op1429 must skip.
|
||||
func (c *Catalog) autoCompleteCarryForwardGrants(
|
||||
record dnfrepo.QuestRecord,
|
||||
character CharacterEligibility,
|
||||
) ([]autoCompleteCarryForwardGrant, error) {
|
||||
if c == nil {
|
||||
return nil, ErrCatalogEmpty
|
||||
}
|
||||
requirements := c.autoCompleteOutstandingItemRequirements(record, character)
|
||||
if len(requirements) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
contributions := make(map[int64]autoCompleteCarryForwardContribution, len(requirements))
|
||||
visitedDefinitions := make(map[int64]struct{})
|
||||
var collectDefinition func(Definition) error
|
||||
collectDefinition = func(definition Definition) error {
|
||||
if definition.ID <= 0 || !definitionMatchesCharacter(definition, character) {
|
||||
return nil
|
||||
}
|
||||
if _, visited := visitedDefinitions[definition.ID]; visited {
|
||||
return nil
|
||||
}
|
||||
visitedDefinitions[definition.ID] = struct{}{}
|
||||
|
||||
if normalizeQuestTag(definition.RewardType) == "item" && definition.RewardParseError == "" {
|
||||
for _, rule := range definition.RewardItems {
|
||||
if _, needed := requirements[rule.ItemID]; !needed || !rewardRuleMatches(rule, character) {
|
||||
continue
|
||||
}
|
||||
contribution := contributions[rule.ItemID]
|
||||
contribution.rewardCount += rule.Count
|
||||
if contribution.anchorQuestID == 0 || definition.ID < contribution.anchorQuestID {
|
||||
contribution.anchorQuestID = definition.ID
|
||||
contribution.anchorPath = definition.Path
|
||||
}
|
||||
contributions[rule.ItemID] = contribution
|
||||
}
|
||||
}
|
||||
for _, rule := range finishDefinitionSeekingRules(definition) {
|
||||
if _, needed := requirements[rule.ItemID]; !needed {
|
||||
continue
|
||||
}
|
||||
contribution := contributions[rule.ItemID]
|
||||
contribution.consumeCount += rule.Count
|
||||
contributions[rule.ItemID] = contribution
|
||||
}
|
||||
|
||||
if !questClearDefinition(definition) {
|
||||
return nil
|
||||
}
|
||||
// Task-manual completion closes a composite epic without executing its
|
||||
// rewarded internal stages. Reconstruct those stages from the parent so
|
||||
// cumulative story items (for example quest 3509's ten fragments) retain
|
||||
// the same net balance as an ordinary play-through.
|
||||
for _, childID := range definition.IntData {
|
||||
child, found := c.Find(childID)
|
||||
if !found || child.MainQuestID != definition.ID {
|
||||
continue
|
||||
}
|
||||
if err := collectDefinition(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
collect := func(questID int64, state dnfrepo.QuestState) error {
|
||||
if !isCompletedQuestStatus(state.Status) ||
|
||||
state.Extra["auto_completed"] != "true" ||
|
||||
state.Extra["auto_complete_reason"] != "epic_below_character_level_no_reward" {
|
||||
return nil
|
||||
}
|
||||
definition, found := c.Find(questID)
|
||||
if !found || normalizeQuestTag(definition.Grade) != "epic" {
|
||||
return nil
|
||||
}
|
||||
return collectDefinition(definition)
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(record.States)+len(record.Progress))
|
||||
for questID, state := range record.States {
|
||||
seen[questID] = struct{}{}
|
||||
if err := collect(questID, state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for questID, state := range record.Progress {
|
||||
if _, duplicate := seen[questID]; duplicate {
|
||||
continue
|
||||
}
|
||||
if err := collect(questID, state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
itemIDs := make([]int64, 0, len(contributions))
|
||||
netCounts := make(map[int64]int64, len(contributions))
|
||||
for itemID, contribution := range contributions {
|
||||
netCount := contribution.rewardCount - contribution.consumeCount
|
||||
// What the pass owes the character is exactly what it withheld: the net
|
||||
// the completed producers would have handed over. It is deliberately
|
||||
// not the consumer's requirement. The pass stops wherever the chain
|
||||
// stops being safe to auto-complete - a [quest clear] parent with a
|
||||
// reward-bearing child, for one - and the producers inside that
|
||||
// remaining stretch are ones the player still earns by playing it. A
|
||||
// short net there is normal, not malformed PVF: quest 3473 needs three
|
||||
// fragments while its own sibling 3472 awards two of them.
|
||||
if contribution.anchorQuestID <= 0 || netCount <= 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: cumulative producers item=%d reward=%d consumed=%d net=%d required=%d",
|
||||
ErrQuestRewardMalformed,
|
||||
itemID,
|
||||
contribution.rewardCount,
|
||||
contribution.consumeCount,
|
||||
netCount,
|
||||
requirements[itemID].Count,
|
||||
)
|
||||
}
|
||||
if requirement := requirements[itemID].Count; netCount > requirement {
|
||||
netCount = requirement
|
||||
}
|
||||
netCounts[itemID] = netCount
|
||||
itemIDs = append(itemIDs, itemID)
|
||||
}
|
||||
sort.Slice(itemIDs, func(left, right int) bool { return itemIDs[left] < itemIDs[right] })
|
||||
grants := make([]autoCompleteCarryForwardGrant, 0, len(itemIDs))
|
||||
for _, itemID := range itemIDs {
|
||||
contribution := contributions[itemID]
|
||||
requirement := requirements[itemID]
|
||||
grants = append(grants, autoCompleteCarryForwardGrant{
|
||||
ProducerQuestID: contribution.anchorQuestID,
|
||||
ConsumerQuestID: requirement.ConsumerQuestID,
|
||||
PVFPath: contribution.anchorPath,
|
||||
ItemID: itemID,
|
||||
Count: netCounts[itemID],
|
||||
})
|
||||
}
|
||||
return grants, nil
|
||||
}
|
||||
|
||||
// autoCompleteOutstandingItemRequirements collects the story items the
|
||||
// character's active or currently acceptable epic line still has to hand in.
|
||||
// Consumers are epics, or children of an epic [quest clear] parent, whose type
|
||||
// consumes items ([seeking] / [seek n meet npc]).
|
||||
func (c *Catalog) autoCompleteOutstandingItemRequirements(
|
||||
record dnfrepo.QuestRecord,
|
||||
character CharacterEligibility,
|
||||
) map[int64]autoCompleteCarryForwardRequirement {
|
||||
requirements := make(map[int64]autoCompleteCarryForwardRequirement)
|
||||
consumerIDs := make(map[int64]struct{})
|
||||
collectActive := func(states map[int64]dnfrepo.QuestState) {
|
||||
for questID, state := range states {
|
||||
if isActiveQuestStatus(state.Status) {
|
||||
consumerIDs[questID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
collectActive(record.States)
|
||||
collectActive(record.Progress)
|
||||
for _, questID := range c.Acceptable(character, record).IDs {
|
||||
consumerIDs[int64(questID)] = struct{}{}
|
||||
}
|
||||
// A quest-clear parent owns internal child steps that are not advertised as
|
||||
// independently acceptable. Include those definitions so the item needed by
|
||||
// the next composite main quest is preserved before the parent is accepted.
|
||||
for questID := range consumerIDs {
|
||||
definition, found := c.Find(questID)
|
||||
if !found || !questClearDefinition(definition) {
|
||||
continue
|
||||
}
|
||||
for _, childID := range definition.IntData {
|
||||
if child, childFound := c.Find(childID); childFound && child.MainQuestID == definition.ID {
|
||||
consumerIDs[childID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for questID := range consumerIDs {
|
||||
if state, found := questStateFor(record, questID); found && isCompletedQuestStatus(state.Status) {
|
||||
continue
|
||||
}
|
||||
definition, found := c.Find(questID)
|
||||
if !found || !c.autoCompleteStoryItemConsumer(definition) || !definitionMatchesCharacter(definition, character) {
|
||||
continue
|
||||
}
|
||||
for _, rule := range finishDefinitionSeekingRules(definition) {
|
||||
if rule.ItemID <= 0 || rule.Count <= 0 {
|
||||
continue
|
||||
}
|
||||
current, exists := requirements[rule.ItemID]
|
||||
if !exists || rule.Count > current.Count || (rule.Count == current.Count && questID < current.ConsumerQuestID) {
|
||||
requirements[rule.ItemID] = autoCompleteCarryForwardRequirement{ConsumerQuestID: questID, Count: rule.Count}
|
||||
}
|
||||
}
|
||||
}
|
||||
return requirements
|
||||
}
|
||||
|
||||
func (c *Catalog) autoCompleteStoryItemConsumer(definition Definition) bool {
|
||||
if normalizeQuestTag(definition.Grade) == "epic" {
|
||||
return true
|
||||
}
|
||||
if definition.MainQuestID <= 0 {
|
||||
return false
|
||||
}
|
||||
parent, found := c.Find(definition.MainQuestID)
|
||||
return found && normalizeQuestTag(parent.Grade) == "epic" && questClearDefinition(parent)
|
||||
}
|
||||
Reference in new issue
Block a user