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