DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,856 @@
package dnfbridge
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
"longheng.io/server/internal/modules/dnf/premium"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
)
// currentQuestDropCandidate is one rolled quest-item drop from a PVF
// [monster reward item] or [enemy reward item] entry.
type currentQuestDropCandidate struct {
QuestID int64
ItemID uint32
Count int64
DropRate int64
MaxStack int64
}
type currentDungeonQuestDropPlan struct {
sequence uint64
characterID uint16
characterGeneration uint64
characterKey string
accountID string
dungeonID int64
difficulty int64
enemyCode int64
enemyType int64
includeMonster bool
}
const currentDungeonQuestEnemyTypeAICharacter = int64(2)
// checkCurrentDungeonQuestMonsterDrops implements the 86JP
// QuestDropService.CheckMonsterDrop path: after a monster dies, every
// active quest's PVF [monster reward item] table is checked for the
// killed monster code. Matched entries are rolled (per-attempt
// percentage) and granted directly to the character backpack, followed
// by an op14 incremental refresh. Quest progress sync for "get item" /
// "seeking" quests is left to the existing pickup/progress owners.
func (s *Service) checkCurrentDungeonQuestMonsterDrops(
session *gameSession,
runtime *runtimeDungeonState,
monsterID int64,
) {
s.checkCurrentDungeonQuestEnemyDrops(session, runtime, monsterID, 1, true)
}
// checkCurrentDungeonQuestEnemyDrops owns the common task-item grant path for
// current-PVF enemy categories. Ordinary monsters also consult the legacy
// [monster reward item] table; passive objects consult only type-3
// [enemy reward item] rows.
func (s *Service) checkCurrentDungeonQuestEnemyDrops(
session *gameSession,
runtime *runtimeDungeonState,
enemyCode int64,
enemyType int64,
includeMonsterRewards bool,
) {
if s == nil || session == nil || session.selectedCharacterID == 0 || runtime == nil || enemyCode <= 0 ||
currentDungeonRuntimeIsPractice(runtime) || s.currentDungeonRuntimeIsRaidGate(runtime) {
return
}
plan := currentDungeonQuestDropPlan{
characterID: session.selectedCharacterID,
characterGeneration: session.characterGeneration,
characterKey: strconv.FormatUint(uint64(session.selectedCharacterID), 10),
accountID: s.accountIDForSession(session),
dungeonID: runtime.Dungeon.ID,
difficulty: int64(runtime.Request.Difficulty),
enemyCode: enemyCode,
enemyType: enemyType,
includeMonster: includeMonsterRewards,
}
plan.sequence = reserveCurrentDungeonQuestProgress(session)
if session.events != nil && session.events.loop != nil && session.events.active.Load() {
go s.runCurrentDungeonQuestDrops(session, plan)
return
}
runCurrentDungeonQuestProgressOrdered(session, plan.sequence, func() {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
s.executeCurrentDungeonQuestDrops(ctx, session, plan, func(body []byte) error {
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0)
})
})
}
func (s *Service) runCurrentDungeonQuestDrops(session *gameSession, plan currentDungeonQuestDropPlan) {
runCurrentDungeonQuestProgressOrdered(session, plan.sequence, func() {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
s.executeCurrentDungeonQuestDrops(ctx, session, plan, func(body []byte) error {
body = append([]byte(nil), body...)
return s.postGameSessionCharacterEvent(
session,
"dungeon-quest-drop-persistence-complete",
plan.characterID,
plan.characterGeneration,
func() error {
return s.sendGameUpperRawClass(
session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0,
)
},
)
})
})
}
func (s *Service) executeCurrentDungeonQuestDrops(
ctx context.Context,
session *gameSession,
plan currentDungeonQuestDropPlan,
deliver func([]byte) error,
) {
catalog, err := s.loadQuestCatalog(ctx)
if err != nil || catalog == nil {
return
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Quest == nil || repositories.CharacterItems == nil {
return
}
questHelperActive := false
growthQuestDropPercent := int64(0)
if repositories.Account != nil {
account, accountFound, accountErr := repositories.Account.Load(ctx, plan.accountID)
questHelperActive = accountErr == nil &&
accountFound &&
premium.Active(account, premium.DevilSlotType(premium.DevilSlotQuestHelper), time.Now().UTC())
}
if growthEffect, growthActive := s.currentGrowthContractEffect(ctx, plan.accountID); growthActive {
growthQuestDropPercent = growthEffect.QuestItemDropRatePercent
}
questRecord, found, err := repositories.Quest.Load(ctx, plan.characterKey)
if err != nil || !found {
return
}
activeIDs := currentQuestDropActiveIDs(questRecord)
if len(activeIDs) == 0 {
return
}
var candidates []currentQuestDropCandidate
if plan.includeMonster {
candidates = s.currentQuestDropMatchMonster(catalog, activeIDs, plan.dungeonID, plan.difficulty, plan.enemyCode)
} else {
candidates = currentQuestDropMatchEnemy(catalog, activeIDs, plan.dungeonID, plan.difficulty, plan.enemyCode, plan.enemyType)
}
if len(candidates) == 0 {
return
}
owner, err := dnfdungeon.NewOwner(repositories)
if err != nil {
return
}
dropCatalog, catalogErr := s.currentPVFItemCatalog()
rng := newCurrentDungeonDropLCG(uint32(time.Now().UnixNano()))
var grantedSlots []int16
var grantedAccountSlots []int16
// 落点循环只写一份:账号优先(account != nil)与改造前的只进背包(account == nil)
// 共用它,区别只在事务边界。
drops := func(account *dnfrepo.AccountInventoryRecord, inventory *dnfrepo.InventoryRecord) (bool, bool, error) {
accountChanged := false
characterChanged := false
for _, candidate := range candidates {
// [max stack] 的"已持有"口径保持改造前原样:**只数背包**。
// 这里刻意不改。
//
// 原因不是"账号仓那份不算数",而是实测真实 Script.pvf:9487 个
// .qst 里 [enemy reward item] / [monster reward item] 共 8900 行,
// 其中指向六个晶块/六个灵魂的行是 **0 行** —— 这个上限对账号共享
// 道具根本不可能被触发,把账号仓并进来只会改变 4363 这类普通任务
// 道具(它们不在账号仓,但固定格里若被塞进外来件就会多算)的行为,
// 属于没有收益的风险。真正给晶块的任务奖励走的是任务通关奖励那条
// 路([reward int data],见 currentQuestFinishItemAllocator)。
held := currentQuestDropHeldCount(*inventory, int64(candidate.ItemID))
if candidate.MaxStack > 0 && held >= candidate.MaxStack {
s.logGameEvent(session, "game-dungeon-quest-drop-skipped-max-stack",
"quest_id", candidate.QuestID,
"item_id", candidate.ItemID,
"held", held,
"max_stack", candidate.MaxStack)
continue
}
dropRate := currentQuestItemDropRate(candidate.DropRate, growthQuestDropPercent, questHelperActive)
dropCount := currentQuestDropRoll(int(rng.NextUint32()), candidate.Count, dropRate, candidate.MaxStack, held)
if dropCount <= 0 {
continue
}
if catalogErr != nil || dropCatalog == nil {
s.logGameEvent(session, "game-dungeon-quest-drop-skipped-catalog",
"quest_id", candidate.QuestID,
"item_id", candidate.ItemID,
"error", catalogErr)
continue
}
definition, resolveErr := dropCatalog.ResolveItem(candidate.ItemID)
if resolveErr != nil {
s.logGameEvent(session, "game-dungeon-quest-drop-skipped-resolve",
"quest_id", candidate.QuestID,
"item_id", candidate.ItemID,
"error", resolveErr)
continue
}
// 账号优先:无色/灵魂类掉落先落账号共享仓的固定格,余量再走角色背包。
// 落点规则只有一份实现(currentQuestDropPlaceCandidate),祭坛那条
// op255 批量路径共用它。
placement, placeErr := currentQuestDropPlaceCandidate(inventory, account, definition, uint32(dropCount))
if placement.AccountChanged {
accountChanged = true
grantedAccountSlots = append(grantedAccountSlots, placement.AccountSlot)
s.logGameEvent(session, "game-dungeon-quest-drop-granted",
"quest_id", candidate.QuestID,
"monster_id", plan.enemyCode,
"enemy_code", plan.enemyCode,
"enemy_type", plan.enemyType,
"item_id", candidate.ItemID,
"count", dropCount,
"base_drop_rate", candidate.DropRate,
"effective_drop_rate", dropRate,
"growth_contract_drop_percent", growthQuestDropPercent,
"quest_helper", questHelperActive,
"slot", placement.AccountSlot,
"new_total", placement.AccountTotal,
"owner", "account_shared")
}
if placeErr != nil {
s.logGameEvent(session, "game-dungeon-quest-drop-skipped-grant",
"quest_id", candidate.QuestID,
"item_id", candidate.ItemID,
"count", placement.Remainder,
"error", placeErr)
continue
}
if !placement.CharacterChanged {
continue
}
grantedSlots = append(grantedSlots, placement.CharacterSlot)
characterChanged = true
s.logGameEvent(session, "game-dungeon-quest-drop-granted",
"quest_id", candidate.QuestID,
"monster_id", plan.enemyCode,
"enemy_code", plan.enemyCode,
"enemy_type", plan.enemyType,
"item_id", candidate.ItemID,
"count", placement.Remainder,
"base_drop_rate", candidate.DropRate,
"effective_drop_rate", dropRate,
"growth_contract_drop_percent", growthQuestDropPercent,
"quest_helper", questHelperActive,
"slot", placement.CharacterSlot,
"new_total", placement.CharacterTotal,
"owner", "character")
}
return accountChanged, characterChanged, nil
}
// 账号仓那条事务只在**确实能用**时才走:账号仓事务实现缺失、plan 没有 accountID、
// 或角色记录不属于该账号,都会让 MutateAccountCharacterInventory 整体失败 ——
// 那等于把"任务怪掉落"整条打没了(被动物件掉落就是这么被闸门抓出来的)。
// 这种情况退回改造前的事务边界 MutateInventory(只给角色背包),账号侧 account=nil。
var (
resultInventory dnfrepo.InventoryRecord
resultAccount dnfrepo.AccountInventoryRecord
accountChanged bool
characterChanged bool
)
if currentQuestDropAccountSharedReady(ctx, repositories, plan) {
mutation, mutationErr := owner.MutateAccountCharacterInventory(ctx, dnfdungeon.AccountCharacterInventoryMutationCommand{
AccountID: plan.accountID,
CharacterID: plan.characterKey,
UpdatedAt: time.Now().UTC(),
Apply: drops,
})
if mutationErr != nil {
s.logGameEvent(session, "game-dungeon-quest-drop-save-failed",
"monster_id", plan.enemyCode,
"enemy_code", plan.enemyCode,
"enemy_type", plan.enemyType,
"error", mutationErr)
return
}
resultInventory, resultAccount = mutation.Inventory, mutation.AccountInventory
accountChanged, characterChanged = mutation.AccountChanged, mutation.CharacterChanged
} else {
mutation, mutationErr := owner.MutateInventory(ctx, dnfdungeon.InventoryMutationCommand{
CharacterID: plan.characterKey,
UpdatedAt: time.Now().UTC(),
Apply: func(inventory *dnfrepo.InventoryRecord) (bool, error) {
_, characterDirty, applyErr := drops(nil, inventory)
return characterDirty, applyErr
},
})
if mutationErr != nil {
s.logGameEvent(session, "game-dungeon-quest-drop-save-failed",
"monster_id", plan.enemyCode,
"enemy_code", plan.enemyCode,
"enemy_type", plan.enemyType,
"error", mutationErr)
return
}
resultInventory = mutation.Inventory
characterChanged = mutation.Changed
}
if !accountChanged && !characterChanged {
return
}
updates := make([]currentItemListEntry, 0, len(grantedSlots)+len(grantedAccountSlots))
for _, slot := range grantedSlots {
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
if stack, stackFound := resultInventory.Slots[key]; stackFound {
updates = append(updates, currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack))
}
}
// 账号共享槽也是 list-0 线地址:ACK 行必须从账号记录里取,才报得出真正落库的数量。
for _, slot := range grantedAccountSlots {
key := dnfrepo.AccountSharedInventorySlotKey(slot)
if stack, stackFound := resultAccount.Slots[key]; stackFound {
updates = append(updates, currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack))
}
}
if len(updates) == 0 {
return
}
sortCurrentItemListEntries(updates)
body := buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, updates)
if deliver == nil {
return
}
if sendErr := deliver(body); sendErr != nil {
s.logGameEvent(session, "game-dungeon-quest-drop-refresh-failed",
"monster_id", plan.enemyCode,
"enemy_code", plan.enemyCode,
"enemy_type", plan.enemyType,
"error", sendErr)
return
}
s.logGameEvent(session, "game-dungeon-quest-drop-refresh-send",
"monster_id", plan.enemyCode,
"enemy_code", plan.enemyCode,
"enemy_type", plan.enemyType,
"entries", len(updates),
"msg_id", uint16(dnfenum.CmdPacketWalkoutPartyMember))
}
// currentQuestDropMatchMonster keeps the explicit PVF reward rows
// authoritative, then consults the narrowly-scoped fallback relationships
// that the current archive stores outside those rows.
func (s *Service) currentQuestDropMatchMonster(
catalog *dnfquest.Catalog,
activeIDs []int64,
dungeonID int64,
difficulty int64,
monsterID int64,
) []currentQuestDropCandidate {
candidates := currentQuestDropMatchMonster(catalog, activeIDs, dungeonID, difficulty, monsterID)
itemCatalog, itemErr := s.currentPVFItemCatalog()
monsterCatalog, monsterErr := s.dungeonMonsterCatalog()
if itemErr != nil || monsterErr != nil || itemCatalog == nil || monsterCatalog == nil {
return candidates
}
return append(candidates, currentQuestDropMatchImplicitMonster(
catalog, itemCatalog, monsterCatalog, activeIDs, dungeonID, difficulty, monsterID,
)...)
}
// currentQuestDropMatchImplicitMonster recovers two current-PVF relationships
// that are not represented by [monster reward item]/[enemy reward item]:
//
// - a plain seeking material scoped by [dungeon info], including event
// copies that inherit the scope from an identically named/itemed sibling;
// - a quest item whose own [explain] names the killed Dimension Crack hidden
// boss.
//
// Both paths remain bounded by the active quest's required count. Definitions
// carrying an explicit reward table never enter this fallback.
func currentQuestDropMatchImplicitMonster(
questCatalog *dnfquest.Catalog,
itemCatalog *pvfDungeonDropCatalog,
monsterCatalog *pvfDungeonMonsterCatalog,
activeIDs []int64,
dungeonID int64,
difficulty int64,
monsterID int64,
) []currentQuestDropCandidate {
if questCatalog == nil || itemCatalog == nil || monsterCatalog == nil || dungeonID <= 0 || monsterID <= 0 {
return nil
}
monster, monsterFound, monsterErr := monsterCatalog.Find(monsterID)
if monsterErr != nil || !monsterFound {
return nil
}
hiddenDimensionBoss := currentQuestDropIsDimensionHiddenBoss(monster.Path, monster.Sections)
monsterName := normalizeCurrentQuestDropProse(monster.Name)
var candidates []currentQuestDropCandidate
for _, questID := range activeIDs {
definition, found := questCatalog.Find(questID)
if !found || len(definition.MonsterRewardItems) != 0 || len(definition.EnemyRewardItems) != 0 {
continue
}
targets := questCatalog.SeekingItemTargets(questID)
if len(targets) == 0 {
continue
}
scopeMatch := currentQuestDropScopeMatches(questCatalog.SeekingDungeonScopes(questID), dungeonID, difficulty)
for _, target := range targets {
if target.ItemID <= 0 || target.ItemID > math.MaxUint32 || target.Count <= 0 {
continue
}
item, itemErr := itemCatalog.ResolveItem(uint32(target.ItemID))
if itemErr != nil || item.Kind != dungeonDropItemStackable {
continue
}
typeName := normalizeDungeonPVFSymbol(item.StackableType)
matched := typeName == "material" && scopeMatch
if !matched && typeName == "quest" && hiddenDimensionBoss && monsterName != "" {
matched = strings.Contains(normalizeCurrentQuestDropProse(item.Explain), monsterName)
}
if !matched {
continue
}
candidates = append(candidates, currentQuestDropCandidate{
QuestID: questID, ItemID: uint32(target.ItemID), Count: 1, DropRate: 100, MaxStack: target.Count,
})
}
}
return candidates
}
func currentQuestDropScopeMatches(scopes []dnfquest.DungeonScope, dungeonID int64, difficulty int64) bool {
for _, scope := range scopes {
if scope.DungeonID != -1 && scope.DungeonID != dungeonID {
continue
}
if scope.Difficulty >= 0 && scope.Difficulty != difficulty {
continue
}
return true
}
return false
}
func currentQuestDropIsDimensionHiddenBoss(monsterPath string, sections []string) bool {
pathValue := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(monsterPath), "\\", "/"))
if !strings.Contains(pathValue, "/dimensioncrack/specialboss/") &&
!strings.Contains(pathValue, "dimensioncrack/specialboss/") {
return false
}
for _, section := range sections {
if normalizeDungeonPVFSymbol(section) == "dimension hidden boss" {
return true
}
}
return false
}
func normalizeCurrentQuestDropProse(value string) string {
return strings.ToLower(strings.Join(strings.Fields(value), ""))
}
// currentQuestHelperDropRate applies the original task-assistant behavior:
// quest-item probability is raised by one fifth, capped at certainty. The
// current EXE has no slot-2 local call site, so this belongs on the server
// roll that owns quest-item drops.
func currentQuestHelperDropRate(base int64, active bool) int64 {
return currentQuestItemDropRate(base, 0, active)
}
func currentQuestItemDropRate(base int64, growthContractPercent int64, questHelperActive bool) int64 {
const questHelperPercent int64 = 20
if base <= 0 {
return base
}
bonusPercent := growthContractPercent
if bonusPercent < 0 {
bonusPercent = 0
}
if questHelperActive {
bonusPercent += questHelperPercent
}
if bonusPercent <= 0 {
return base
}
if bonusPercent >= 10000 || base > math.MaxInt64/(100+bonusPercent) {
return 100
}
boosted := (base*(100+bonusPercent) + 99) / 100
if boosted > 100 {
return 100
}
return boosted
}
// currentQuestDropActiveIDs extracts quest IDs whose status is "active"
// from the persisted quest record.
func currentQuestDropActiveIDs(record dnfrepo.QuestRecord) []int64 {
if len(record.States) == 0 {
return nil
}
ids := make([]int64, 0, len(record.States))
for questID, state := range record.States {
if strings.EqualFold(strings.TrimSpace(state.Status), "active") {
ids = append(ids, questID)
}
}
return ids
}
// currentQuestDropMatchMonster checks every active quest's PVF
// [monster reward item] entries for the killed monster, applying the
// 86JP dungeon/difficulty wildcard rules.
func currentQuestDropMatchMonster(
catalog *dnfquest.Catalog,
activeIDs []int64,
dungeonID int64,
difficulty int64,
monsterID int64,
) []currentQuestDropCandidate {
var candidates []currentQuestDropCandidate
for _, questID := range activeIDs {
definition, found := catalog.Find(questID)
if !found {
continue
}
for _, entry := range definition.MonsterRewardItems {
if entry.MonsterCode != monsterID {
continue
}
if entry.DungeonID != -1 && entry.DungeonID != dungeonID {
continue
}
if entry.Difficulty >= 0 && entry.Difficulty != difficulty {
continue
}
if entry.ItemID <= 0 || entry.ItemID > math.MaxUint32 || entry.Count <= 0 || entry.DropRate <= 0 {
continue
}
candidates = append(candidates, currentQuestDropCandidate{
QuestID: questID,
ItemID: uint32(entry.ItemID),
Count: entry.Count,
DropRate: entry.DropRate,
MaxStack: entry.MaxStack,
})
}
for _, entry := range definition.EnemyRewardItems {
if entry.EnemyType != 1 || entry.EnemyCode != monsterID {
continue
}
if entry.DungeonID != -1 && entry.DungeonID != dungeonID {
continue
}
if entry.Difficulty >= 0 && entry.Difficulty != difficulty {
continue
}
if entry.ItemID <= 0 || entry.ItemID > math.MaxUint32 || entry.Count <= 0 || entry.DropRate <= 0 {
continue
}
candidates = append(candidates, currentQuestDropCandidate{
QuestID: questID,
ItemID: uint32(entry.ItemID),
Count: entry.Count,
DropRate: entry.DropRate,
MaxStack: entry.MaxStack,
})
}
}
return candidates
}
// currentQuestDropMatchEnemy strictly applies the eight-field
// [enemy reward item] identity. Type 3 is reserved for passive objects; the
// caller must provide independently proven object identity and map scope.
func currentQuestDropMatchEnemy(
catalog *dnfquest.Catalog,
activeIDs []int64,
dungeonID int64,
difficulty int64,
enemyCode int64,
enemyType int64,
) []currentQuestDropCandidate {
var candidates []currentQuestDropCandidate
for _, questID := range activeIDs {
definition, found := catalog.Find(questID)
if !found {
continue
}
for _, entry := range definition.EnemyRewardItems {
if entry.EnemyType != enemyType || entry.EnemyCode != enemyCode {
continue
}
if entry.DungeonID != -1 && entry.DungeonID != dungeonID {
continue
}
if entry.Difficulty >= 0 && entry.Difficulty != difficulty {
continue
}
if entry.ItemID <= 0 || entry.ItemID > math.MaxUint32 || entry.Count <= 0 || entry.DropRate <= 0 {
continue
}
candidates = append(candidates, currentQuestDropCandidate{
QuestID: questID, ItemID: uint32(entry.ItemID), Count: entry.Count,
DropRate: entry.DropRate, MaxStack: entry.MaxStack,
})
}
}
return candidates
}
// currentQuestDropRoll implements the 86JP QuestDropProvider.RollDrop:
// for k in 0..count-1, if rand(100) < dropRate then actual++.
// The result is clamped to maxStack - held.
func currentQuestDropRoll(seed int, count int64, dropRate int64, maxStack int64, held int64) int64 {
if count <= 0 || dropRate <= 0 {
return 0
}
if maxStack > 0 && held >= maxStack {
return 0
}
var actual int64
rng := newCurrentDungeonDropLCG(uint32(seed))
for k := int64(0); k < count; k++ {
if int64(rng.Next(100)) < dropRate {
actual++
}
}
if actual <= 0 {
return 0
}
if actual > 999 {
actual = 999
}
if maxStack > 0 && held+actual > maxStack {
actual = maxStack - held
}
if actual < 0 {
return 0
}
return actual
}
// currentQuestDropHeldCount counts how many of itemID the character
// currently holds across all backpack slots.
func currentQuestDropHeldCount(inventory dnfrepo.InventoryRecord, itemID int64) int64 {
var total int64
for _, stack := range inventory.Slots {
if stack.ItemID == itemID {
total += stack.Count
}
}
return total
}
// currentQuestDropAccountSharedReady 判断"账号优先"这条事务能不能走。
//
// 需要三件事同时成立:账号仓事务实现存在、plan 里有 accountID、角色记录确实属于该账号。
// 任何一条不成立都必须**退回改造前的事务边界(只进背包)**,而不是让
// MutateAccountCharacterInventory 报错把整条掉落打没 —— 2026-09-21 的教训:
// 被动物件掉落(enemy_type=3)的既有夹具里角色没有 AccountID,
// 于是那条路整体发放为 0,被 baseline 闸门抓出来(4 个新失败)。
func currentQuestDropAccountSharedReady(
ctx context.Context,
repositories dnfrepo.Group,
plan currentDungeonQuestDropPlan,
) bool {
if repositories.AccountAssets == nil || repositories.Character == nil ||
strings.TrimSpace(plan.accountID) == "" || strings.TrimSpace(plan.characterKey) == "" {
return false
}
character, found, err := repositories.Character.Load(ctx, plan.characterKey)
if err != nil || !found {
return false
}
return strings.TrimSpace(character.AccountID) == strings.TrimSpace(plan.accountID)
}
// currentQuestDropAccountSharedSource 是这条路写进账号仓行 Extra["source"] 的溯源串。
const currentQuestDropAccountSharedSource = "dungeon_quest_drop"
// currentQuestDropPlacement 是"这一次掉落最后落到哪儿去了"的完整结果。
type currentQuestDropPlacement struct {
// AccountSlot 是账号共享仓里的固定格(354..365);0 表示该道具没有固定格。
AccountSlot int16
// AccountChanged 表示账号记录这一次真的被并入了量。
AccountChanged bool
// AccountTotal 是并入后账号仓该格的总量(仅在 AccountChanged 时有意义)。
AccountTotal int64
// CharacterSlot / CharacterChanged / CharacterTotal 是背包侧的结果。
CharacterSlot int16
CharacterChanged bool
CharacterTotal int64
// Remainder 是账号仓装不下、按容器规则溢出到背包的那部分。恒为 0 表示全部进了账号仓。
Remainder uint32
}
// currentQuestDropPlaceCandidate 是任务怪掉落**唯一**的落点实现:无色/灵魂先并进
// 账号共享仓的固定格(统一入口见 account_shared_grant.go),装不下的余量才按调用方
// 原有的容器规则落角色背包。
//
// # 为什么必须是"唯一"
//
// 这条路历史上被写了两遍 —— 普通地图杀怪(executeCurrentDungeonQuestDrops)与祭坛
// op255 批量死亡(commitCurrentDungeonAltarQuestDrops)各有一份落点判定。2026-09-21
// 的晶块事故就是这么来的:同一个语义散成多份,改一份漏一份。落点规则只允许有一份,
// 两条调用方都必须走这里。
//
// # 失败语义
//
// 背包侧失败(背包满)**不会吞掉账号侧已经并进去的量**:账号那部分照常返回,由调用方
// 决定继续还是回滚(祭坛那条按原有策略回滚整个事务,普通那条跳过本次)。
func currentQuestDropPlaceCandidate(
inventory *dnfrepo.InventoryRecord,
account *dnfrepo.AccountInventoryRecord,
definition dungeonDropItemDefinition,
count uint32,
) (currentQuestDropPlacement, error) {
var placement currentQuestDropPlacement
if count == 0 {
return placement, nil
}
shared := grantCurrentAccountSharedFirst(account, definition, count, currentQuestDropAccountSharedSource)
placement.AccountSlot = shared.Slot
placement.AccountChanged = shared.Changed
placement.Remainder = shared.Remainder
if shared.Changed && account != nil {
if row, found := account.Slots[dnfrepo.AccountSharedInventorySlotKey(shared.Slot)]; found {
placement.AccountTotal = row.Count
}
}
if shared.Remainder == 0 {
return placement, nil
}
slot, err := currentQuestDropGrantStack(inventory, definition, shared.Remainder)
if err != nil {
return placement, err
}
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
stack := inventory.Slots[key]
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
inventory.Slots[key] = stack
placement.CharacterSlot = slot
placement.CharacterChanged = true
placement.CharacterTotal = stack.Count
return placement, nil
}
// currentQuestDropGrantStack adds count of the item to the character
// backpack, merging with an existing stack when possible (86JP
// QuestDropService.TryPickupItemToInventory). It returns the slot
// that received the items.
func currentQuestDropGrantStack(inventory *dnfrepo.InventoryRecord, definition dungeonDropItemDefinition, count uint32) (int16, error) {
if inventory == nil || count == 0 || definition.ItemID == 0 {
return 0, errDungeonPickupItemInvalid
}
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
// Revive coins go to the fixed slot-1 wallet, never to an ordinary bag
// row -- same rule as dungeon.PlacePickupItem. This is the chokepoint for
// quest monster drops, altar drops and guild contents.
if dnfrevivecoin.IsReward(int64(definition.ItemID)) {
if _, _, err := dnfrevivecoin.GrantSlots(
inventory.Slots, int64(count), "revive_coin_quest_drop"); err != nil {
return 0, err
}
return dnfrevivecoin.WalletSlot, nil
}
itemID := int64(definition.ItemID)
stackLimit := int64(definition.StackLimit)
if definition.Kind == dungeonDropItemEquipment {
stackLimit = 1
}
// An omitted [stack limit] is the current client's unlimited signed-32-bit
// stack contract. Treating it as 999 caused valid quest materials to split
// into extra rows after the first stack reached that invented threshold.
if definition.Kind == dungeonDropItemStackable && stackLimit <= 0 {
stackLimit = math.MaxInt32
}
if int64(count) > stackLimit {
return 0, fmt.Errorf("%w: item=%d amount=%d stack_limit=%d", errDungeonPickupItemInvalid, definition.ItemID, count, stackLimit)
}
// Use the PVF-defined slot range for the item type. Quest items
// ([quest] stackable) land in slots 177..232 (the quest backpack
// page), materials in 121..176, etc.
slotStart := definition.SlotStart
slotEnd := definition.SlotEnd
if slotStart <= 0 || slotEnd < slotStart {
slotStart, slotEnd = 0, dnfrepo.CrystalWarehouseFirstSlot-1
}
// Try to merge with an existing stack in the item's slot range.
// PVF [stack limit] 是这个容器(角色背包 list 0)里该道具的总量上限:额度按容器里
// 同 id 总量算,容器里已经有该道具的行时不能再开第二行。没写上限的道具沿用旧行为。
newRowStackLimit := int64(0)
questSpace := int64(math.MaxInt32)
if definition.Kind == dungeonDropItemStackable && definition.StackLimit > 0 {
newRowStackLimit = definition.StackLimit
questSpace = dnfrepo.ContainerItemSpace(
inventory.Slots,
dnfrepo.MainInventoryListType,
itemID,
definition.StackLimit,
)
}
if definition.Kind == dungeonDropItemStackable {
for slot := slotStart; slot <= slotEnd; slot++ {
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
stack, exists := inventory.Slots[key]
if !exists || stack.ItemID != itemID || stack.Bind ||
!currentItemStackExpirationMatches(stack, definition.ExpirationDate) {
continue
}
room := stackLimit - stack.Count
if room < int64(count) || int64(count) > questSpace {
continue
}
stack, _ = normalizeCurrentPVFItemStack(stack, definition, time.Now().UTC())
stack.Count += int64(count)
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
if stack.Extra == nil {
stack.Extra = make(map[string]string, 1)
}
stack.Extra["last_grant_source"] = "quest_monster_reward"
inventory.Slots[key] = stack
return slot, nil
}
}
// Find the first empty slot in the item's range.
for slot := slotStart; slot <= slotEnd; slot++ {
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
if _, occupied := inventory.Slots[key]; occupied {
continue
}
if !dnfrepo.CanOpenContainerItemRow(inventory.Slots, dnfrepo.MainInventoryListType, itemID, newRowStackLimit) {
break
}
if _, err := insertCurrentDungeonPickup(inventory, definition, count, slot); err != nil {
return 0, err
}
stack := inventory.Slots[key]
stack.Extra["source"] = "quest_monster_reward"
inventory.Slots[key] = stack
return slot, nil
}
return 0, fmt.Errorf("%w: item=%d", errDungeonPickupInventoryFull, definition.ItemID)
}