Files
90CNServer/server/internal/services/dnfbridge/dungeon_quest_drop.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

857 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}