服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
675 lines
24 KiB
Go
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 := ¤tDungeonEntryInventoryBatch{
|
|
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, ¤tDungeonEntryInventoryCommit{
|
|
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{}
|
|
}
|