DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,830 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfdrops "longheng.io/server/internal/modules/dnf/drop"
|
||||
"longheng.io/server/internal/modules/dnf/premium"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/worldmap"
|
||||
)
|
||||
|
||||
var currentDungeonCardGoldDifficultyBonus = [...]float64{1.02, 1.38, 1.60, 1.90, 2.0}
|
||||
|
||||
const currentDungeonGoldCardItemMultiplier int64 = 2
|
||||
|
||||
// currentDungeonGoldCardUse 读副本 PVF 的 [gold card use]。
|
||||
// 该段被 worldmap 收进 DungeonMetadata.IntegerValues。
|
||||
//
|
||||
// 第二个返回值区分**「显式声明 0」**与**「未声明该段」**,因为二者含义相反:
|
||||
//
|
||||
// 显式 0 → 策划明确要求关闭付费翻牌(周常五图 4906~4910 都是这样)
|
||||
// 未声明 → 老副本没有这个字段,应当**保持原有付费翻牌**,不能当成 0
|
||||
//
|
||||
// 过去只用普通 map 取值,未声明时会得到零值 0,导致「未声明」被误判成
|
||||
// 「显式 0」,绝大多数老副本(例如 Act1 系列)的付费翻牌被整条关闭 ——
|
||||
// 现场表现为结算时下面 4 张黄金卡牌点击后不扣费、奖励也不入包。
|
||||
func currentDungeonGoldCardUse(dungeon worldmap.Dungeon) (value int64, declared bool) {
|
||||
if dungeon.Metadata.IntegerValues == nil {
|
||||
return 0, false
|
||||
}
|
||||
value, declared = dungeon.Metadata.IntegerValues["gold card use"]
|
||||
return value, declared
|
||||
}
|
||||
|
||||
func (s *Service) freezeCurrentDungeonCardRewardStateLocked(
|
||||
session *gameSession,
|
||||
runtime *runtimeDungeonState,
|
||||
) error {
|
||||
if s == nil || session == nil || runtime == nil || session.dungeon.runtime != runtime {
|
||||
return errDungeonCardAssetOwnerUnavailable
|
||||
}
|
||||
if currentDungeonTowerRewardMsgID(runtime) != 0 || runtime.settlementCardRewardState != nil {
|
||||
return nil
|
||||
}
|
||||
monsterCatalog, err := s.dungeonMonsterCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
goldReferences, err := currentDungeonGoldReferences(monsterCatalog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !runtime.Dungeon.Metadata.BasisLevel.Set || runtime.Dungeon.Metadata.BasisLevel.Value <= 0 {
|
||||
return errCurrentDungeonSettlementPVFInvalid
|
||||
}
|
||||
dungeonLevel := int(runtime.Dungeon.Metadata.BasisLevel.Value)
|
||||
goldReference, found := goldReferences[int64(dungeonLevel)]
|
||||
if !found {
|
||||
return fmt.Errorf("%w: dungeon_level=%d", errCurrentDungeonGoldReferenceInvalid, runtime.Dungeon.Metadata.BasisLevel.Value)
|
||||
}
|
||||
itemCatalog, err := monsterCatalog.DropCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clearRewardRules, err := currentDungeonClearRewardRules(monsterCatalog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
partyMembers := 1
|
||||
if runtime.partyDungeonOwner != nil {
|
||||
partyMembers = len(runtime.partyDungeonOwner.roster())
|
||||
}
|
||||
visitedRooms := len(runtime.Rooms)
|
||||
clearRewardRule, err := clearRewardRules.Resolve(
|
||||
int64(dungeonLevel),
|
||||
int(runtime.Request.Difficulty),
|
||||
partyMembers,
|
||||
visitedRooms,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Freeze the commission beside the plan it prices. Resolve already proves
|
||||
// [gold card cost table] covers this level, so the value is the same one the
|
||||
// 86JP reference hands the client in op35's PAID-CARD COMMISSION field.
|
||||
runtime.settlementPaidCardCost = clearRewardRule.PaidCardCost
|
||||
goldTerms := currentDungeonCardGoldTerms{
|
||||
Difficulty: int(runtime.Request.Difficulty),
|
||||
PartyMembers: partyMembers,
|
||||
PartyRate: clearRewardRules.PartyMemberRate(partyMembers),
|
||||
ClearRankRate: s.currentDungeonClearRankGoldRate(runtime),
|
||||
WeightedKills: runtime.currentDungeonClearWeightedKills(),
|
||||
}
|
||||
dimensionCatalog, err := currentDungeonDimensionDrops(monsterCatalog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dimensionChronicleActive := false
|
||||
characterJob := byte(0)
|
||||
characterGrowType := byte(runtime.Character.Stats["grow_type"]) & 0x0f
|
||||
if _, dimensionMatched := currentDungeonDimensionTier(
|
||||
dimensionCatalog,
|
||||
runtime.Dungeon.ID,
|
||||
runtime.Character.Level,
|
||||
); dimensionMatched && len(dimensionCatalog.ChronicleByCareer) > 0 {
|
||||
jobValue, parseErr := strconv.ParseUint(strings.TrimSpace(runtime.Character.Job), 10, 8)
|
||||
if parseErr != nil {
|
||||
return fmt.Errorf("%w: dimension character job=%q", errCurrentDungeonDimensionDropInvalid, runtime.Character.Job)
|
||||
}
|
||||
characterJob = byte(jobValue)
|
||||
dimensionChronicleActive = true
|
||||
}
|
||||
|
||||
rng := newCurrentDungeonDropLCG(runtime.Seed)
|
||||
var visit *runtimeDungeonRoomVisit
|
||||
if runtime.Session != nil {
|
||||
if scene, ok := runtime.Session.Scene(); ok {
|
||||
if ownedVisit, visitErr := runtime.currentDungeonRoomVisit(scene); visitErr == nil {
|
||||
visit = ownedVisit
|
||||
rng = newCurrentDungeonDropLCG(ownedVisit.DropRNG)
|
||||
}
|
||||
}
|
||||
}
|
||||
now := s.gameplayNow().UTC()
|
||||
randomReward := func() (dungeonCardRewardBundle, error) {
|
||||
if dimensionChronicleActive {
|
||||
return currentDungeonRandomDimensionCardRewardBundle(
|
||||
rng,
|
||||
goldReference,
|
||||
goldTerms,
|
||||
itemCatalog,
|
||||
dimensionCatalog,
|
||||
runtime.Dungeon.ID,
|
||||
runtime.Character.Level,
|
||||
characterJob,
|
||||
characterGrowType,
|
||||
now,
|
||||
)
|
||||
}
|
||||
return currentDungeonRandomCardRewardBundle(
|
||||
rng,
|
||||
goldReference,
|
||||
goldTerms,
|
||||
itemCatalog,
|
||||
dungeonLevel,
|
||||
clearRewardRule,
|
||||
now,
|
||||
)
|
||||
}
|
||||
free, err := randomReward()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paid, err := randomReward()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
_, account, character, premiumErr := s.currentPremiumServiceRecords(ctx, session)
|
||||
cancel()
|
||||
var blackDiamond [dungeonCardSlotsPerSide]dungeonCardRewardBundle
|
||||
blackDiamondActive := s.currentBlackDiamondActive(session)
|
||||
if blackDiamondActive {
|
||||
for member := range blackDiamond {
|
||||
blackDiamond[member], err = randomReward()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
waivePaidCardCost := false
|
||||
if premiumErr == nil &&
|
||||
premium.Active(account, premium.DevilSlotType(premium.DevilSlotGoldCard), now) &&
|
||||
premium.DailyUsage(character, premium.DevilSlotGoldCard, now) < premium.DailyLimit(premium.DevilSlotGoldCard) {
|
||||
paid.ConsumePremiumDaily = true
|
||||
paid.PremiumDailySlot = premium.DevilSlotGoldCard
|
||||
// The purchased 魔王契约 黄金卡牌免费翻 service pays the commission for
|
||||
// this flip. 86JP waives the same charge through
|
||||
// DevilContractBenefitPolicy.GoldCardSlot, whose daily limit of 10
|
||||
// matches DailyLimit(DevilSlotGoldCard) above.
|
||||
waivePaidCardCost = true
|
||||
if err := applyCurrentDungeonGoldCardItemMultiplier(&paid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if visit != nil {
|
||||
visit.DropRNG = rng.Seed()
|
||||
}
|
||||
plan, err := newDungeonCardRewardPlanWithBlackDiamond(
|
||||
dungeonCardPlanIdentity{
|
||||
CharacterID: strconv.Itoa(int(session.selectedCharacterID)),
|
||||
DungeonID: runtime.Dungeon.ID,
|
||||
MazeIndex: runtime.MazeIndex,
|
||||
RunSeed: runtime.Seed,
|
||||
},
|
||||
"current_pvf_clear_reward_generator_free_and_paid_rows",
|
||||
free,
|
||||
paid,
|
||||
blackDiamond,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The commission printed under 黄金卡牌 N is the same number spent when that
|
||||
// card is flipped, so it is frozen beside the plan: the client is told the
|
||||
// list price whether or not the contract pays it, and a replay cannot charge
|
||||
// a different amount than it displayed.
|
||||
plan.PaidCardCost = clearRewardRule.PaidCardCost
|
||||
if waivePaidCardCost {
|
||||
plan.PaidCardCost = 0
|
||||
}
|
||||
// 【2026-10-16 修正】只有**显式声明** [gold card use] 0 才整条关闭付费翻牌。
|
||||
//
|
||||
// 周常五图(4906~4910)显式写 0,报告要求"按五图 gold card use=0 禁用付费翻牌,
|
||||
// 免费奖励保留原流程"。只置 0 费用不够:那样付费行仍可翻且不收费,等于把付费
|
||||
// 奖励白送。所以显式 0 时必须同时把该侧标为禁用,reserveSelection 会拒绝它。
|
||||
//
|
||||
// 但**未声明**该段的副本(Act1 系列等绝大多数老图)必须保持付费翻牌可用:
|
||||
// 过去把未声明也当成 0,导致这些副本的付费侧被误关,现场表现为结算时下面
|
||||
// 4 张黄金卡牌点击后不扣次数、奖励也不入包。
|
||||
if value, declared := currentDungeonGoldCardUse(runtime.Dungeon); declared && value == 0 {
|
||||
plan.PaidCardCost = 0
|
||||
plan.PaidCardDisabled = true
|
||||
}
|
||||
state, err := newDungeonCardState(plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.settlementCardRewardState = state
|
||||
s.logGameEvent(session, "game-dungeon-card-reward-plan-frozen",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"dungeon_id", runtime.Dungeon.ID,
|
||||
"plan_id", plan.ID,
|
||||
"free_gold", free.Gold,
|
||||
"free_item_count", len(free.Items),
|
||||
"paid_gold", paid.Gold,
|
||||
"paid_item_count", len(paid.Items),
|
||||
"black_diamond_active", blackDiamondActive,
|
||||
"black_diamond_reward_seats", currentDungeonCardNonEmptyBundleCount(blackDiamond[:]),
|
||||
"premium_gold_card_available", paid.ConsumePremiumDaily,
|
||||
"source", plan.Source,
|
||||
"dimension_chronicle_active", dimensionChronicleActive,
|
||||
"dimension_chronicle_job", characterJob,
|
||||
"dimension_chronicle_grow_type", characterGrowType,
|
||||
"clear_reward_equipment_rate", clearRewardRule.EquipmentRate,
|
||||
"clear_reward_map_count", clearRewardRule.MapCount,
|
||||
"clear_reward_party_members", partyMembers,
|
||||
"domain_source", "current_pvf_clear_reward_equipment_rule")
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentDungeonRandomCardRewardBundle(
|
||||
rng *currentDungeonDropLCG,
|
||||
goldReference currentDungeonGoldReference,
|
||||
goldTerms currentDungeonCardGoldTerms,
|
||||
itemCatalog *pvfDungeonDropCatalog,
|
||||
dungeonLevel int,
|
||||
clearRewardRule dnfdrops.ClearRewardRule,
|
||||
now time.Time,
|
||||
) (dungeonCardRewardBundle, error) {
|
||||
bundle := dungeonCardRewardBundle{
|
||||
Gold: currentDungeonCardGoldAmount(rng, goldReference, goldTerms),
|
||||
}
|
||||
item, found, err := currentDungeonRandomClearCardEquipment(
|
||||
rng,
|
||||
itemCatalog,
|
||||
dungeonLevel,
|
||||
clearRewardRule,
|
||||
now,
|
||||
)
|
||||
if err != nil {
|
||||
return dungeonCardRewardBundle{}, err
|
||||
}
|
||||
if found {
|
||||
bundle.Items = []dungeonCardItemReward{item}
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func currentDungeonRandomClearCardEquipment(
|
||||
rng *currentDungeonDropLCG,
|
||||
catalog *pvfDungeonDropCatalog,
|
||||
dungeonLevel int,
|
||||
rule dnfdrops.ClearRewardRule,
|
||||
now time.Time,
|
||||
) (dungeonCardItemReward, bool, error) {
|
||||
if rng == nil || catalog == nil || dungeonLevel <= 0 || rule.RarityScale == 0 {
|
||||
return dungeonCardItemReward{}, false, errDungeonCardPVFSourceRequired
|
||||
}
|
||||
if uint32(rng.Next(int(dnfdrops.ClearRewardItemRateDenominator))) >= rule.EquipmentRate {
|
||||
return dungeonCardItemReward{}, false, nil
|
||||
}
|
||||
rarityRoll := uint32(rng.Next(int(rule.RarityScale)))
|
||||
rarity := currentDungeonClearCardEquipmentRarity(rule.RarityThresholds, rarityRoll)
|
||||
itemID, found, err := catalog.SelectGenericEquipment(
|
||||
rng,
|
||||
dungeonLevel,
|
||||
rule.GradeDown,
|
||||
rule.GradeUp,
|
||||
int64(rarity),
|
||||
)
|
||||
if err != nil {
|
||||
return dungeonCardItemReward{}, false, err
|
||||
}
|
||||
if !found || itemID == 0 {
|
||||
// The native clear-card pool is equipment-only. A missing equipment
|
||||
// candidate leaves this card gold-only; it must never fall through to
|
||||
// the generic stackable pool (bombs, discs, boomerangs, and materials).
|
||||
return dungeonCardItemReward{}, false, nil
|
||||
}
|
||||
reward, err := currentDungeonCardItemReward(rng, catalog, itemID, now)
|
||||
return reward, err == nil, err
|
||||
}
|
||||
|
||||
func currentDungeonClearCardEquipmentRarity(thresholds [7]uint32, roll uint32) int {
|
||||
for rarity, threshold := range thresholds {
|
||||
if roll < threshold {
|
||||
return rarity
|
||||
}
|
||||
}
|
||||
return len(thresholds) - 1
|
||||
}
|
||||
|
||||
func currentDungeonRandomDimensionCardRewardBundle(
|
||||
rng *currentDungeonDropLCG,
|
||||
goldReference currentDungeonGoldReference,
|
||||
goldTerms currentDungeonCardGoldTerms,
|
||||
itemCatalog *pvfDungeonDropCatalog,
|
||||
dimensionCatalog *currentDungeonDimensionDropCatalog,
|
||||
dungeonID int64,
|
||||
characterLevel int,
|
||||
job byte,
|
||||
growType byte,
|
||||
now time.Time,
|
||||
) (dungeonCardRewardBundle, error) {
|
||||
bundle := dungeonCardRewardBundle{
|
||||
Gold: currentDungeonCardGoldAmount(rng, goldReference, goldTerms),
|
||||
}
|
||||
_, itemID, _, matched, selected, err := currentDungeonDimensionChronicleItem(
|
||||
dimensionCatalog,
|
||||
dungeonID,
|
||||
characterLevel,
|
||||
job,
|
||||
growType,
|
||||
rng,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return dungeonCardRewardBundle{}, err
|
||||
}
|
||||
if !matched {
|
||||
return dungeonCardRewardBundle{}, fmt.Errorf(
|
||||
"%w: dimension clear reward dungeon_id=%d",
|
||||
errCurrentDungeonDimensionDropInvalid,
|
||||
dungeonID,
|
||||
)
|
||||
}
|
||||
if selected {
|
||||
item, resolveErr := currentDungeonCardItemReward(rng, itemCatalog, itemID, now)
|
||||
if resolveErr != nil {
|
||||
return dungeonCardRewardBundle{}, resolveErr
|
||||
}
|
||||
bundle.Items = []dungeonCardItemReward{item}
|
||||
} else {
|
||||
bundle.Gold += 100
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func currentDungeonCardNonEmptyBundleCount(bundles []dungeonCardRewardBundle) int {
|
||||
count := 0
|
||||
for _, bundle := range bundles {
|
||||
if bundle.Gold > 0 || len(bundle.Items) > 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// applyCurrentDungeonGoldCardItemMultiplier turns the current EXE's visible
|
||||
// x2 Gold Card result into two durable item units. Stackables stay one reward
|
||||
// row with a doubled count. Equipment is cloned into independent count-one
|
||||
// instances because equipment must never be stored as a stack.
|
||||
func applyCurrentDungeonGoldCardItemMultiplier(bundle *dungeonCardRewardBundle) error {
|
||||
if bundle == nil || len(bundle.Items) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := make([]dungeonCardItemReward, 0, len(bundle.Items)*int(currentDungeonGoldCardItemMultiplier))
|
||||
for _, item := range bundle.Items {
|
||||
if item.ItemID <= 0 || item.Count <= 0 ||
|
||||
item.Count > math.MaxInt64/currentDungeonGoldCardItemMultiplier {
|
||||
return fmt.Errorf(
|
||||
"%w: gold-card item=%d count=%d",
|
||||
errDungeonCardPlanInvalid,
|
||||
item.ItemID,
|
||||
item.Count,
|
||||
)
|
||||
}
|
||||
if item.Stackable {
|
||||
item.Count *= currentDungeonGoldCardItemMultiplier
|
||||
items = append(items, cloneDungeonCardItemReward(item))
|
||||
continue
|
||||
}
|
||||
if item.Count != 1 {
|
||||
return fmt.Errorf(
|
||||
"%w: gold-card equipment=%d count=%d",
|
||||
errDungeonCardPlanInvalid,
|
||||
item.ItemID,
|
||||
item.Count,
|
||||
)
|
||||
}
|
||||
for range currentDungeonGoldCardItemMultiplier {
|
||||
items = append(items, cloneDungeonCardItemReward(item))
|
||||
}
|
||||
}
|
||||
bundle.Items = items
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentDungeonRandomCardItem(
|
||||
rng *currentDungeonDropLCG,
|
||||
catalog *pvfDungeonDropCatalog,
|
||||
dungeonLevel int,
|
||||
reference dungeonCardPVFItemDropReference,
|
||||
now time.Time,
|
||||
) (dungeonCardItemReward, bool, error) {
|
||||
if rng == nil || catalog == nil || dungeonLevel <= 0 {
|
||||
return dungeonCardItemReward{}, false, errDungeonCardPVFSourceRequired
|
||||
}
|
||||
rarityRoll := rng.Next(1_000_000) + 1
|
||||
rarity := int64(0)
|
||||
switch {
|
||||
case rarityRoll <= 500_000:
|
||||
rarity = 0
|
||||
case rarityRoll <= 799_900:
|
||||
rarity = 1
|
||||
case rarityRoll <= 999_900:
|
||||
rarity = 2
|
||||
default:
|
||||
rarity = 3
|
||||
}
|
||||
itemID, found, err := catalog.SelectGenericEquipment(
|
||||
rng,
|
||||
dungeonLevel,
|
||||
reference.ValueA,
|
||||
reference.ValueB,
|
||||
rarity,
|
||||
)
|
||||
if err != nil {
|
||||
return dungeonCardItemReward{}, false, err
|
||||
}
|
||||
if !found {
|
||||
itemID, found, err = catalog.SelectGenericStackable(
|
||||
rng,
|
||||
dungeonLevel,
|
||||
reference.ValueA,
|
||||
reference.ValueB,
|
||||
rarity,
|
||||
)
|
||||
if err != nil {
|
||||
return dungeonCardItemReward{}, false, err
|
||||
}
|
||||
}
|
||||
if !found || itemID == 0 {
|
||||
return dungeonCardItemReward{}, false, nil
|
||||
}
|
||||
reward, err := currentDungeonCardItemReward(rng, catalog, itemID, now)
|
||||
return reward, err == nil, err
|
||||
}
|
||||
|
||||
func currentDungeonCardItemReward(
|
||||
rng *currentDungeonDropLCG,
|
||||
catalog *pvfDungeonDropCatalog,
|
||||
itemID uint32,
|
||||
now time.Time,
|
||||
) (dungeonCardItemReward, error) {
|
||||
if rng == nil || catalog == nil || itemID == 0 {
|
||||
return dungeonCardItemReward{}, errDungeonCardPVFSourceRequired
|
||||
}
|
||||
definition, err := catalog.ResolveItem(itemID)
|
||||
if err != nil {
|
||||
return dungeonCardItemReward{}, err
|
||||
}
|
||||
definition, err = currentPVFItemDefinitionForGrantAt(definition, now)
|
||||
if err != nil {
|
||||
return dungeonCardItemReward{}, err
|
||||
}
|
||||
extra := map[string]string{
|
||||
"source": "dungeon_card_reward",
|
||||
"item_kind": string(definition.Kind),
|
||||
"pvf_path": definition.PVFPath,
|
||||
}
|
||||
if definition.StackableType != "" {
|
||||
extra["stackable_type"] = definition.StackableType
|
||||
}
|
||||
if definition.StackLimit > 0 {
|
||||
extra["stack_limit"] = strconv.FormatInt(definition.StackLimit, 10)
|
||||
}
|
||||
if definition.EquipmentType != "" {
|
||||
extra["equipment_type"] = definition.EquipmentType
|
||||
}
|
||||
if definition.Durability > 0 {
|
||||
extra["durability"] = strconv.FormatUint(uint64(definition.Durability), 10)
|
||||
extra["max_durability"] = strconv.FormatUint(uint64(definition.Durability), 10)
|
||||
}
|
||||
if definition.Kind == dungeonDropItemEquipment {
|
||||
qualitySeed := rng.NextUint32()%currentEquipmentRandomQualitySeedCount + 1
|
||||
extra["quality_seed"] = strconv.FormatUint(uint64(qualitySeed), 10)
|
||||
}
|
||||
return dungeonCardItemReward{
|
||||
ItemID: int64(itemID),
|
||||
Count: 1,
|
||||
Stackable: definition.Kind == dungeonDropItemStackable,
|
||||
StackLimit: definition.StackLimit,
|
||||
SlotStart: definition.SlotStart,
|
||||
SlotEnd: definition.SlotEnd,
|
||||
ExpireAt: definition.ExpirationDate,
|
||||
Extra: extra,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) selectCurrentDungeonCardLocked(
|
||||
session *gameSession,
|
||||
runtime *runtimeDungeonState,
|
||||
side dungeonCardSide,
|
||||
memberIndex byte,
|
||||
source string,
|
||||
clickedSlot ...byte,
|
||||
) error {
|
||||
if session == nil || runtime == nil || session.dungeon.runtime != runtime ||
|
||||
!runtime.settlementCardLayoutSent ||
|
||||
runtime.settlementPhase < currentDungeonSettlementPhaseCardsRevealed ||
|
||||
side >= dungeonCardSideCount || memberIndex >= dungeonCardSlotsPerSide {
|
||||
return nil
|
||||
}
|
||||
if runtime.partyDungeonOwner != nil {
|
||||
member, current := runtime.partyDungeonOwner.member(
|
||||
session.selectedCharacterID,
|
||||
session.characterGeneration,
|
||||
)
|
||||
if !current || !runtime.partyMemberIndexed || runtime.partyMemberIndex != member.slot || memberIndex != member.slot {
|
||||
s.logGameEvent(session, "game-party-dungeon-card-select-blocked",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"party_id", runtime.partyDungeonOwner.partyID,
|
||||
"reported_member_index", memberIndex,
|
||||
"stable_member_index", member.slot,
|
||||
"source", source,
|
||||
"reason", "same_version_card_result_must_use_native_party_slot")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if runtime.settlementCardSideSelectionKnown[side] &&
|
||||
runtime.settlementCardSideMember[side] != memberIndex {
|
||||
s.logGameEvent(session, "game-dungeon-card-select-blocked",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"dungeon_id", runtime.Dungeon.ID,
|
||||
"side", side,
|
||||
"member_index", memberIndex,
|
||||
"accepted_member_index", runtime.settlementCardSideMember[side],
|
||||
"source", source,
|
||||
"reason", "row_selection_conflicts_with_first_accepted_member")
|
||||
return nil
|
||||
}
|
||||
if runtime.settlementCardRewardState == nil {
|
||||
if err := s.freezeCurrentDungeonCardRewardStateLocked(session, runtime); err != nil {
|
||||
s.logGameEvent(session, "game-dungeon-card-reward-plan-blocked",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"dungeon_id", runtime.Dungeon.ID,
|
||||
"side", side,
|
||||
"member_index", memberIndex,
|
||||
"source", source,
|
||||
"error", err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
replay := runtime.settlementCardSideSelectionSent[side]
|
||||
if !runtime.settlementCardSidePositionKnown[side] {
|
||||
position := memberIndex
|
||||
if len(clickedSlot) != 0 {
|
||||
position = clickedSlot[0]
|
||||
}
|
||||
if position >= dungeonCardSlotsPerSide {
|
||||
return errDungeonCardSelectionInvalid
|
||||
}
|
||||
if owner := runtime.partyDungeonOwner; owner != nil {
|
||||
owner.mu.Lock()
|
||||
if owner.cardScreenOwners[side][position] != 0 && owner.cardScreenOwners[side][position] != session.selectedCharacterID {
|
||||
if len(clickedSlot) != 0 {
|
||||
owner.mu.Unlock()
|
||||
// The DLL restores only this pending choice; no reward is
|
||||
// committed for two members racing for the same card.
|
||||
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketSelectCard),
|
||||
[]byte{0, 254, byte(side), position}, currentDungeonCardResponseClass)
|
||||
}
|
||||
// Timed auto-flips and old two-byte clients take an empty card.
|
||||
position = dungeonCardSlotsPerSide
|
||||
for candidate, occupant := range owner.cardScreenOwners[side] {
|
||||
if occupant == 0 {
|
||||
position = byte(candidate)
|
||||
break
|
||||
}
|
||||
}
|
||||
if position >= dungeonCardSlotsPerSide {
|
||||
owner.mu.Unlock()
|
||||
return errDungeonCardSelectionInvalid
|
||||
}
|
||||
}
|
||||
owner.cardScreenOwners[side][position] = session.selectedCharacterID
|
||||
owner.mu.Unlock()
|
||||
}
|
||||
runtime.settlementCardSidePosition[side] = position
|
||||
runtime.settlementCardSidePositionKnown[side] = true
|
||||
}
|
||||
runtime.settlementCardSideSelectionKnown[side] = true
|
||||
runtime.settlementCardSideMember[side] = memberIndex
|
||||
runtime.settlementCardSelected = byte(side)
|
||||
runtime.refreshCurrentDungeonFreeCardReadiness()
|
||||
slots71, rewardTupleCount := currentDungeonSelectedCardSlots(runtime)
|
||||
body71, err := buildCurrentDungeonOp71SuccessBody(slots71)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketSelectCard), body71, currentDungeonCardResponseClass); err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.settlementCardSideSelectionSent[side] = true
|
||||
runtime.refreshCurrentDungeonFreeCardReadiness()
|
||||
if side == dungeonCardSideFree {
|
||||
s.cancelCurrentDungeonCardAutoFlipLocked(session, runtime, "free_row_selected")
|
||||
}
|
||||
s.logGameEvent(session, "game-dungeon-card-select-ack-sent",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"dungeon_id", runtime.Dungeon.ID,
|
||||
"side", side,
|
||||
"member_index", memberIndex,
|
||||
"screen_slot", runtime.settlementCardSidePosition[side],
|
||||
"replay", replay,
|
||||
"response_body_len", len(body71),
|
||||
"classification", currentDungeonCardResponseClass,
|
||||
"source", source,
|
||||
"reward_tuple_count", rewardTupleCount,
|
||||
"body_source", "current_exe_op71_proved_reader_selection_state_with_frozen_card_reward_context")
|
||||
|
||||
if err := s.commitCurrentDungeonCardSideRewardLocked(session, runtime, side, source); err != nil {
|
||||
s.logGameEvent(session, "game-dungeon-card-reward-delivery-blocked",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"dungeon_id", runtime.Dungeon.ID,
|
||||
"side", side,
|
||||
"member_index", memberIndex,
|
||||
"source", source,
|
||||
"error", err)
|
||||
}
|
||||
if err := s.publishRuntimePartyDungeonCardSelection(
|
||||
session,
|
||||
runtime,
|
||||
side,
|
||||
memberIndex,
|
||||
body71,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) commitCurrentDungeonCardRewardLocked(
|
||||
session *gameSession,
|
||||
runtime *runtimeDungeonState,
|
||||
source string,
|
||||
) error {
|
||||
if runtime == nil || runtime.settlementCardRewardCommitted {
|
||||
return nil
|
||||
}
|
||||
return s.commitCurrentDungeonCardSideRewardLocked(session, runtime, dungeonCardSideFree, source)
|
||||
}
|
||||
|
||||
func (s *Service) commitCurrentDungeonCardSideRewardLocked(
|
||||
session *gameSession,
|
||||
runtime *runtimeDungeonState,
|
||||
side dungeonCardSide,
|
||||
source string,
|
||||
) error {
|
||||
if runtime == nil || side >= dungeonCardSideCount ||
|
||||
runtime.settlementCardSideRewardCommitted[side] {
|
||||
return nil
|
||||
}
|
||||
if !runtime.settlementCardSideSelectionKnown[side] ||
|
||||
!runtime.settlementCardSideSelectionSent[side] {
|
||||
return errDungeonCardSelectionInvalid
|
||||
}
|
||||
if err := s.deliverCurrentDungeonSelectedCardRewardLocked(
|
||||
session,
|
||||
runtime,
|
||||
side,
|
||||
runtime.settlementCardSideMember[side],
|
||||
source,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.settlementCardSideRewardCommitted[side] = true
|
||||
runtime.refreshCurrentDungeonFreeCardReadiness()
|
||||
if side == dungeonCardSideFree {
|
||||
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseRewardCommitted)
|
||||
}
|
||||
s.logGameEvent(session, "game-dungeon-card-reward-phase-committed",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"dungeon_id", runtime.Dungeon.ID,
|
||||
"side", side,
|
||||
"member_index", runtime.settlementCardSideMember[side],
|
||||
"source", source,
|
||||
"next_stage", "await_current_exe_c2s_op72_or_completed_op42")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) deliverCurrentDungeonSelectedCardRewardLocked(
|
||||
session *gameSession,
|
||||
runtime *runtimeDungeonState,
|
||||
side dungeonCardSide,
|
||||
memberIndex byte,
|
||||
source string,
|
||||
) error {
|
||||
if s == nil || session == nil || runtime == nil || runtime.settlementCardRewardState == nil {
|
||||
return nil
|
||||
}
|
||||
reservation, result, err := runtime.settlementCardRewardState.reserveSelection(side, memberIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !reservation.grant {
|
||||
s.logGameEvent(session, "game-dungeon-card-reward-delivery-skipped",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"dungeon_id", runtime.Dungeon.ID,
|
||||
"selection_result", result,
|
||||
"side", side,
|
||||
"member_index", memberIndex,
|
||||
"source", source,
|
||||
"reason", "no_undelivered_reward_for_selection")
|
||||
return nil
|
||||
}
|
||||
repositories, ok := s.repositoryGroup()
|
||||
if !ok || repositories.CharacterAssets == nil {
|
||||
runtime.settlementCardRewardState.finishDelivery(reservation, false)
|
||||
return errDungeonCardAssetOwnerUnavailable
|
||||
}
|
||||
// 翻牌落点必须用该角色**真实已购**的背包扩展行数。这里过去写死 0,等于
|
||||
// "只认默认 4 行":`currentMainInventoryClampedSlotEnd` 会把每个 56 格分类的
|
||||
// SlotEnd 夹到 `SlotStart+31`,于是买过扩展券的角色在前 4 行占满、扩展行空着
|
||||
// 的时候也会被判成"背包已满",整份翻牌奖励转投邮件(现场:装备 9-64 只用
|
||||
// 9-40、材料 65-120 只用 65-96)。取不到容器状态时安全退回 0,与
|
||||
// 祭坛/赛丽亚商店同一条口径(见 altar_settlement.go 的 owner 契约注释)。
|
||||
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
||||
loadCtx, loadCancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
mainSlotCount := s.currentInventoryPlacementMainSlotCount(loadCtx, nil, characterID)
|
||||
loadCancel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
defer cancel()
|
||||
grant, err := deliverDungeonCardReservation(
|
||||
ctx,
|
||||
runtime.settlementCardRewardState,
|
||||
reservation,
|
||||
repositories,
|
||||
mainSlotCount,
|
||||
s.gameplayNow().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if grant.GoldAfter != grant.GoldBefore {
|
||||
body := buildCurrentGoldStateBody(grant.GoldAfter)
|
||||
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(grant.ItemSlots))
|
||||
for _, slot := range grant.ItemSlots {
|
||||
if slot < 0 {
|
||||
continue
|
||||
}
|
||||
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{
|
||||
ListType: dnfrepo.MainInventoryListType,
|
||||
SlotIndex: slot,
|
||||
})
|
||||
}
|
||||
if err := s.sendSelectedIncrementalItemSlotRefreshes(
|
||||
session,
|
||||
"dungeon-card-reward",
|
||||
refreshes,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if grant.OverflowMailID != "" {
|
||||
if err := s.sendMailboxAlarmToOnlineRecipient(session.selectedCharacterID); err != nil {
|
||||
// Reward and its mailbox fallback have already committed. A packet
|
||||
// delivery failure must not turn that durable result into a retryable
|
||||
// card selection; op96 will project it on the next mailbox open.
|
||||
s.logWarn("dungeon reward mailbox alarm deferred to next mailbox open",
|
||||
"character_id", session.selectedCharacterID,
|
||||
"mail_id", grant.OverflowMailID,
|
||||
"error", err)
|
||||
}
|
||||
}
|
||||
s.logGameEvent(session, "game-dungeon-card-reward-delivered",
|
||||
"char_id", session.selectedCharacterID,
|
||||
"dungeon_id", runtime.Dungeon.ID,
|
||||
"selection_result", result,
|
||||
"side", side,
|
||||
"member_index", memberIndex,
|
||||
"gold_before", grant.GoldBefore,
|
||||
"gold_after", grant.GoldAfter,
|
||||
"item_slots", grant.ItemSlots,
|
||||
"overflow_mail_id", grant.OverflowMailID,
|
||||
"source", source,
|
||||
"domain_source", "86jp_auto_flip_reward_delivery")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (runtime *runtimeDungeonState) refreshCurrentDungeonFreeCardReadiness() {
|
||||
if runtime == nil {
|
||||
return
|
||||
}
|
||||
runtime.settlementCardSelectionKnown = runtime.settlementCardSideSelectionKnown[dungeonCardSideFree]
|
||||
runtime.settlementCardSelectionSent = runtime.settlementCardSideSelectionSent[dungeonCardSideFree]
|
||||
runtime.settlementCardRewardCommitted = runtime.settlementCardSideRewardCommitted[dungeonCardSideFree]
|
||||
}
|
||||
Reference in new issue
Block a user