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

1174 lines
52 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"
"encoding/binary"
"errors"
"fmt"
"math"
"math/rand/v2"
"sort"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfexpertjob "longheng.io/server/internal/modules/dnf/expertjob"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentExpertJobCompoundRequestSize = 8
currentExpertJobExtractionRequestSize = 6
)
// 【op416 副职业提取器批量提取 2026-09-25】
//
// 背景:客户端(模式 17/18/19:附魔师/炼金师/控偶师 三个提取器)本来就是
// "一趟把选中装备全部发出去",每件一个 op416。服务端过去对每个包各开一个事务、
// 各回一个 ACK、各做一次整容器重建,于是玩家看到的是"一件一件消失"。
// 这与 op26(NPC 分解机)/ op235(个人商店分解机)当年一模一样,两者都已用
// "客户端把 N 件合成一个带标记的信封 + 服务端单事务批量"解决。
// 本次按同一口径给 op416 补上。
//
// 信封(客户端 90CN 钩子合成):
//
// u8 extractorType | i16 extractorSlot | u8 targetList
// | i16 0xFFFF 哨兵 | u32 "B41\x01" | u8 count | i16 slots[count]
// 长度 = 11 + 2*count
//
// 与 6 字节单件请求天然区分:单件体第 4..5 字节是 targetSlot 且必须 >= 0,
// 信封在该位置固定写 -1 哨兵。因此老客户端(只发单件)行为完全不变。
const (
currentExpertJobExtractionBatchHeaderSize = 11
currentExpertJobExtractionBatchMaxItems = 127
// 合并回包的行数上限。客户端结果窗按行循环没有上限比较(已实测),
// 这里只做服务端侧的保护,取值与 op235 批量的 200 行对齐。
currentExpertJobExtractionBatchMaxRows = 200
)
// currentExpertJobExtractionBatchSentinel 占住单件请求的 targetSlot 位置。
var currentExpertJobExtractionBatchSentinel int16 = -1
// currentExpertJobExtractionBatchMarker 证明信封按预期顺序写出(同 op26/op235)。
var currentExpertJobExtractionBatchMarker = [4]byte{'B', '4', '1', 0x01}
type currentExpertJobCompoundRequest struct {
RecipeItemID int64
Count int
CardSlot int16
}
type currentExpertJobExtractionRequest struct {
ExtractorType byte
ExtractorSlot int16
TargetList byte
TargetSlot int16
}
// currentExpertJobExtractionBatchRequest 是一次批量提取:同一把提取器、
// 同一个目标容器、N 个目标槽。三个职业(附魔师/炼金师/控偶师)共用同一个
// opcode 与同一条客户端循环,差别只有 ExtractorType 一个字节。
type currentExpertJobExtractionBatchRequest struct {
ExtractorType byte
ExtractorSlot int16
TargetList byte
Slots []int16
}
type currentExpertJobCompoundResult struct {
Plan dnfexpertjob.CompoundPlan
JobType byte
}
type currentExpertJobExtractionMaterial struct {
Slot int16
ItemID uint32
Count uint32
}
type currentExpertJobExtractionResult struct {
JobType byte
TargetList byte
TargetSlot int16
Materials []currentExpertJobExtractionMaterial
// ExperienceGain is what the op205 profession row has to be republished
// for. LevelChanged alone is not enough: most extractions move the bar
// without crossing a threshold, and the client has no other source for it.
ExperienceGain int64
LevelChanged bool
}
func expertJobGameplayModule() gameplayModuleDefinition {
compound := uint16(dnfenum.CmdPacketCompoundItemByExpertJob)
extraction := uint16(dnfenum.CmdPacketExpertExtraction)
giveUp := uint16(dnfenum.CmdPacketGiveupExpertJob)
handleCompound := func(service *Service, session *gameSession, request gameplayRequest) error {
return service.handleCurrentExpertJobCompound(session, request.Body)
}
handleExtraction := func(service *Service, session *gameSession, request gameplayRequest) error {
return service.handleCurrentExpertJobExtraction(session, request.Body)
}
module := gameplayModuleDefinition{
Name: "expert-job",
LegacyHandlers: map[uint16]gameplayHandler{
compound: handleCompound,
extraction: handleExtraction,
giveUp: func(service *Service, session *gameSession, request gameplayRequest) error {
return service.handleCurrentExpertJobGiveUp(session, request.Body)
},
},
UpperHandlers: map[uint16]gameplayHandler{
compound: defaultClassGameplayHandler(
"game-expert-job-compound-blocked",
"current_exe_op238_command_class_mismatch",
func(service *Service, session *gameSession, body []byte) error {
return service.handleCurrentExpertJobCompound(session, body)
},
),
extraction: defaultClassGameplayHandler(
"game-expert-job-extraction-blocked",
"current_exe_op416_command_class_mismatch",
func(service *Service, session *gameSession, body []byte) error {
return service.handleCurrentExpertJobExtraction(session, body)
},
),
giveUp: defaultClassGameplayHandler(
"game-expert-job-give-up-blocked",
"current_exe_op239_command_class_mismatch",
func(service *Service, session *gameSession, body []byte) error {
return service.handleCurrentExpertJobGiveUp(session, body)
},
),
},
LegacyNormalizers: map[uint16]gameplayLegacyNormalizer{
compound: func(body []byte) []byte {
return stripLegacyTransportTrailer(body, currentExpertJobCompoundRequestSize)
},
extraction: func(body []byte) []byte {
return stripLegacyTransportTrailer(body, currentExpertJobExtractionRequestSize)
},
giveUp: func(body []byte) []byte {
return stripLegacyTransportTrailer(body, 0)
},
},
}
registerStore := func(opcode uint16, handler func(*Service, *gameSession, uint16, []byte) error) {
captured := opcode
module.LegacyHandlers[captured] = func(service *Service, session *gameSession, request gameplayRequest) error {
return handler(service, session, captured, request.Body)
}
module.UpperHandlers[captured] = defaultClassGameplayHandler(
"game-expert-job-store-blocked",
"current_exe_expert_job_store_command_class_mismatch",
func(service *Service, session *gameSession, body []byte) error {
return handler(service, session, captured, body)
},
)
}
registerStore(uint16(dnfenum.CmdPacketCreateExpertJobStore), (*Service).handleCurrentExpertJobStoreCreate)
registerStore(uint16(dnfenum.CmdPacketEnterExpertJobStore), (*Service).handleCurrentExpertJobStoreEnter)
registerStore(uint16(dnfenum.CmdPacketCloseExpertJobStore), (*Service).handleCurrentExpertJobStoreClose)
registerStore(uint16(dnfenum.CmdPacketRepairDisjointMachine), (*Service).handleCurrentExpertJobStoreRepair)
registerStore(uint16(dnfenum.CmdPacketRepairExpertJobStore), (*Service).handleCurrentExpertJobStoreRepair)
registerStore(uint16(dnfenum.CmdPacketUpgradeDisjointMachine), (*Service).handleCurrentExpertJobStoreUpgrade)
registerStore(uint16(dnfenum.CmdPacketRequestDisjointItem), (*Service).handleCurrentExpertJobStoreDisjoint)
registerStore(uint16(dnfenum.CmdPacketUseEnchantStore), (*Service).handleCurrentExpertJobStoreEnchant)
return module
}
func parseCurrentExpertJobCompoundRequest(body []byte) (currentExpertJobCompoundRequest, error) {
if len(body) != currentExpertJobCompoundRequestSize {
return currentExpertJobCompoundRequest{}, fmt.Errorf("expert job compound body=%d", len(body))
}
request := currentExpertJobCompoundRequest{
RecipeItemID: int64(int32(binary.LittleEndian.Uint32(body[0:4]))),
Count: int(binary.LittleEndian.Uint16(body[4:6])),
CardSlot: int16(binary.LittleEndian.Uint16(body[6:8])),
}
// 【2026-09-18 放开"制作宝珠数量必须=1"的限制】
//
// 这里原先还有一条 `CardSlot >= 0 && Count != 1` 的判定:附魔机界面里可以把
// 数量调到 50 / 49 再按制作,客户端会照原样发来,于是被这条校验直接判非法、
// 回失败码 19(玩家实测「制作宝珠被服务端拒了」,日志里是
// game-expert-job-compound-rejected reason=expert job compound request is invalid)。
// 普通合成那条路径本来就把 Count 当批量数用(PlanCompoundWithLearned(...,
// request.Count, ...)),所以批量是客户端本来就支持的用法,这里不再拦。
// 数量只做下界检查;实际能做几次由卡片与配方材料的存量决定(见 commit 里的循环)。
if request.RecipeItemID <= 0 || request.Count <= 0 || request.CardSlot < -1 {
return currentExpertJobCompoundRequest{}, errors.New("expert job compound request is invalid")
}
return request, nil
}
func parseCurrentExpertJobExtractionRequest(body []byte) (currentExpertJobExtractionRequest, error) {
if len(body) != currentExpertJobExtractionRequestSize {
return currentExpertJobExtractionRequest{}, fmt.Errorf("expert job extraction body=%d", len(body))
}
request := currentExpertJobExtractionRequest{
ExtractorType: body[0],
ExtractorSlot: int16(binary.LittleEndian.Uint16(body[1:3])),
TargetList: body[3],
TargetSlot: int16(binary.LittleEndian.Uint16(body[4:6])),
}
if (request.ExtractorType != dnfexpertjob.EnchanterType && request.ExtractorType != dnfexpertjob.AlchemistType && request.ExtractorType != dnfexpertjob.DollControllerType) ||
request.ExtractorSlot < 0 || request.TargetList != dnfrepo.MainInventoryListType ||
request.TargetSlot < 0 || request.ExtractorSlot == request.TargetSlot {
return currentExpertJobExtractionRequest{}, errors.New("expert job extraction request is invalid")
}
return request, nil
}
// currentExpertJobExtractionTypeSupported 报告这个字节是不是三个"提取器职业"之一
// (1 附魔师 / 2 炼金师 / 4 控偶师)。三个职业共用 op416 与同一条客户端循环。
func currentExpertJobExtractionTypeSupported(extractorType byte) bool {
return extractorType == dnfexpertjob.EnchanterType ||
extractorType == dnfexpertjob.AlchemistType ||
extractorType == dnfexpertjob.DollControllerType
}
// parseCurrentExpertJobExtractionBatchRequest 识别客户端 90CN 钩子合成的批量提取命令。
//
// 第二个返回值表示"这是一个批量信封"(即使内容非法),让调用方能区分
// "不是批量"(照原样走 6 字节单件路径)与"批量但坏了"(回失败码)——与 op26 的
// parseCurrentDisjointBatchRequest、op235 的 parseCurrentExpertJobStoreDisjointBatchRequest
// 同一约定。
//
// 关键不变式:6 字节单件请求永远进不来这里(长度不足且哨兵位置是合法 targetSlot),
// 所以老客户端与单件提取的行为逐字节不变。
func parseCurrentExpertJobExtractionBatchRequest(body []byte) (currentExpertJobExtractionBatchRequest, bool, error) {
if len(body) < currentExpertJobExtractionBatchHeaderSize ||
int16(binary.LittleEndian.Uint16(body[4:6])) != currentExpertJobExtractionBatchSentinel ||
body[6] != currentExpertJobExtractionBatchMarker[0] ||
body[7] != currentExpertJobExtractionBatchMarker[1] ||
body[8] != currentExpertJobExtractionBatchMarker[2] ||
body[9] != currentExpertJobExtractionBatchMarker[3] {
return currentExpertJobExtractionBatchRequest{}, false, nil
}
count := int(body[10])
if count < 1 || count > currentExpertJobExtractionBatchMaxItems ||
len(body) != currentExpertJobExtractionBatchHeaderSize+count*2 {
return currentExpertJobExtractionBatchRequest{}, true,
fmt.Errorf("expert job extraction batch count=%d body=%d", count, len(body))
}
request := currentExpertJobExtractionBatchRequest{
ExtractorType: body[0],
ExtractorSlot: int16(binary.LittleEndian.Uint16(body[1:3])),
TargetList: body[3],
Slots: make([]int16, 0, count),
}
if !currentExpertJobExtractionTypeSupported(request.ExtractorType) ||
request.ExtractorSlot < 0 || request.TargetList != dnfrepo.MainInventoryListType {
return currentExpertJobExtractionBatchRequest{}, true,
fmt.Errorf("expert job extraction batch type=%d extractor=%d list=%d",
request.ExtractorType, request.ExtractorSlot, request.TargetList)
}
seen := make(map[int16]struct{}, count)
for index := 0; index < count; index++ {
offset := currentExpertJobExtractionBatchHeaderSize + index*2
slot := int16(binary.LittleEndian.Uint16(body[offset : offset+2]))
if slot < 0 || slot == request.ExtractorSlot ||
dnfrepo.IsAccountSharedInventorySlot(request.TargetList, slot) {
return currentExpertJobExtractionBatchRequest{}, true,
fmt.Errorf("expert job extraction batch index=%d slot=%d", index, slot)
}
if _, duplicate := seen[slot]; duplicate {
return currentExpertJobExtractionBatchRequest{}, true,
fmt.Errorf("expert job extraction batch duplicate slot=%d", slot)
}
seen[slot] = struct{}{}
request.Slots = append(request.Slots, slot)
}
return request, true, nil
}
// currentExpertJobProjectionNeedsRepublish reports whether one expert-job
// operation moved state that only the class-0 op205 projection carries.
//
// The compound and extraction ACKs describe the crafted items, never the
// profession's experience bar; op205 is its only carrier, and the client-side
// hook writes it straight into the current actor. Gating the republish on a
// level change therefore left every ordinary craft's experience invisible
// until the next character select replayed the login push - the visible
// symptom for 附魔师. Both expert-job store paths have always republished
// unconditionally, so only these two lagged behind their own writes.
func currentExpertJobProjectionNeedsRepublish(experienceGain int64, levelChanged bool) bool {
return experienceGain != 0 || levelChanged
}
func (s *Service) handleCurrentExpertJobCompound(session *gameSession, body []byte) error {
request, err := parseCurrentExpertJobCompoundRequest(body)
if err != nil {
s.logGameEvent(session, "game-expert-job-compound-rejected", "body_len", len(body), "reason", err)
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketCompoundItemByExpertJob), 19)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
result, err := s.commitCurrentExpertJobCompound(ctx, session, request)
if err != nil {
code := currentExpertJobCompoundError(err)
s.logGameEvent(session, "game-expert-job-compound-rejected", "recipe", request.RecipeItemID, "count", request.Count, "error", err, "error_code", code)
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketCompoundItemByExpertJob), code)
}
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketCompoundItemByExpertJob), buildCurrentExpertJobCompoundSuccessBody(result.Plan), dnfproto.DefaultChannelClassification); err != nil {
return err
}
if err := s.sendSelectedCurrentContainerListsRefresh(session, "expert_job_compound_after_ack"); err != nil {
return err
}
if currentExpertJobProjectionNeedsRepublish(result.Plan.ExperienceGain, result.Plan.LevelChanged) {
if err := s.sendCurrentExpertJobInfoFromRepository(session, result.JobType, true); err != nil {
return err
}
}
s.logGameEvent(session, "game-expert-job-compound-committed", "job_type", result.JobType, "recipe", request.RecipeItemID, "attempts", request.Count, "successes", result.Plan.SuccessCount, "failures", result.Plan.FailureCount, "experience_gain", result.Plan.ExperienceGain)
return nil
}
func (s *Service) handleCurrentExpertJobExtraction(session *gameSession, body []byte) error {
// 批量信封优先。第二个返回值为 false 表示"这不是信封",原样落到下面的
// 6 字节单件路径——单件提取的行为与本函数此前完全一致(只多了一次常量级判断)。
batch, isBatch, batchErr := parseCurrentExpertJobExtractionBatchRequest(body)
if batchErr != nil {
s.logGameEvent(session, "game-expert-job-extraction-batch-rejected", "body_len", len(body), "reason", batchErr)
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketExpertExtraction), 19)
}
if isBatch {
return s.handleCurrentExpertJobExtractionBatch(session, batch)
}
request, err := parseCurrentExpertJobExtractionRequest(body)
if err != nil {
s.logGameEvent(session, "game-expert-job-extraction-rejected", "body_len", len(body), "reason", err)
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketExpertExtraction), 19)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
result, err := s.commitCurrentExpertJobExtraction(ctx, session, request)
if err != nil {
code := currentExpertJobExtractionError(err)
s.logGameEvent(session, "game-expert-job-extraction-rejected", "extractor_type", request.ExtractorType, "extractor_slot", request.ExtractorSlot, "target_slot", request.TargetSlot, "error", err, "error_code", code)
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketExpertExtraction), code)
}
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketExpertExtraction), buildCurrentExpertJobExtractionSuccessBody(result), dnfproto.DefaultChannelClassification); err != nil {
return err
}
if err := s.sendSelectedCurrentContainerListsRefresh(session, "expert_job_extraction_after_ack"); err != nil {
return err
}
if currentExpertJobProjectionNeedsRepublish(result.ExperienceGain, result.LevelChanged) {
if err := s.sendCurrentExpertJobInfoFromRepository(session, result.JobType, true); err != nil {
return err
}
}
s.logGameEvent(session, "game-expert-job-extraction-committed", "job_type", result.JobType, "extractor_slot", request.ExtractorSlot, "target_slot", request.TargetSlot, "materials", len(result.Materials), "experience_gain", result.ExperienceGain)
return nil
}
func (s *Service) commitCurrentExpertJobCompound(ctx context.Context, session *gameSession, request currentExpertJobCompoundRequest) (currentExpertJobCompoundResult, error) {
jobCatalog, items, owner, accountID, characterID, err := s.currentExpertJobMutationContext(session)
if err != nil {
return currentExpertJobCompoundResult{}, err
}
var result currentExpertJobCompoundResult
err = owner.Compound(ctx, dnfexpertjob.Command{AccountID: accountID, CharacterID: characterID, UpdatedAt: s.gameplayNow(), Project: func(assets *dnfexpertjob.Assets) (dnfexpertjob.Changes, error) {
jobType, experience, err := currentExpertJobCharacterState(assets.Character)
if err != nil {
return dnfexpertjob.Changes{}, err
}
if request.CardSlot >= 0 {
if jobType != dnfexpertjob.EnchanterType {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrJobUnsupported
}
// 【批量制作宝珠 2026-09-18】
//
// 附魔机的"制作宝珠"和普通合成一样带数量:请求体是
// {u32 配方ID, u16 数量, u16 卡片槽},客户端把界面里选的数量原样发来。
// 原先这条分支只处理 1 个(parse 直接把数量!=1 判非法、回失败码 19),
// 玩家把数量调成 50 / 49 就必然被拒;现在按数量循环:
//
// 每次消耗 1 张卡 + 一份配方材料 → 按"当前经验"掷一次成功率
// → 成功才产出宝珠、并把这次的经验累加(下一件的成功率/等级按新经验算)
//
// 卡用完或配方材料不足时停止循环,并按已完成的次数结算;一次都没能完成
// (第一张卡就不合法 / 第一份材料就不够)则整体失败、事务不落库,保持和
// 原来单次制作一样的原子性。
//
// 回包沿用现有格式:buildCurrentExpertJobCompoundSuccessBody 本身就带
// "产物条数 + 每条 (itemID,count) + 成功数 + 失败数",单产物时正好 19 字节
// (与线上成功回包一致)。产物条目按成功次数聚合;0 次成功时不带产物条目,
// 与单次制作失败时的表现保持一致。
cardKey := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, request.CardSlot)
compoundPlan := dnfexpertjob.CompoundPlan{}
finalExperience := experience
levelChanged := false
inventoryChanged := false
accountChanged := false
var beadItemID int64
attempted := 0
for attempt := 0; attempt < request.Count; attempt++ {
card, found := assets.Inventory.Slots[cardKey]
if !found || card.ItemID <= 0 || card.Count <= 0 || currentNPCShopItemLocked(card) {
if attempt == 0 {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrRecipeUnavailable
}
break
}
// 每件都按当前经验重新掷:等级/成功率会随累计经验变化。
beadPlan, planErr := jobCatalog.PlanEnchanterBead(finalExperience, request.RecipeItemID, card.ItemID, rand.IntN)
if planErr != nil {
if attempt == 0 {
return dnfexpertjob.Changes{}, planErr
}
break
}
upgradeCount := currentExpertJobEnchantUpgradeCount(card)
card.Count--
if card.Count == 0 {
delete(assets.Inventory.Slots, cardKey)
} else {
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, request.CardSlot, card)
card.RawEntry = append([]byte(nil), entry.data[:]...)
assets.Inventory.Slots[cardKey] = card
}
consumedAccount, consumeErr := consumeCurrentExpertJobRecipe(assets.Character, assets.Inventory, assets.AccountInventory, beadPlan.Recipe.Materials, 0)
if consumeErr != nil {
// 这一张卡已经扣了,材料不够就停在这里:与单次制作"失败也照扣"的
// 口径一致。首件就失败则整体不落库。
if attempt == 0 {
return dnfexpertjob.Changes{}, consumeErr
}
break
}
accountChanged = accountChanged || consumedAccount
inventoryChanged = true
attempted++
finalExperience = beadPlan.FinalExperience
levelChanged = levelChanged || beadPlan.LevelChanged
compoundPlan.ExperienceGain += beadPlan.ExperienceGain
if !beadPlan.Success {
compoundPlan.FailureCount++
continue
}
if beadPlan.BeadItemID <= 0 || beadPlan.BeadItemID > math.MaxUint32 {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrRecipeUnavailable
}
definition, resolveErr := items.ResolveItem(uint32(beadPlan.BeadItemID))
if resolveErr != nil || definition.Kind != dungeonDropItemStackable {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrRecipeUnavailable
}
if _, grantErr := grantCurrentExpertJobBead(assets.Inventory, definition, upgradeCount); grantErr != nil {
return dnfexpertjob.Changes{}, grantErr
}
beadItemID = beadPlan.BeadItemID
compoundPlan.SuccessCount++
}
if attempted == 0 {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrRecipeUnavailable
}
compoundPlan.FinalExperience = finalExperience
if compoundPlan.SuccessCount > 0 {
compoundPlan.AttemptedOutputs = []dnfexpertjob.RecipeEntry{{
ItemID: beadItemID,
Count: int64(compoundPlan.SuccessCount),
}}
compoundPlan.Rewards = append([]dnfexpertjob.RecipeEntry(nil), compoundPlan.AttemptedOutputs...)
}
if assets.Character.Stats == nil {
assets.Character.Stats = make(map[string]int64)
}
assets.Character.Stats["expert_job_exp"] = finalExperience
result = currentExpertJobCompoundResult{Plan: compoundPlan, JobType: jobType}
return dnfexpertjob.Changes{
Character: compoundPlan.ExperienceGain != 0 || levelChanged,
AccountInventory: accountChanged,
Inventory: inventoryChanged,
}, nil
}
learned := assets.Character.Stats[currentExpertJobRecipeStatKey(jobType, request.RecipeItemID)] != 0
plan, err := jobCatalog.PlanCompoundWithLearned(jobType, experience, request.RecipeItemID, request.Count, learned, rand.IntN)
if err != nil {
return dnfexpertjob.Changes{}, err
}
accountChanged, err := consumeCurrentExpertJobRecipe(assets.Character, assets.Inventory, assets.AccountInventory, plan.Materials, plan.GoldCost)
if err != nil {
return dnfexpertjob.Changes{}, err
}
for _, reward := range plan.Rewards {
if reward.ItemID <= 0 || reward.ItemID > math.MaxUint32 || reward.Count <= 0 || reward.Count > math.MaxUint32 {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrRecipeUnavailable
}
definition, resolveErr := items.ResolveItem(uint32(reward.ItemID))
if resolveErr != nil || definition.Kind != dungeonDropItemStackable {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrRecipeUnavailable
}
// 副职业配方产出同样要过账号共享落点:无色/灵魂类产物必须先落进
// 354..365,装不下的余量再走角色背包。这条路径过去直接调
// grantCurrentCeraShopProduct,产物会留在背包(2026-09-21 与 NPC 商店
// 金币购买同类的漏点)。判定与溢出都收在下面那个可测的小函数里。
rewardAccountChanged, rewardErr := grantCurrentExpertJobCompoundReward(
assets.Inventory,
assets.AccountInventory,
definition,
uint32(reward.Count),
s.currentInventoryPlacementMainSlotCount(ctx, assets.Inventory, characterID),
)
if rewardErr != nil {
return dnfexpertjob.Changes{}, rewardErr
}
accountChanged = accountChanged || rewardAccountChanged
}
if assets.Character.Stats == nil {
assets.Character.Stats = make(map[string]int64)
}
assets.Character.Stats["expert_job_exp"] = plan.FinalExperience
result = currentExpertJobCompoundResult{Plan: plan, JobType: jobType}
return dnfexpertjob.Changes{Character: plan.ExperienceGain != 0, AccountInventory: accountChanged, Inventory: true}, nil
}})
return result, err
}
func (s *Service) commitCurrentExpertJobExtraction(ctx context.Context, session *gameSession, request currentExpertJobExtractionRequest) (currentExpertJobExtractionResult, error) {
jobCatalog, items, owner, accountID, characterID, err := s.currentExpertJobMutationContext(session)
if err != nil {
return currentExpertJobExtractionResult{}, err
}
var result currentExpertJobExtractionResult
err = owner.Extract(ctx, dnfexpertjob.Command{AccountID: accountID, CharacterID: characterID, UpdatedAt: s.gameplayNow(), Project: func(assets *dnfexpertjob.Assets) (dnfexpertjob.Changes, error) {
jobType, experience, err := currentExpertJobCharacterState(assets.Character)
if err != nil || jobType != request.ExtractorType {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrJobUnsupported
}
extractor, extractorFound := assets.Inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, request.ExtractorSlot)]
targetKey := currentCeraShopInventorySlotKey(request.TargetList, request.TargetSlot)
target, targetFound := assets.Inventory.Slots[targetKey]
if !extractorFound || extractor.ItemID <= 0 || currentNPCShopItemLocked(extractor) || !targetFound || target.ItemID <= 0 || target.ItemID > math.MaxUint32 || target.Count != 1 || currentNPCShopItemLocked(target) {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrExtractionInvalid
}
metadata, err := jobCatalog.Equipment(target.ItemID)
if err != nil {
return dnfexpertjob.Changes{}, err
}
if metadata.DisjointForbidden || metadata.AttachType == "trade delete" {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrExtractionInvalid
}
metadata.State = currentExpertJobEquipmentState(target)
plan, err := jobCatalog.PlanExtraction(jobType, experience, extractor.ItemID, metadata, rand.IntN)
if err != nil {
return dnfexpertjob.Changes{}, err
}
delete(assets.Inventory.Slots, targetKey)
// 提取产物(晶块/灵魂占多数)必须先过账号共享落点。accountChanged 决定
// 账号记录要不要跟着这次操作一起提交。
accountChanged := false
for _, reward := range plan.Materials {
if reward.ItemID <= 0 || reward.ItemID > math.MaxUint32 || reward.Count <= 0 || reward.Count > math.MaxUint32 {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrExtractionInvalid
}
definition, resolveErr := items.ResolveItem(uint32(reward.ItemID))
if resolveErr != nil || definition.Kind != dungeonDropItemStackable {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrExtractionInvalid
}
// 统一入口:有固定格就先进账号共享仓,余量再走角色背包。
rows, rewardAccountChanged, grantErr := grantCurrentExpertJobExtractionReward(
assets.Inventory,
assets.AccountInventory,
definition,
uint32(reward.Count),
s.currentInventoryPlacementMainSlotCount(ctx, assets.Inventory, characterID),
)
if grantErr != nil {
return dnfexpertjob.Changes{}, grantErr
}
accountChanged = accountChanged || rewardAccountChanged
result.Materials = append(result.Materials, rows...)
}
if assets.Character.Stats == nil {
assets.Character.Stats = make(map[string]int64)
}
assets.Character.Stats["expert_job_exp"] = plan.FinalExperience
result.JobType = jobType
result.TargetList = request.TargetList
result.TargetSlot = request.TargetSlot
result.ExperienceGain = plan.ExperienceGain
result.LevelChanged = plan.LevelChanged
sort.Slice(result.Materials, func(i, j int) bool { return result.Materials[i].Slot < result.Materials[j].Slot })
return dnfexpertjob.Changes{Character: plan.ExperienceGain != 0, AccountInventory: accountChanged, Inventory: true}, nil
}})
return result, err
}
// handleCurrentExpertJobExtractionBatch 结算一次批量提取:一个 ACK、一次容器刷新、
// 一次 op205,取代过去"每件一套事务/回包/重建"的做法。
// 只有客户端 90CN 钩子合成的信封才会走到这里。
func (s *Service) handleCurrentExpertJobExtractionBatch(session *gameSession, request currentExpertJobExtractionBatchRequest) error {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
result, err := s.commitCurrentExpertJobExtractionBatch(ctx, session, request)
if err != nil {
code := currentExpertJobExtractionError(err)
s.logGameEvent(session, "game-expert-job-extraction-batch-rejected", "extractor_type", request.ExtractorType, "extractor_slot", request.ExtractorSlot, "slot_count", len(request.Slots), "error", err, "error_code", code)
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketExpertExtraction), code)
}
// 合并回包。targetSlot 只能报一个(客户端读取器整个函数只清这一个源槽,已实测),
// 报第一个被消耗的槽;其余源槽由紧随其后的整容器重建权威刷新——与 op26/op235
// 批量的分工完全一致,那两条已在线上验证过。
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketExpertExtraction), buildCurrentExpertJobExtractionSuccessBody(result), dnfproto.DefaultChannelClassification); err != nil {
return err
}
// 整批只重建一次容器:与单件路径的收尾动作完全相同,只是从 N 次变成 1 次。
if err := s.sendSelectedCurrentContainerListsRefresh(session, "expert_job_extraction_batch_after_ack"); err != nil {
return err
}
if currentExpertJobProjectionNeedsRepublish(result.ExperienceGain, result.LevelChanged) {
if err := s.sendCurrentExpertJobInfoFromRepository(session, result.JobType, true); err != nil {
return err
}
}
s.logGameEvent(session, "game-expert-job-extraction-batch-committed", "job_type", result.JobType, "extractor_slot", request.ExtractorSlot, "slot_count", len(request.Slots), "materials", len(result.Materials), "experience_gain", result.ExperienceGain)
return nil
}
// commitCurrentExpertJobExtractionBatch 在一个事务里把 N 件装备各提取一次。
//
// 逐件与单件路径逐条同源:同一把提取器(不消耗、无耐久)、同样的目标校验、
// 同样的 PlanExtraction(逐件各掷各的随机数,所以产物分布与 N 次单件请求完全一致)、
// 经验逐件往下传(第 k+1 件按第 k 件结算后的经验算等级与成功率)。
// 任何一件不合法 → 直接返回错误 → 整个事务回滚(整批原子),与 op26/op235
// 批量的口径一致:绝不允许"分了一半"。
func (s *Service) commitCurrentExpertJobExtractionBatch(ctx context.Context, session *gameSession, request currentExpertJobExtractionBatchRequest) (currentExpertJobExtractionResult, error) {
jobCatalog, items, owner, accountID, characterID, err := s.currentExpertJobMutationContext(session)
if err != nil {
return currentExpertJobExtractionResult{}, err
}
var result currentExpertJobExtractionResult
if len(request.Slots) == 0 {
return currentExpertJobExtractionResult{}, dnfexpertjob.ErrExtractionInvalid
}
err = owner.Extract(ctx, dnfexpertjob.Command{AccountID: accountID, CharacterID: characterID, UpdatedAt: s.gameplayNow(), Project: func(assets *dnfexpertjob.Assets) (dnfexpertjob.Changes, error) {
jobType, experience, err := currentExpertJobCharacterState(assets.Character)
if err != nil || jobType != request.ExtractorType {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrJobUnsupported
}
// N 件共用同一把提取器:校验一次即可。提取器本身不会被消耗
// (单件路径同样只把它传给 PlanExtraction),所以逐件复查只是重复同一判断。
extractor, extractorFound := assets.Inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, request.ExtractorSlot)]
if !extractorFound || extractor.ItemID <= 0 || currentNPCShopItemLocked(extractor) {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrExtractionInvalid
}
finalExperience := experience
levelChanged := false
accountChanged := false
for _, targetSlot := range request.Slots {
targetKey := currentCeraShopInventorySlotKey(request.TargetList, targetSlot)
target, targetFound := assets.Inventory.Slots[targetKey]
if !targetFound || target.ItemID <= 0 || target.ItemID > math.MaxUint32 || target.Count != 1 || currentNPCShopItemLocked(target) {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrExtractionInvalid
}
metadata, metadataErr := jobCatalog.Equipment(target.ItemID)
if metadataErr != nil {
return dnfexpertjob.Changes{}, metadataErr
}
if metadata.DisjointForbidden || metadata.AttachType == "trade delete" {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrExtractionInvalid
}
metadata.State = currentExpertJobEquipmentState(target)
plan, planErr := jobCatalog.PlanExtraction(jobType, finalExperience, extractor.ItemID, metadata, rand.IntN)
if planErr != nil {
return dnfexpertjob.Changes{}, planErr
}
delete(assets.Inventory.Slots, targetKey)
for _, reward := range plan.Materials {
if reward.ItemID <= 0 || reward.ItemID > math.MaxUint32 || reward.Count <= 0 || reward.Count > math.MaxUint32 {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrExtractionInvalid
}
definition, resolveErr := items.ResolveItem(uint32(reward.ItemID))
if resolveErr != nil || definition.Kind != dungeonDropItemStackable {
return dnfexpertjob.Changes{}, dnfexpertjob.ErrExtractionInvalid
}
// 与单件路径同一个入口:有固定格先进账号共享仓,余量再走角色背包。
rows, rewardAccountChanged, grantErr := grantCurrentExpertJobExtractionReward(
assets.Inventory,
assets.AccountInventory,
definition,
uint32(reward.Count),
s.currentInventoryPlacementMainSlotCount(ctx, assets.Inventory, characterID),
)
if grantErr != nil {
return dnfexpertjob.Changes{}, grantErr
}
accountChanged = accountChanged || rewardAccountChanged
result.Materials = append(result.Materials, rows...)
}
finalExperience = plan.FinalExperience
levelChanged = levelChanged || plan.LevelChanged
result.ExperienceGain += plan.ExperienceGain
}
result.Materials = currentExpertJobMergeExtractionBatchRows(result.Materials)
if len(result.Materials) > currentExpertJobExtractionBatchMaxRows {
return dnfexpertjob.Changes{}, errCurrentDisjointRewardInvalid
}
if assets.Character.Stats == nil {
assets.Character.Stats = make(map[string]int64)
}
assets.Character.Stats["expert_job_exp"] = finalExperience
result.JobType = jobType
result.TargetList = request.TargetList
result.TargetSlot = request.Slots[0]
result.LevelChanged = levelChanged
sort.Slice(result.Materials, func(i, j int) bool { return result.Materials[i].Slot < result.Materials[j].Slot })
return dnfexpertjob.Changes{Character: result.ExperienceGain != 0 || levelChanged, AccountInventory: accountChanged, Inventory: true}, nil
}})
return result, err
}
// currentExpertJobMergeExtractionBatchRows 把 N 件产出的材料行按 (槽, 道具) 合并,
// 同一格的多行合成一行并累加数量。
//
// 为什么必须合并:客户端读取器是把回包里的行逐行应用到容器行上的,同一槽出现多行时
// 它的语义取决于底层是"置值"还是"累加"(未取证)。op26 批量的
// mergeCurrentDisjointBatchRewards 也是先聚合再回包,这里取同一口径:既避开这个歧义,
// 又把行数压到"涉及多少个格子"的规模。
func currentExpertJobMergeExtractionBatchRows(rows []currentExpertJobExtractionMaterial) []currentExpertJobExtractionMaterial {
if len(rows) < 2 {
return rows
}
type rowKey struct {
slot int16
itemID uint32
}
merged := make([]currentExpertJobExtractionMaterial, 0, len(rows))
seen := make(map[rowKey]int, len(rows))
for _, row := range rows {
key := rowKey{row.Slot, row.ItemID}
if at, found := seen[key]; found {
merged[at].Count += row.Count
continue
}
seen[key] = len(merged)
merged = append(merged, row)
}
return merged
}
func (s *Service) currentExpertJobMutationContext(session *gameSession) (*dnfexpertjob.Catalog, *pvfDungeonDropCatalog, *dnfexpertjob.Owner, string, string, error) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil, nil, nil, "", "", dnfexpertjob.ErrOwnerUnavailable
}
jobCatalog, err := s.currentExpertJobCatalog()
if err != nil {
return nil, nil, nil, "", "", err
}
items, err := s.currentPVFItemCatalog()
if err != nil {
return nil, nil, nil, "", "", err
}
repositories, ok := s.repositoryGroup()
if !ok {
return nil, nil, nil, "", "", dnfexpertjob.ErrOwnerUnavailable
}
owner, err := dnfexpertjob.NewOwner(repositories)
if err != nil {
return nil, nil, nil, "", "", err
}
accountID := strings.TrimSpace(s.accountIDForSession(session))
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
if accountID == "" {
return nil, nil, nil, "", "", dnfexpertjob.ErrOwnerUnavailable
}
return jobCatalog, items, owner, accountID, characterID, nil
}
func currentExpertJobCharacterState(character *dnfrepo.CharacterRecord) (byte, int64, error) {
if character == nil {
return 0, 0, dnfexpertjob.ErrCharacterNotFound
}
rawJobType := character.Stats["expert_job_type"]
if rawJobType < int64(dnfexpertjob.EnchanterType) || rawJobType > int64(dnfexpertjob.DollControllerType) {
return 0, 0, dnfexpertjob.ErrJobUnsupported
}
jobType := byte(rawJobType)
experience := character.Stats["expert_job_exp"]
if experience < 0 {
experience = 0
}
return jobType, experience, nil
}
func consumeCurrentExpertJobRecipe(
character *dnfrepo.CharacterRecord,
inventory *dnfrepo.InventoryRecord,
account *dnfrepo.AccountInventoryRecord,
materials []dnfexpertjob.RecipeEntry,
gold int64,
) (bool, error) {
if character == nil {
return false, dnfexpertjob.ErrCharacterNotFound
}
if inventory == nil {
return false, dnfexpertjob.ErrInventoryNotFound
}
required := make(map[int64]int64, len(materials))
for _, material := range materials {
if material.ItemID <= 0 || material.Count <= 0 || required[material.ItemID] > math.MaxInt64-material.Count {
return false, dnfexpertjob.ErrRecipeUnavailable
}
required[material.ItemID] += material.Count
}
if gold < 0 {
return false, dnfexpertjob.ErrRecipeUnavailable
}
if gold > 0 {
walletGold, goldErr := currentExpertJobWalletGold(character)
if goldErr != nil || walletGold < gold {
return false, errCurrentExpertJobInsufficientMaterials
}
}
// Cube fragments and souls live in fixed account-shared slots. The
// crafting UI counts those balances, so the transaction must validate and
// consume them from the same owner instead of searching only the backpack.
accountChanged := false
for itemID, count := range required {
fixedSlot, shared := currentExpertJobSharedMaterialSlot(itemID)
if !shared {
continue
}
if account == nil || account.Slots == nil {
return false, errCurrentExpertJobInsufficientMaterials
}
key := dnfrepo.AccountSharedInventorySlotKey(fixedSlot)
stack, found := account.Slots[key]
if !found || stack.ItemID != itemID || stack.Count < count || currentNPCShopItemLocked(stack) {
return false, errCurrentExpertJobInsufficientMaterials
}
}
type candidate struct {
key string
slot int16
}
candidates := make(map[int64][]candidate, len(required))
available := make(map[int64]int64, len(required))
for key, stack := range inventory.Slots {
listType, slot, ok := parseSceneInventorySlotKey(key)
if !ok || listType != dnfrepo.MainInventoryListType || slot <= 0 || stack.Count <= 0 || currentNPCShopItemLocked(stack) {
continue
}
if _, shared := currentExpertJobSharedMaterialSlot(stack.ItemID); shared {
continue
}
if _, needed := required[stack.ItemID]; !needed {
continue
}
available[stack.ItemID] += stack.Count
candidates[stack.ItemID] = append(candidates[stack.ItemID], candidate{key, slot})
}
for itemID, count := range required {
if _, shared := currentExpertJobSharedMaterialSlot(itemID); shared {
continue
}
if available[itemID] < count {
return false, errCurrentExpertJobInsufficientMaterials
}
}
for itemID, count := range required {
if fixedSlot, shared := currentExpertJobSharedMaterialSlot(itemID); shared {
key := dnfrepo.AccountSharedInventorySlotKey(fixedSlot)
stack := account.Slots[key]
stack.Count -= count
if stack.Count == 0 {
delete(account.Slots, key)
} else {
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, fixedSlot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
account.Slots[key] = stack
}
accountChanged = true
continue
}
sort.Slice(candidates[itemID], func(i, j int) bool { return candidates[itemID][i].slot < candidates[itemID][j].slot })
remaining := count
for _, candidate := range candidates[itemID] {
stack := inventory.Slots[candidate.key]
consumed := min(remaining, stack.Count)
stack.Count -= consumed
remaining -= consumed
if stack.Count == 0 {
delete(inventory.Slots, candidate.key)
} else {
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, candidate.slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
inventory.Slots[candidate.key] = stack
}
if remaining == 0 {
break
}
}
}
if gold > 0 {
walletGold, _ := currentExpertJobWalletGold(character)
currentExpertJobSetWalletGold(character, walletGold-gold)
}
return accountChanged, nil
}
func currentExpertJobSharedMaterialSlot(itemID int64) (int16, bool) {
// 走权威表(repository/account_shared_materials.go),不再自己查 bridge 的派生视图。
return dnfrepo.AccountSharedWarehouseSlotForItem(itemID)
}
// grantCurrentExpertJobCompoundReward 把一个副职业配方产物送到它的落点:
// 有固定格(六个晶块 / 六个灵魂)→ 先进账号共享仓;装不下的余量 → 角色背包。
// 返回账号记录是否被改动(调用方据此提交账号记录)。
//
// 抽成独立函数是为了**可测**:配方产物这一层过去直接调 grantCurrentCeraShopProduct,
// 无色/灵魂类产物会留在背包(2026-09-21 与 NPC 商店金币购买同类的漏点)。
func grantCurrentExpertJobCompoundReward(
inventory *dnfrepo.InventoryRecord,
account *dnfrepo.AccountInventoryRecord,
definition dungeonDropItemDefinition,
count uint32,
mainSlotCount uint16,
) (bool, error) {
shared := grantCurrentAccountSharedFirst(account, definition, count, "expert_job_compound")
if shared.Remainder > 0 {
if _, grantErr := grantCurrentCeraShopProduct(inventory, definition, shared.Remainder, mainSlotCount); grantErr != nil {
return false, grantErr
}
}
return shared.Changed, nil
}
// grantCurrentExpertJobExtractionReward 把一个提取产物送到它的落点,并给出 ACK 行。
//
// 提取产物以晶块/灵魂为主,所以这条路径同样必须先过账号共享落点。ACK 行与已修好的
// 分解机提取(expert_job_store.go)保持同一口径:**每个真正落库的格子报一条,数量报
// 该格的增量**(不是计划总量)—— 账号固定槽与溢出的背包槽各自成行。
func grantCurrentExpertJobExtractionReward(
inventory *dnfrepo.InventoryRecord,
account *dnfrepo.AccountInventoryRecord,
definition dungeonDropItemDefinition,
count uint32,
mainSlotCount uint16,
) ([]currentExpertJobExtractionMaterial, bool, error) {
if inventory == nil || count == 0 {
return nil, false, dnfexpertjob.ErrExtractionInvalid
}
// 角色侧可能把余量拆到多行(受 [stack limit] 限制),所以先记下背包里同 id 各行
// 的旧数量,落库后按"该槽增量"逐行报 —— 只报 slots[0] 配全部余量会让 ACK 撒谎。
previousCounts := make(map[int16]int64)
for key, stack := range inventory.Slots {
listType, slot, ok := parseSceneInventorySlotKey(key)
if ok && listType == dnfrepo.MainInventoryListType && stack.ItemID == int64(definition.ItemID) && stack.Count > 0 {
previousCounts[slot] = stack.Count
}
}
shared := grantCurrentAccountSharedFirst(account, definition, count, "expert_job_extraction")
rows := make([]currentExpertJobExtractionMaterial, 0, 2)
if shared.Changed {
rows = append(rows, currentExpertJobExtractionMaterial{
Slot: shared.Slot,
ItemID: definition.ItemID,
Count: uint32(shared.Placed),
})
}
if shared.Remainder > 0 {
slots, grantErr := grantCurrentCeraShopProduct(inventory, definition, shared.Remainder, mainSlotCount)
if grantErr != nil {
return nil, false, grantErr
}
if len(slots) == 0 {
return nil, false, dnfexpertjob.ErrExtractionInvalid
}
for _, rawSlot := range slots {
slot := int16(rawSlot)
stack, found := inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)]
if !found || stack.Count <= 0 {
return nil, false, dnfexpertjob.ErrExtractionInvalid
}
added := stack.Count - previousCounts[slot]
if added <= 0 {
// 该槽只是被 touch 到、没真正增加数量:不发这一行 ACK。
continue
}
rows = append(rows, currentExpertJobExtractionMaterial{
Slot: slot,
ItemID: definition.ItemID,
Count: uint32(added),
})
}
}
return rows, shared.Changed, nil
}
func currentExpertJobEquipmentState(stack dnfrepo.ItemStack) int {
if sceneInventoryExtraUint32(stack.Extra, "chronicle_option_count", "chronicle_count") > 0 {
return 2
}
if sceneInventoryExtraByte(stack.Extra, "amplify_type", "amplification_type", "byte_13", "value_13", "value_c")&0x80 != 0 {
return 1
}
return 0
}
func currentExpertJobEnchantUpgradeCount(stack dnfrepo.ItemStack) byte {
if value := sceneInventoryExtraByte(stack.Extra, "byte_12", "value_12", "enchant_upgrade_count"); value != 0 {
return value
}
if len(stack.RawEntry) > 0x12 {
return stack.RawEntry[0x12]
}
return 0
}
func currentExpertJobSetEnchantUpgradeCount(stack *dnfrepo.ItemStack, value byte) {
if stack == nil {
return
}
if stack.Extra == nil {
stack.Extra = make(map[string]string, 2)
}
stack.Extra["byte_12"] = strconv.Itoa(int(value))
stack.Extra["enchant_upgrade_count"] = strconv.Itoa(int(value))
if len(stack.RawEntry) == currentItemListEntryWireSize {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
stack.RawEntry[0x12] = value
}
}
// grantCurrentExpertJobBead places the completed bead with its card-upgrade
// byte already attached. Generic reward placement knows only the item ID and
// would merge a +N bead into a base bead (or vice versa) before this byte was
// written, changing every item in the destination stack. Bead stack identity
// therefore includes the same +0x12 value used by card movement and direct
// enchant validation.
func grantCurrentExpertJobBead(
inventory *dnfrepo.InventoryRecord,
definition dungeonDropItemDefinition,
upgradeCount byte,
) (int16, error) {
if inventory == nil || definition.ItemID == 0 || definition.Kind != dungeonDropItemStackable ||
definition.SlotStart < 0 || definition.SlotEnd < definition.SlotStart {
return 0, dnfexpertjob.ErrRecipeUnavailable
}
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
// PVF [stack limit] 是角色背包(list 0)这个容器里该道具的总量上限:额度按容器里
// 同 id 总量算,不是一个格子的余量。没写 [stack limit] 的道具沿用旧行为
// (StackLimit <= 0 时助手返回 MaxInt32,不做总量限制)。
//
// 这条路径刻意不套用 CanOpenContainerItemRow("同 id 只能有一行"):宝珠行的堆身份
// 还包含 +0x12 附魔等级(见上面的函数注释),而客户端的 op19 移动/整理本身就按这个
// 身份分堆 —— inventory 包的 canStack 拒绝跨等级合并,
// TestOwnerMoveAndSortKeepUpgradedMonsterCardSeparateFromBaseStack 也钉住了"基础
// 宝珠 4 颗 + 满级宝珠 1 颗同容器两行"这一布局。若在此禁止开第二行,满级附魔师只要
// 背包里还有一颗基础宝珠就再也做不出 +N 宝珠(整包被拒绝)。总量上限仍然生效:
// 额度用尽的宝珠和并包失败一样,走下面既有的 errDungeonPickupInventoryFull 拒绝路径。
space := int64(math.MaxInt32)
if definition.Kind == dungeonDropItemStackable && definition.StackLimit > 0 {
space = dnfrepo.ContainerItemSpace(
inventory.Slots,
dnfrepo.MainInventoryListType,
int64(definition.ItemID),
definition.StackLimit,
)
}
for slot := definition.SlotStart; slot <= definition.SlotEnd; slot++ {
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
stack, found := inventory.Slots[key]
if !found || stack.ItemID != int64(definition.ItemID) || stack.Bind || stack.Count <= 0 ||
!currentItemStackExpirationMatches(stack, definition.ExpirationDate) ||
currentExpertJobEnchantUpgradeCount(stack) != upgradeCount {
continue
}
limit := int64(math.MaxInt32)
if definition.StackLimit > 0 {
limit = definition.StackLimit
}
if stack.Count >= limit || space <= 0 {
continue
}
stack.Count++
space--
currentExpertJobSetEnchantUpgradeCount(&stack, upgradeCount)
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
inventory.Slots[key] = stack
return slot, nil
}
for slot := definition.SlotStart; slot <= definition.SlotEnd; slot++ {
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
if _, occupied := inventory.Slots[key]; occupied {
continue
}
// 总量额度已经用尽就不再入包:宝珠和并包不下时一样,走下面既有的
// errDungeonPickupInventoryFull 拒绝路径,绝不把容器写到 [stack limit] 之上。
if space <= 0 {
break
}
stack, err := buildCurrentDungeonPickupStack(slot, definition, 1)
if err != nil {
return 0, err
}
currentExpertJobSetEnchantUpgradeCount(&stack, upgradeCount)
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
inventory.Slots[key] = stack
return slot, nil
}
return 0, errDungeonPickupInventoryFull
}
var errCurrentExpertJobInsufficientMaterials = errors.New("expert job materials are insufficient")
func currentExpertJobCompoundError(err error) byte {
switch {
case errors.Is(err, errCurrentExpertJobInsufficientMaterials):
return 21
case errors.Is(err, dnfexpertjob.ErrLevelTooLow):
return 14
case errors.Is(err, errDungeonPickupInventoryFull), errors.Is(err, errCurrentDisjointRewardInvalid):
return 4
default:
return 19
}
}
func currentExpertJobExtractionError(err error) byte {
switch {
case errors.Is(err, errDungeonPickupInventoryFull), errors.Is(err, errCurrentDisjointRewardInvalid):
return 4
case errors.Is(err, dnfexpertjob.ErrExtractorInvalid), errors.Is(err, dnfexpertjob.ErrExtractionInvalid):
return 13
default:
return 19
}
}
func buildCurrentExpertJobCompoundSuccessBody(plan dnfexpertjob.CompoundPlan) []byte {
w := packetWriter{}
w.writeByte(1)
w.writeByte(byte(len(plan.AttemptedOutputs)))
for _, output := range plan.AttemptedOutputs {
w.writeUint32(uint32(output.ItemID))
w.writeUint32(uint32(output.Count))
}
w.writeUint32(uint32(plan.SuccessCount))
w.writeUint32(uint32(plan.FailureCount))
w.writeByte(0)
return w.bytes()
}
func buildCurrentExpertJobExtractionSuccessBody(result currentExpertJobExtractionResult) []byte {
w := packetWriter{}
w.writeByte(1)
w.writeByte(result.TargetList)
w.writeUint16(uint16(result.TargetSlot))
w.writeByte(byte(len(result.Materials)))
for _, material := range result.Materials {
w.writeUint16(uint16(material.Slot))
w.writeUint32(material.ItemID)
w.writeUint32(material.Count)
}
return w.bytes()
}