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

675 lines
24 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"errors"
"fmt"
"maps"
"math"
"sort"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
var (
errDungeonEntryItemMissing = errors.New("dungeon entry item is missing")
errDungeonEntryItemRestoreConflict = errors.New("dungeon entry item restore conflicts with current inventory")
)
// currentDungeonEntryItemShortage is the structured form of
// errDungeonEntryItemMissing. The current client shows nothing for select
// failure reason 17, so the shortage has to be carried out to a visible notice
// with the exact item and counts; errors.Is on errDungeonEntryItemMissing keeps
// working for every existing caller.
type currentDungeonEntryItemShortage struct {
CharacterID uint16
ItemID int64
Required int64
Available int64
}
func (e currentDungeonEntryItemShortage) Error() string {
return fmt.Sprintf("%v: character=%d item=%d need=%d available=%d",
errDungeonEntryItemMissing, e.CharacterID, e.ItemID, e.Required, e.Available)
}
func (e currentDungeonEntryItemShortage) Unwrap() error {
return errDungeonEntryItemMissing
}
type currentDungeonEntryAdmissionPlan struct {
FreePasses []worldmap.TicketItem
Requirements []worldmap.TicketItem
RequirementAlternatives []worldmap.TicketItem
Source string
// AdmissionCostSource names where the requirement count came from
// (PVF declaration vs the client's level formula); diagnostics only.
AdmissionCostSource string
}
type currentTowerEntryItemDefinition struct {
ItemID int64
PVFPath string
StackableType string
}
// The current PVF authors one canonical [required item] in every layer, while
// older event/shop versions remain distinct inventory item IDs. Only direct
// [material]/[quest] invitations are alternatives here. Invitation boxes and
// [waste] items with [up layer despair dungeon] keep their separate use-item
// owner and must not be consumed again by dungeon admission.
var currentTowerOfDespairEntryItemDefinitions = []currentTowerEntryItemDefinition{
{ItemID: 4473, PVFPath: "stackable/invitation_drapyus2.stk", StackableType: "[material]"},
{ItemID: 490001700, PVFPath: "stackable/490001001/chn_490001700.stk", StackableType: "[material]"},
{ItemID: 490001875, PVFPath: "stackable/490001001/chn_490001875.stk", StackableType: "[material]"},
{ItemID: 490004423, PVFPath: "stackable/490004001/chn_490004423.stk", StackableType: "[material]"},
{ItemID: 490004952, PVFPath: "stackable/490004001/chn_490004952.stk", StackableType: "[material]"},
{ItemID: 490002380, PVFPath: "stackable/490002001/chn_490002380.stk", StackableType: "[quest]"},
{ItemID: 10004955, PVFPath: "stackable/event/chn_event_20150326_simona_event/chn_simona_10004955.stk", StackableType: "[quest]"},
{ItemID: 10005828, PVFPath: "stackable/event/chn_event_20150716_towerofhope/chn_invitation_10005828.stk", StackableType: "[quest]"},
{ItemID: 10006020, PVFPath: "stackable/event/chn_event_20150813_sheepfarm/chn_invitation_10006020.stk", StackableType: "[quest]"},
}
var currentTowerOfGriefEntryItemDefinitions = []currentTowerEntryItemDefinition{
{ItemID: 10161065, PVFPath: "stackable/10161001/10161065.stk", StackableType: "[material]"},
}
type currentDungeonEntryInventoryCommit struct {
session *gameSession
updates []currentItemListEntry
restore func() error
restored bool
}
// currentDungeonEntryInventoryBatch is the admission boundary shared by every
// member of a party. Each character-owned inventory is persisted before any
// runtime is committed; if a later member cannot pay, or a runtime commit
// fails, the already persisted deductions are compensated in reverse order.
//
// The repositories do not expose a cross-character transaction. Each member
// therefore retains only the exact consumed-slot before/post-images. A later
// compensation can restore those deltas without replacing unrelated inventory
// changes that committed after this member left its session mailbox.
type currentDungeonEntryInventoryBatch struct {
commits []*currentDungeonEntryInventoryCommit
}
type currentDungeonEntryInventorySlotMutation struct {
key string
before dnfrepo.ItemStack
beforeExists bool
after dnfrepo.ItemStack
afterExists bool
}
func (batch *currentDungeonEntryInventoryBatch) updatesFor(session *gameSession) []currentItemListEntry {
if batch == nil || session == nil {
return nil
}
for _, commit := range batch.commits {
if commit != nil && commit.session == session {
return append([]currentItemListEntry(nil), commit.updates...)
}
}
return nil
}
func (s *Service) restoreCurrentDungeonEntryInventoryBatch(
batch *currentDungeonEntryInventoryBatch,
) error {
if batch == nil {
return nil
}
var restoreErr error
for index := len(batch.commits) - 1; index >= 0; index-- {
commit := batch.commits[index]
if commit == nil || commit.restore == nil || commit.restored {
continue
}
if err := commit.restore(); err != nil {
characterID := uint16(0)
if commit.session != nil {
characterID = commit.session.selectedCharacterID
s.logGameEvent(commit.session, "game-dungeon-entry-item-restore-failed",
"char_id", characterID,
"error", err)
}
restoreErr = errors.Join(restoreErr, fmt.Errorf(
"restore dungeon admission inventory for character %d: %w", characterID, err))
continue
}
commit.restored = true
}
return restoreErr
}
func (s *Service) consumeCurrentDungeonEntryPartyItems(
ctx context.Context,
leader *gameSession,
leaderRuntime *runtimeDungeonState,
preparedFollowers []*runtimePartyPreparedDungeonEntry,
) (*currentDungeonEntryInventoryBatch, error) {
batch := &currentDungeonEntryInventoryBatch{
commits: make([]*currentDungeonEntryInventoryCommit, 0, len(preparedFollowers)+1),
}
consume := func(session *gameSession, runtime *runtimeDungeonState) error {
if session == nil || runtime == nil || currentDungeonRuntimeIsPractice(runtime) {
return nil
}
updates, restore, err := s.consumeCurrentDungeonEntryItems(ctx, session, runtime)
if err != nil {
return err
}
if restore == nil {
return nil
}
batch.commits = append(batch.commits, &currentDungeonEntryInventoryCommit{
session: session,
updates: append([]currentItemListEntry(nil), updates...),
restore: restore,
})
return nil
}
if err := consume(leader, leaderRuntime); err != nil {
return nil, err
}
for _, prepared := range preparedFollowers {
if prepared == nil || prepared.session == nil || prepared.runtime == nil {
err := errors.New("prepared party member admission is incomplete")
if restoreErr := s.restoreCurrentDungeonEntryInventoryBatch(batch); restoreErr != nil {
err = errors.Join(err, restoreErr)
}
return nil, err
}
var consumeErr error
err := s.callGameSession(ctx, prepared.session, "runtime-party-dungeon-admission-inventory", func() error {
consumeErr = consume(prepared.session, prepared.runtime)
return consumeErr
})
if err != nil {
if consumeErr == nil {
consumeErr = err
} else if !errors.Is(err, consumeErr) {
consumeErr = errors.Join(consumeErr, err)
}
if restoreErr := s.restoreCurrentDungeonEntryInventoryBatch(batch); restoreErr != nil {
consumeErr = errors.Join(consumeErr, restoreErr)
}
return nil, fmt.Errorf(
"consume party member %d dungeon admission: %w",
prepared.session.selectedCharacterID,
consumeErr,
)
}
}
for _, prepared := range preparedFollowers {
prepared.entryItemUpdates = batch.updatesFor(prepared.session)
}
return batch, nil
}
func (s *Service) resolveCurrentDungeonEntryAdmissionPlan(
runtime *runtimeDungeonState,
) (currentDungeonEntryAdmissionPlan, error) {
if runtime == nil {
return currentDungeonEntryAdmissionPlan{}, nil
}
if len(runtime.Dungeon.Metadata.EventRequiredItems) != 0 || len(runtime.Dungeon.Metadata.AddedRequiredItems) != 0 {
return currentDungeonEntryAdmissionPlan{}, fmt.Errorf(
"dungeon %d has event/schedule admission items without a proven current op16 selector",
runtime.Dungeon.ID,
)
}
plan := currentDungeonEntryAdmissionPlan{
Requirements: append([]worldmap.TicketItem(nil), runtime.Dungeon.Metadata.RequiredItems...),
Source: "dungeon_required_item",
}
if alternatives, source, err := s.resolveCurrentTowerEntryItemAlternatives(
runtime.Dungeon.ID,
plan.Requirements,
); err != nil {
return currentDungeonEntryAdmissionPlan{}, err
} else if len(alternatives) != 0 {
plan.Requirements = nil
plan.RequirementAlternatives = alternatives
plan.Source = source
}
particularHell := normalizeDungeonPVFSymbol(runtime.Dungeon.Metadata.DungeonType) == "particular hell"
inferno := runtime.Dungeon.Metadata.InfernoDungeon
ordinaryHell := currentDungeonExplicitHellPartyRequest(runtime.Request, runtime.Dungeon, runtime.MazeIndex)
if !particularHell && !inferno && !ordinaryHell {
return validateCurrentDungeonEntryAdmissionPlan(plan)
}
if s == nil {
return currentDungeonEntryAdmissionPlan{}, errDungeonWorldMapUnavailable
}
s.worldMapMu.RLock()
table := s.worldMapTable
s.worldMapMu.RUnlock()
if table == nil {
return currentDungeonEntryAdmissionPlan{}, errDungeonWorldMapUnavailable
}
area, found := table.FindAreaByDungeon(runtime.Dungeon.ID)
if !found {
return currentDungeonEntryAdmissionPlan{}, fmt.Errorf("%w: dungeon=%d has no owning area", errDungeonWorldMapUnavailable, runtime.Dungeon.ID)
}
switch {
case particularHell:
plan.FreePasses = append([]worldmap.TicketItem(nil), area.ParticularHellFreePassItems...)
plan.Source = "worldmap_particularhell_freepass_or_dungeon_required_item"
case inferno:
plan.FreePasses = append([]worldmap.TicketItem(nil), area.InfernoFreePassItems...)
plan.Source = "worldmap_inferno_freepass_or_item_condition"
case ordinaryHell:
plan.FreePasses = append([]worldmap.TicketItem(nil), area.HellFreePassItems...)
plan.Source = "worldmap_hell_freepass_or_item_condition"
}
plan.FreePasses = uniqueCurrentDungeonEntryItems(plan.FreePasses)
if len(plan.Requirements) == 0 && len(plan.RequirementAlternatives) == 0 && (inferno || ordinaryHell) {
conditions := uniquePositiveInt64(area.ItemConditions)
if len(conditions) != 1 {
return currentDungeonEntryAdmissionPlan{}, fmt.Errorf(
"invalid PVF hell admission item condition dungeon=%d item_conditions=%v",
runtime.Dungeon.ID,
conditions,
)
}
// 194/205 个深渊区域副本没有 [hell party required item count];没有声明值时按
// 客户端同一条等级式推导(见 dungeon_hell_admission_cost.go),否则 193 这类副本
// 的入场计划建立不起来,玩家点下去什么回执都收不到。
count, costSource, err := currentDungeonHellAdmissionInvitationCount(runtime.Dungeon)
if err != nil {
return currentDungeonEntryAdmissionPlan{}, fmt.Errorf(
"invalid PVF hell admission cost dungeon=%d item_conditions=%v: %w",
runtime.Dungeon.ID,
conditions,
err,
)
}
plan.Requirements = []worldmap.TicketItem{{ItemID: conditions[0], Count: count}}
plan.AdmissionCostSource = costSource
}
return validateCurrentDungeonEntryAdmissionPlan(plan)
}
func validateCurrentDungeonEntryAdmissionPlan(plan currentDungeonEntryAdmissionPlan) (currentDungeonEntryAdmissionPlan, error) {
plan.Requirements = uniqueCurrentDungeonEntryItems(plan.Requirements)
plan.RequirementAlternatives = uniqueCurrentDungeonEntryItems(plan.RequirementAlternatives)
for _, group := range [][]worldmap.TicketItem{plan.FreePasses, plan.Requirements, plan.RequirementAlternatives} {
for _, item := range group {
if item.ItemID <= 0 || item.Count <= 0 {
return currentDungeonEntryAdmissionPlan{}, fmt.Errorf("invalid dungeon entry item id=%d count=%d", item.ItemID, item.Count)
}
}
}
if len(plan.FreePasses) != 0 && len(plan.Requirements) == 0 && len(plan.RequirementAlternatives) == 0 {
return currentDungeonEntryAdmissionPlan{}, fmt.Errorf("PVF dungeon entry freepass has no fallback material contract")
}
if len(plan.Requirements) != 0 && len(plan.RequirementAlternatives) != 0 {
return currentDungeonEntryAdmissionPlan{}, fmt.Errorf("dungeon entry has both conjunctive and alternative item contracts")
}
return plan, nil
}
func (s *Service) resolveCurrentTowerEntryItemAlternatives(
dungeonID int64,
requirements []worldmap.TicketItem,
) ([]worldmap.TicketItem, string, error) {
if s == nil || dungeonID <= 0 || dungeonID > math.MaxUint32 {
return nil, "", nil
}
s.worldMapMu.RLock()
catalog := s.dungeonAdmissionCatalog
s.worldMapMu.RUnlock()
if catalog == nil {
return nil, "", nil
}
var definitions []currentTowerEntryItemDefinition
var source string
for _, policy := range catalog.PoliciesForDungeon(uint32(dungeonID)) {
switch policy.Key {
case "tower_of_despair.character.daily", "tower_of_despair.account.daily":
if source != "" && source != "tower_of_despair_entry_item_alternatives" {
return nil, "", fmt.Errorf("dungeon %d belongs to conflicting tower admission families", dungeonID)
}
definitions = currentTowerOfDespairEntryItemDefinitions
source = "tower_of_despair_entry_item_alternatives"
case "tower_of_grief.character.daily", "tower_of_grief.account.daily":
if source != "" && source != "tower_of_grief_entry_item_alternatives" {
return nil, "", fmt.Errorf("dungeon %d belongs to conflicting tower admission families", dungeonID)
}
definitions = currentTowerOfGriefEntryItemDefinitions
source = "tower_of_grief_entry_item_alternatives"
}
}
if len(definitions) == 0 {
return nil, "", nil
}
canonical := worldmap.TicketItem{ItemID: definitions[0].ItemID, Count: 1}
if len(requirements) != 1 || requirements[0] != canonical {
return nil, "", fmt.Errorf(
"tower dungeon %d required-item contract=%v want canonical=%v",
dungeonID,
requirements,
canonical,
)
}
alternatives := make([]worldmap.TicketItem, 0, len(definitions))
for _, definition := range definitions {
alternatives = append(alternatives, worldmap.TicketItem{ItemID: definition.ItemID, Count: 1})
}
return alternatives, source, nil
}
func uniqueCurrentDungeonEntryItems(items []worldmap.TicketItem) []worldmap.TicketItem {
if len(items) == 0 {
return nil
}
type itemKey struct {
itemID int64
count int64
}
seen := make(map[itemKey]struct{}, len(items))
result := make([]worldmap.TicketItem, 0, len(items))
for _, item := range items {
key := itemKey{itemID: item.ItemID, count: item.Count}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
result = append(result, item)
}
return result
}
func uniquePositiveInt64(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
// consumeCurrentDungeonEntryItems applies the owning world's authored
// freepass category first, then the dungeon/area fallback cost, to the
// character-owned inventory. Account/shared warehouses are not searched
// because the client admission projection is backed by main inventory.
func (s *Service) consumeCurrentDungeonEntryItems(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
) ([]currentItemListEntry, func() error, error) {
if runtime == nil || session == nil {
return nil, nil, nil
}
repositories, ok := s.repositoryGroup()
if !ok {
return nil, nil, errDungeonWorldMapUnavailable
}
if ctx == nil {
ctx = context.Background()
}
plan, err := s.resolveCurrentDungeonEntryAdmissionPlan(runtime)
if err != nil {
return nil, nil, err
}
if len(plan.FreePasses) == 0 && len(plan.Requirements) == 0 && len(plan.RequirementAlternatives) == 0 {
return nil, nil, nil
}
updates := make([]currentItemListEntry, 0)
mutations := make([]currentDungeonEntryInventorySlotMutation, 0)
var consumed []worldmap.TicketItem
usedFreePass := false
owner, err := dnfdungeon.NewOwner(repositories)
if err != nil {
return nil, nil, err
}
_, err = owner.MutateInventory(ctx, dnfdungeon.InventoryMutationCommand{
CharacterID: fmt.Sprintf("%d", session.selectedCharacterID),
Apply: func(inventory *dnfrepo.InventoryRecord) (bool, error) {
if inventory == nil {
return false, errDungeonEntryItemMissing
}
mutations = mutations[:0]
selectedItems, freePass, selectErr := selectCurrentDungeonEntryItems(inventory.Slots, plan)
if selectErr != nil {
var shortage currentDungeonEntryItemShortage
if errors.As(selectErr, &shortage) {
shortage.CharacterID = session.selectedCharacterID
selectErr = shortage
}
return false, selectErr
}
consumed = selectedItems
usedFreePass = freePass
keys := currentDungeonEntryOwnedKeys(inventory.Slots)
requirements := make(map[int64]int64, len(consumed))
for _, required := range consumed {
requirements[required.ItemID] += required.Count
}
itemIDs := make([]int64, 0, len(requirements))
for itemID := range requirements {
itemIDs = append(itemIDs, itemID)
}
sort.Slice(itemIDs, func(i, j int) bool { return itemIDs[i] < itemIDs[j] })
for _, itemID := range itemIDs {
remaining := requirements[itemID]
for _, key := range keys {
if remaining == 0 {
break
}
stack := inventory.Slots[key]
if stack.ItemID != itemID || stack.Count <= 0 {
continue
}
beforeStack := cloneCurrentDungeonEntryInventoryStack(stack)
listType, slot, _ := parseSceneInventorySlotKey(key)
used := stack.Count
if used > remaining {
used = remaining
}
stack.Count -= used
remaining -= used
if stack.Count == 0 {
delete(inventory.Slots, key)
mutations = append(mutations, currentDungeonEntryInventorySlotMutation{
key: key, before: beforeStack, beforeExists: true,
})
var removed currentItemListEntry
removed.patchCore(slot, math.MaxUint32, 0)
updates = append(updates, removed)
} else {
inventory.Slots[key] = stack
mutations = append(mutations, currentDungeonEntryInventorySlotMutation{
key: key, before: beforeStack, beforeExists: true,
after: cloneCurrentDungeonEntryInventoryStack(stack), afterExists: true,
})
updates = append(updates, currentItemListEntryFromStack(listType, slot, stack))
}
}
if remaining != 0 {
return false, errDungeonEntryItemMissing
}
}
return len(updates) != 0, nil
},
})
if err != nil {
return nil, nil, err
}
if len(updates) == 0 {
return nil, nil, nil
}
restore := func() error {
restoreCtx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
_, restoreErr := owner.MutateInventory(restoreCtx, dnfdungeon.InventoryMutationCommand{
CharacterID: fmt.Sprintf("%d", session.selectedCharacterID),
Apply: func(inventory *dnfrepo.InventoryRecord) (bool, error) {
if inventory == nil {
return false, errDungeonEntryItemMissing
}
// Validate every post-image before changing any slot. One conflict
// fails the repository transaction without a partial compensation.
for _, mutation := range mutations {
current, found := inventory.Slots[mutation.key]
if found != mutation.afterExists ||
(found && !sameCurrentDungeonEntryInventoryStack(current, mutation.after)) {
return false, fmt.Errorf("%w: slot=%s", errDungeonEntryItemRestoreConflict, mutation.key)
}
}
if inventory.Slots == nil && len(mutations) != 0 {
inventory.Slots = make(map[string]dnfrepo.ItemStack, len(mutations))
}
for _, mutation := range mutations {
if mutation.beforeExists {
inventory.Slots[mutation.key] = cloneCurrentDungeonEntryInventoryStack(mutation.before)
} else {
delete(inventory.Slots, mutation.key)
}
}
return len(mutations) != 0, nil
},
})
return restoreErr
}
s.logGameEvent(session, "game-dungeon-entry-items-consumed",
"dungeon_id", runtime.Dungeon.ID,
"consumed", consumed,
"freepass", usedFreePass,
"update_count", len(updates),
"source", plan.Source,
"admission_cost_source", plan.AdmissionCostSource)
return updates, restore, nil
}
func cloneCurrentDungeonEntryInventoryStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
stack.Extra = maps.Clone(stack.Extra)
return stack
}
func sameCurrentDungeonEntryInventoryStack(left, right dnfrepo.ItemStack) bool {
return left.ItemID == right.ItemID && left.Count == right.Count && left.Bind == right.Bind &&
left.ExpireAt.Equal(right.ExpireAt) && bytes.Equal(left.RawEntry, right.RawEntry) &&
maps.Equal(left.Extra, right.Extra)
}
// currentDungeonEntryOwnedKeys lists the character-owned list-0 rows one entry
// may consume from, in the sorted order both the selector below and the consume
// loop walk. Account/shared warehouses are deliberately not searched: the client
// admission projection is backed by main inventory.
func currentDungeonEntryOwnedKeys(slots map[string]dnfrepo.ItemStack) []string {
keys := make([]string, 0, len(slots))
for key, stack := range slots {
listType, _, valid := parseSceneInventorySlotKey(key)
if valid && listType == dnfrepo.MainInventoryListType && stack.ItemID > 0 && stack.Count > 0 {
keys = append(keys, key)
}
}
sort.Strings(keys)
return keys
}
// selectCurrentDungeonEntryItems decides which authored admission rows one
// attempt consumes: the owning area's freepass category first, then the
// dungeon/area alternatives, then the conjunctive cost -- and reports the same
// currentDungeonEntryItemShortage the consume path reports when the player
// cannot pay.
//
// It is pure on purpose. The read-only retry preflight and the consuming entry
// path both call it, so "the preflight said the player can pay" and "the consume
// found the item" can never disagree. Live 2026-09-14 22:22 is what the
// disagreement cost: 再次挑战 passed a preflight that never looked at entry
// items, retired the completed run, and only then failed the consume -- leaving
// the client on the settlement page with no runtime, so 返回城镇 did nothing.
func selectCurrentDungeonEntryItems(
slots map[string]dnfrepo.ItemStack,
plan currentDungeonEntryAdmissionPlan,
) ([]worldmap.TicketItem, bool, error) {
keys := currentDungeonEntryOwnedKeys(slots)
if selected := firstAvailableCurrentDungeonEntryItem(slots, keys, plan.FreePasses); selected.ItemID > 0 {
return []worldmap.TicketItem{selected}, true, nil
}
if len(plan.RequirementAlternatives) != 0 {
selected := firstAvailableCurrentDungeonEntryItem(slots, keys, plan.RequirementAlternatives)
if selected.ItemID <= 0 {
// Report the first alternative: it is the item the area's PVF names
// first, and the one the player is told to bring.
first := plan.RequirementAlternatives[0]
available := int64(0)
for _, key := range keys {
if stack := slots[key]; stack.ItemID == first.ItemID && stack.Count > 0 {
available += stack.Count
}
}
return nil, false, currentDungeonEntryItemShortage{
ItemID: first.ItemID,
Required: first.Count,
Available: available,
}
}
return []worldmap.TicketItem{selected}, false, nil
}
requirements := make(map[int64]int64, len(plan.Requirements))
for _, required := range plan.Requirements {
requirements[required.ItemID] += required.Count
}
itemIDs := make([]int64, 0, len(requirements))
for itemID := range requirements {
itemIDs = append(itemIDs, itemID)
}
sort.Slice(itemIDs, func(i, j int) bool { return itemIDs[i] < itemIDs[j] })
for _, itemID := range itemIDs {
available := int64(0)
for _, key := range keys {
stack := slots[key]
if stack.ItemID != itemID || stack.Count <= 0 {
continue
}
available += stack.Count
}
if available < requirements[itemID] {
return nil, false, currentDungeonEntryItemShortage{
ItemID: itemID,
Required: requirements[itemID],
Available: available,
}
}
}
return append([]worldmap.TicketItem(nil), plan.Requirements...), false, nil
}
func firstAvailableCurrentDungeonEntryItem(
slots map[string]dnfrepo.ItemStack,
keys []string,
candidates []worldmap.TicketItem,
) worldmap.TicketItem {
for _, candidate := range candidates {
available := int64(0)
for _, key := range keys {
stack := slots[key]
if stack.ItemID == candidate.ItemID && stack.Count > 0 {
available += stack.Count
}
}
if available >= candidate.Count {
return candidate
}
}
return worldmap.TicketItem{}
}