服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2214 lines
94 KiB
Go
2214 lines
94 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
cryptorand "crypto/rand"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
currentEpicProductionPVFPath = "etc/epicproduction/epicproduction.etc"
|
|
currentEpicProductionStartRequestSize = 4
|
|
currentEpicProductionStartIndex = 0
|
|
currentEpicProductionTargetStat = "epic_production_target_item_id"
|
|
currentEpicProductionChargeStat = "epic_production_charge_point"
|
|
currentEpicProductionWeeklyCountStat = "epic_production_weekly_count"
|
|
currentEpicProductionWeekStartStat = "epic_production_week_start_unix"
|
|
currentEpicProductionInfoMsgID = 1355
|
|
currentEpicProductionInfoStateSize = 28
|
|
currentEpicProductionProcessRowSize = 8
|
|
currentEpicProductionChangePrefixSize = 6
|
|
currentEpicProductionCompoundPrefixSize = 6
|
|
currentEpicProductionCompoundRowSize = 12
|
|
currentEpicProductionGenericError = 3
|
|
currentEpicProductionAlreadyActive = 18
|
|
currentEpicProductionWeeklyLimitError = 19
|
|
currentEpicProductionCarryGroup1Stat = "epic_production_material_carry_group_1"
|
|
currentEpicProductionCarryGroup2Stat = "epic_production_material_carry_group_2"
|
|
currentEpicProductionAbilityPVFPath = "etc/customability/epicproductionability.etc"
|
|
currentEpicProductionAbilityRequestSize = 4
|
|
currentEpicProductionAbilityTailOffset = 0x72
|
|
currentEpicProductionAbilityTailSize = 5
|
|
currentEpicProductionMaterialError = 22
|
|
)
|
|
|
|
var (
|
|
errCurrentEpicProductionInvalid = errors.New("dnf epic production request is invalid")
|
|
errCurrentEpicProductionAlreadyActive = errors.New("dnf epic production is already active")
|
|
errCurrentEpicProductionMaterial = errors.New("dnf epic production material is invalid")
|
|
errCurrentEpicProductionWeeklyLimit = errors.New("dnf epic production weekly limit reached")
|
|
)
|
|
|
|
type currentEpicProductionCatalyst struct {
|
|
ItemID uint32
|
|
GroupKey int32
|
|
LevelLimit uint32
|
|
NeedItemIDs map[uint32]struct{}
|
|
NeedItemType string
|
|
NeedItemParts string
|
|
NeedItemGrade string
|
|
NeedItemMinLevel uint32
|
|
NeedItemMaxLevel uint32
|
|
NeedCount uint32
|
|
MustNeedItemID uint32
|
|
MustNeedItemCount uint32
|
|
MinMakeCount uint32
|
|
MaxMakeCount uint32
|
|
MaxItemCountRate uint32
|
|
GetPoint uint32
|
|
IsMaterialPoint bool
|
|
IsSeal bool
|
|
AffectsTryCount bool
|
|
}
|
|
|
|
type currentEpicProductionCatalog struct {
|
|
targetsByJob map[string]map[uint32]struct{}
|
|
catalysts map[uint32]currentEpicProductionCatalyst
|
|
changeMaterials map[uint32]uint32
|
|
materialPointsByRarity map[int64]uint32
|
|
maxItemCountMaxRate uint32
|
|
levelLimit uint32
|
|
weeklyLimit uint32
|
|
maxChargePoint uint32
|
|
processMaterialNeedCount uint32
|
|
requiredMaterialItemID uint32
|
|
requiredMaterialItemCount uint32
|
|
bigChanceRate uint32
|
|
bigChanceMaxRate uint32
|
|
bigChanceMultiply uint32
|
|
minBigChanceLimit uint32
|
|
maxBigChanceLimit uint32
|
|
}
|
|
|
|
type currentEpicProductionProcessMaterial struct {
|
|
ItemID uint32
|
|
Slot int16
|
|
}
|
|
|
|
type currentEpicProductionProcessRequest struct {
|
|
Materials []currentEpicProductionProcessMaterial
|
|
}
|
|
|
|
type currentEpicProductionProcessResult struct {
|
|
ChargePoint uint32
|
|
WeeklyCount uint32
|
|
BigSuccess bool
|
|
Updates []currentItemListEntry
|
|
}
|
|
|
|
type currentEpicProductionChangeRequest struct {
|
|
TargetItemID uint32
|
|
Materials []currentEpicProductionProcessMaterial
|
|
}
|
|
|
|
type currentEpicProductionChangeResult struct {
|
|
TargetItemID uint32
|
|
Updates []currentItemListEntry
|
|
}
|
|
|
|
type currentEpicProductionCompoundMaterial struct {
|
|
ItemID uint32
|
|
Slot int16
|
|
Count uint32
|
|
}
|
|
|
|
type currentEpicProductionCompoundRequest struct {
|
|
CatalystItemID uint32
|
|
Materials []currentEpicProductionCompoundMaterial
|
|
}
|
|
|
|
type currentEpicProductionCompoundPlan struct {
|
|
Recipe currentEpicProductionCatalyst
|
|
OutputDefinition dungeonDropItemDefinition
|
|
Contribution uint64
|
|
}
|
|
|
|
type currentEpicProductionCompoundResult struct {
|
|
CatalystItemID uint32
|
|
OutputCount uint32
|
|
CarryValue uint32
|
|
GroupSelector byte
|
|
// MustNeedItemID/Count record the scaled [must need item] charge so the
|
|
// applied event can show what the batch actually cost.
|
|
MustNeedItemID uint32
|
|
MustNeedItemCount uint32
|
|
Updates []currentItemListEntry
|
|
}
|
|
|
|
type currentEpicProductionAbilityMaterial struct {
|
|
ItemID uint32
|
|
Count uint32
|
|
}
|
|
|
|
type currentEpicProductionAbilityRecipe struct {
|
|
Category byte
|
|
Option byte
|
|
Materials []currentEpicProductionAbilityMaterial
|
|
}
|
|
|
|
type currentEpicProductionAbilityCatalog struct {
|
|
Recipes map[byte]map[byte]currentEpicProductionAbilityRecipe
|
|
}
|
|
|
|
type currentEpicProductionAbilityRequest struct {
|
|
Slot int16
|
|
Category byte
|
|
Option byte
|
|
}
|
|
|
|
type currentEpicProductionAbilityResult struct {
|
|
Applied bool
|
|
Updates []currentItemListEntry
|
|
}
|
|
|
|
type currentEpicProductionAbilityTarget struct {
|
|
ItemID uint32
|
|
CharacterJob string
|
|
}
|
|
|
|
func decodeCurrentEpicProductionStartRequest(body []byte) (uint32, error) {
|
|
if len(body) != currentEpicProductionStartRequestSize {
|
|
return 0, fmt.Errorf("%w: body length=%d want=%d", errCurrentEpicProductionInvalid, len(body), currentEpicProductionStartRequestSize)
|
|
}
|
|
targetItemID := binary.LittleEndian.Uint32(body)
|
|
if targetItemID == 0 {
|
|
return 0, fmt.Errorf("%w: target item id is zero", errCurrentEpicProductionInvalid)
|
|
}
|
|
return targetItemID, nil
|
|
}
|
|
|
|
func parseCurrentEpicProductionCatalog(source initialEquipmentTextSource) (*currentEpicProductionCatalog, error) {
|
|
if source == nil {
|
|
return nil, fmt.Errorf("%w: PVF source is nil", errCurrentEpicProductionInvalid)
|
|
}
|
|
text, err := source.ReadText(currentEpicProductionPVFPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", currentEpicProductionPVFPath, err)
|
|
}
|
|
document, err := dnfpvf.Parse(currentEpicProductionPVFPath, text)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", currentEpicProductionPVFPath, err)
|
|
}
|
|
|
|
targetsByJob := make(map[string]map[uint32]struct{})
|
|
catalysts := make(map[uint32]currentEpicProductionCatalyst)
|
|
inProductionItems := false
|
|
inLiquidList := false
|
|
currentJob := ""
|
|
inCatalyst := false
|
|
currentCatalyst := currentEpicProductionCatalyst{GroupKey: -1}
|
|
commitCatalyst := func() {
|
|
if currentCatalyst.ItemID != 0 && currentCatalyst.GetPoint != 0 && currentCatalyst.NeedCount != 0 &&
|
|
currentCatalyst.MinMakeCount != 0 && currentCatalyst.MaxMakeCount >= currentCatalyst.MinMakeCount {
|
|
catalysts[currentCatalyst.ItemID] = currentCatalyst
|
|
}
|
|
currentCatalyst = currentEpicProductionCatalyst{GroupKey: -1}
|
|
inCatalyst = false
|
|
}
|
|
catalog := ¤tEpicProductionCatalog{
|
|
targetsByJob: targetsByJob,
|
|
catalysts: catalysts,
|
|
changeMaterials: make(map[uint32]uint32),
|
|
materialPointsByRarity: make(map[int64]uint32),
|
|
}
|
|
for _, section := range document.Sections {
|
|
name := currentRentalSectionName(section.Name)
|
|
tokens, ok := currentRentalSectionTokens(document, section)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: invalid [%s] token range", errCurrentEpicProductionInvalid, section.Name)
|
|
}
|
|
uints := currentEpicProductionUintTokens(tokens)
|
|
switch name {
|
|
case "production item":
|
|
inProductionItems = true
|
|
currentJob = ""
|
|
continue
|
|
case "/production item":
|
|
inProductionItems = false
|
|
currentJob = ""
|
|
continue
|
|
case "liquid list":
|
|
inLiquidList = true
|
|
continue
|
|
case "/liquid list":
|
|
commitCatalyst()
|
|
inLiquidList = false
|
|
continue
|
|
}
|
|
if !inProductionItems && !inLiquidList {
|
|
switch name {
|
|
case "level limit":
|
|
catalog.levelLimit = currentEpicProductionFirstUint(uints)
|
|
case "weekly limit":
|
|
catalog.weeklyLimit = currentEpicProductionFirstUint(uints)
|
|
case "max charge point":
|
|
catalog.maxChargePoint = currentEpicProductionFirstUint(uints)
|
|
case "process material need count":
|
|
catalog.processMaterialNeedCount = currentEpicProductionFirstUint(uints)
|
|
case "must need item":
|
|
if len(uints) >= 2 {
|
|
catalog.requiredMaterialItemID = uints[0]
|
|
catalog.requiredMaterialItemCount = uints[1]
|
|
}
|
|
case "need item type change":
|
|
for index := 0; index+1 < len(uints); index += 2 {
|
|
catalog.changeMaterials[uints[index]] = uints[index+1]
|
|
}
|
|
case "big chance rate":
|
|
catalog.bigChanceRate = currentEpicProductionFirstUint(uints)
|
|
case "big chance max rate":
|
|
catalog.bigChanceMaxRate = currentEpicProductionFirstUint(uints)
|
|
case "big chance multiply":
|
|
catalog.bigChanceMultiply = currentEpicProductionFirstUint(uints)
|
|
case "min big chance limit":
|
|
catalog.minBigChanceLimit = currentEpicProductionFirstUint(uints)
|
|
case "max big chance limit":
|
|
catalog.maxBigChanceLimit = currentEpicProductionFirstUint(uints)
|
|
case "max item count max rate":
|
|
catalog.maxItemCountMaxRate = currentEpicProductionFirstUint(uints)
|
|
}
|
|
}
|
|
if inLiquidList {
|
|
switch name {
|
|
case "item index":
|
|
commitCatalyst()
|
|
inCatalyst = true
|
|
currentCatalyst.ItemID = currentEpicProductionFirstUint(uints)
|
|
currentCatalyst.NeedItemIDs = make(map[uint32]struct{})
|
|
case "/item index":
|
|
commitCatalyst()
|
|
case "material point list":
|
|
for index := 0; index+1 < len(tokens); index += 2 {
|
|
if tokens[index].Kind != dnfpvf.TokenInt || tokens[index+1].Kind != dnfpvf.TokenInt ||
|
|
tokens[index].Int < 0 || tokens[index+1].Int <= 0 || tokens[index+1].Int > math.MaxUint32 {
|
|
continue
|
|
}
|
|
catalog.materialPointsByRarity[tokens[index].Int] = uint32(tokens[index+1].Int)
|
|
}
|
|
case "max item count max rate":
|
|
if !inCatalyst {
|
|
catalog.maxItemCountMaxRate = currentEpicProductionFirstUint(uints)
|
|
}
|
|
case "group key":
|
|
if !inCatalyst {
|
|
break
|
|
}
|
|
for _, token := range tokens {
|
|
if token.Kind == dnfpvf.TokenInt && token.Int >= 0 && token.Int <= math.MaxInt32 {
|
|
currentCatalyst.GroupKey = int32(token.Int)
|
|
break
|
|
}
|
|
}
|
|
case "level limit":
|
|
if inCatalyst {
|
|
currentCatalyst.LevelLimit = currentEpicProductionFirstUint(uints)
|
|
}
|
|
case "need item list":
|
|
if inCatalyst {
|
|
for _, itemID := range uints {
|
|
currentCatalyst.NeedItemIDs[itemID] = struct{}{}
|
|
}
|
|
}
|
|
case "need item type":
|
|
if inCatalyst {
|
|
currentCatalyst.NeedItemType = currentEpicProductionFirstText(tokens)
|
|
}
|
|
case "need item parts":
|
|
if inCatalyst {
|
|
currentCatalyst.NeedItemParts = currentEpicProductionFirstText(tokens)
|
|
}
|
|
case "need item grade":
|
|
if inCatalyst {
|
|
currentCatalyst.NeedItemGrade = currentEpicProductionFirstText(tokens)
|
|
}
|
|
case "need item min level limit":
|
|
if inCatalyst {
|
|
currentCatalyst.NeedItemMinLevel = currentEpicProductionFirstUint(uints)
|
|
}
|
|
case "need item max level limit":
|
|
if inCatalyst {
|
|
currentCatalyst.NeedItemMaxLevel = currentEpicProductionFirstUint(uints)
|
|
}
|
|
case "need item count":
|
|
if inCatalyst {
|
|
currentCatalyst.NeedCount = currentEpicProductionFirstUint(uints)
|
|
}
|
|
case "must need item":
|
|
if inCatalyst && len(uints) >= 2 {
|
|
currentCatalyst.MustNeedItemID = uints[0]
|
|
currentCatalyst.MustNeedItemCount = uints[1]
|
|
}
|
|
case "min make item count":
|
|
if inCatalyst {
|
|
currentCatalyst.MinMakeCount = currentEpicProductionFirstUint(uints)
|
|
}
|
|
case "max make item count":
|
|
if inCatalyst {
|
|
currentCatalyst.MaxMakeCount = currentEpicProductionFirstUint(uints)
|
|
}
|
|
case "max item count rate":
|
|
if inCatalyst {
|
|
currentCatalyst.MaxItemCountRate = currentEpicProductionFirstUint(uints)
|
|
}
|
|
case "get point":
|
|
if inCatalyst {
|
|
currentCatalyst.GetPoint = currentEpicProductionFirstUint(uints)
|
|
}
|
|
case "is material point":
|
|
if inCatalyst {
|
|
currentCatalyst.IsMaterialPoint = currentEpicProductionFirstUint(uints) != 0
|
|
}
|
|
case "is seal":
|
|
if inCatalyst {
|
|
currentCatalyst.IsSeal = currentEpicProductionFirstUint(uints) != 0
|
|
}
|
|
case "is affect try count":
|
|
if inCatalyst {
|
|
currentCatalyst.AffectsTryCount = currentEpicProductionFirstUint(uints) != 0
|
|
}
|
|
}
|
|
}
|
|
if !inProductionItems {
|
|
continue
|
|
}
|
|
switch name {
|
|
case "job":
|
|
currentJob = ""
|
|
for _, token := range tokens {
|
|
if token.Kind == dnfpvf.TokenString || token.Kind == dnfpvf.TokenIdent {
|
|
currentJob = currentRentalJobTag(token.Value)
|
|
break
|
|
}
|
|
}
|
|
case "indexes":
|
|
if currentJob == "" {
|
|
continue
|
|
}
|
|
if targetsByJob[currentJob] == nil {
|
|
targetsByJob[currentJob] = make(map[uint32]struct{})
|
|
}
|
|
for _, itemID := range uints {
|
|
targetsByJob[currentJob][itemID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
if len(targetsByJob) == 0 {
|
|
return nil, fmt.Errorf("%w: %s has no production targets", errCurrentEpicProductionInvalid, currentEpicProductionPVFPath)
|
|
}
|
|
if len(catalysts) == 0 || catalog.maxChargePoint == 0 || catalog.weeklyLimit == 0 ||
|
|
catalog.processMaterialNeedCount == 0 || catalog.requiredMaterialItemID == 0 || catalog.requiredMaterialItemCount == 0 {
|
|
return nil, fmt.Errorf("%w: %s has incomplete process configuration", errCurrentEpicProductionInvalid, currentEpicProductionPVFPath)
|
|
}
|
|
if catalog.bigChanceRate > catalog.bigChanceMaxRate || catalog.bigChanceMultiply == 0 || catalog.bigChanceMaxRate == 0 {
|
|
return nil, fmt.Errorf("%w: %s has invalid big-success configuration", errCurrentEpicProductionInvalid, currentEpicProductionPVFPath)
|
|
}
|
|
return catalog, nil
|
|
}
|
|
|
|
func currentEpicProductionUintTokens(tokens []dnfpvf.Token) []uint32 {
|
|
values := make([]uint32, 0, len(tokens))
|
|
for _, token := range tokens {
|
|
if token.Kind == dnfpvf.TokenInt && token.Int > 0 && token.Int <= math.MaxUint32 {
|
|
values = append(values, uint32(token.Int))
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
func currentEpicProductionFirstUint(values []uint32) uint32 {
|
|
if len(values) == 0 {
|
|
return 0
|
|
}
|
|
return values[0]
|
|
}
|
|
|
|
func currentEpicProductionFirstText(tokens []dnfpvf.Token) string {
|
|
for _, token := range tokens {
|
|
if token.Kind == dnfpvf.TokenString || token.Kind == dnfpvf.TokenIdent {
|
|
return strings.ToLower(strings.TrimSpace(token.Value))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *currentEpicProductionCatalog) allows(jobTag string, targetItemID uint32) bool {
|
|
if c == nil || targetItemID == 0 {
|
|
return false
|
|
}
|
|
_, ok := c.targetsByJob[currentRentalJobTag(jobTag)][targetItemID]
|
|
return ok
|
|
}
|
|
|
|
func validateCurrentEpicProductionTarget(source initialEquipmentTextSource, items *pvfDungeonDropCatalog, catalog *currentEpicProductionCatalog, jobTag string, targetItemID uint32) (dungeonDropItemDefinition, error) {
|
|
if source == nil || items == nil || catalog == nil || !catalog.allows(jobTag, targetItemID) {
|
|
return dungeonDropItemDefinition{}, fmt.Errorf("%w: target item=%d is not configured for job=%s", errCurrentEpicProductionInvalid, targetItemID, jobTag)
|
|
}
|
|
definition, err := items.ResolveItem(targetItemID)
|
|
if err != nil {
|
|
return dungeonDropItemDefinition{}, fmt.Errorf("%w: resolve target item=%d: %v", errCurrentEpicProductionInvalid, targetItemID, err)
|
|
}
|
|
if definition.Kind != dungeonDropItemEquipment || currentRentalJobTag(definition.EquipmentType) != "[weapon]" {
|
|
return dungeonDropItemDefinition{}, fmt.Errorf("%w: target item=%d is not a weapon", errCurrentEpicProductionInvalid, targetItemID)
|
|
}
|
|
text, err := source.ReadText(definition.PVFPath)
|
|
if err != nil {
|
|
return dungeonDropItemDefinition{}, fmt.Errorf("%w: read target item=%d path=%s: %v", errCurrentEpicProductionInvalid, targetItemID, definition.PVFPath, err)
|
|
}
|
|
document, err := dnfpvf.Parse(definition.PVFPath, text)
|
|
if err != nil {
|
|
return dungeonDropItemDefinition{}, fmt.Errorf("%w: parse target item=%d path=%s: %v", errCurrentEpicProductionInvalid, targetItemID, definition.PVFPath, err)
|
|
}
|
|
ability, found := document.Text("custom ability type")
|
|
if !found || currentRentalJobTag(ability) != "epic production" {
|
|
return dungeonDropItemDefinition{}, fmt.Errorf("%w: target item=%d lacks [custom ability type] epic production", errCurrentEpicProductionInvalid, targetItemID)
|
|
}
|
|
return definition, nil
|
|
}
|
|
|
|
func startCurrentEpicProduction(ctx context.Context, characters dnfrepo.CharacterRepository, accountID, characterID string, targetItemID uint32) (bool, error) {
|
|
if characters == nil || strings.TrimSpace(accountID) == "" || strings.TrimSpace(characterID) == "" || targetItemID == 0 {
|
|
return false, errCurrentEpicProductionInvalid
|
|
}
|
|
character, found, err := characters.Load(ctx, characterID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !found || strings.TrimSpace(character.CharacterID) != strings.TrimSpace(characterID) || strings.TrimSpace(character.AccountID) != strings.TrimSpace(accountID) {
|
|
return false, errCurrentEpicProductionInvalid
|
|
}
|
|
if character.Stats == nil {
|
|
character.Stats = make(map[string]int64)
|
|
}
|
|
current := character.Stats[currentEpicProductionTargetStat]
|
|
if current > 0 {
|
|
if current == int64(targetItemID) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("%w: current=%d requested=%d", errCurrentEpicProductionAlreadyActive, current, targetItemID)
|
|
}
|
|
character.Stats[currentEpicProductionTargetStat] = int64(targetItemID)
|
|
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func buildCurrentEpicProductionStartSuccessBody(targetItemID uint32) []byte {
|
|
var body packetWriter
|
|
body.writeUint32(targetItemID)
|
|
body.writeUint32(currentEpicProductionStartIndex)
|
|
return body.bytes()
|
|
}
|
|
|
|
func (s *Service) handleCurrentEpicProductionStart(session *gameSession, body []byte) error {
|
|
if session == nil {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
targetItemID, err := decodeCurrentEpicProductionStartRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-start-blocked", "body_len", len(body), "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), currentEpicProductionGenericError)
|
|
}
|
|
if session.selectedCharacterID == 0 {
|
|
s.logGameEvent(session, "game-upper-epic-production-start-blocked", "target_item_id", targetItemID, "reason", "selected_character_missing")
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), currentEpicProductionGenericError)
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-start-blocked", "target_item_id", targetItemID, "reason", "character_repository_missing")
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), currentEpicProductionGenericError)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
|
character, found, err := repositories.Character.Load(ctx, characterID)
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
if err != nil || !found || accountID == "" || strings.TrimSpace(character.AccountID) != accountID {
|
|
s.logGameEvent(session, "game-upper-epic-production-start-blocked", "target_item_id", targetItemID, "character_id", characterID, "reason", errCurrentEpicProductionInvalid)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), currentEpicProductionGenericError)
|
|
}
|
|
jobValue := numericCharacterStat(character.Job)
|
|
if jobValue < 0 || jobValue > math.MaxUint8 {
|
|
s.logGameEvent(session, "game-upper-epic-production-start-blocked", "target_item_id", targetItemID, "character_id", characterID, "job", character.Job, "reason", "job_out_of_u8_range")
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), currentEpicProductionGenericError)
|
|
}
|
|
|
|
s.initialEquipmentMu.Lock()
|
|
archive, err := s.initialEquipmentArchiveLocked()
|
|
s.initialEquipmentMu.Unlock()
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-start-blocked", "target_item_id", targetItemID, "character_id", characterID, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), currentEpicProductionGenericError)
|
|
}
|
|
productionCatalog, err := parseCurrentEpicProductionCatalog(archive)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-start-blocked", "target_item_id", targetItemID, "character_id", characterID, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), currentEpicProductionGenericError)
|
|
}
|
|
items, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-start-blocked", "target_item_id", targetItemID, "character_id", characterID, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), currentEpicProductionGenericError)
|
|
}
|
|
jobTag, err := currentRentalCharacterJobTag(archive, byte(jobValue))
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-start-blocked", "target_item_id", targetItemID, "character_id", characterID, "job", jobValue, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), currentEpicProductionGenericError)
|
|
}
|
|
definition, err := validateCurrentEpicProductionTarget(archive, items, productionCatalog, jobTag, targetItemID)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-start-blocked", "target_item_id", targetItemID, "character_id", characterID, "job", jobValue, "job_tag", jobTag, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), currentEpicProductionGenericError)
|
|
}
|
|
|
|
changed, err := startCurrentEpicProduction(ctx, repositories.Character, accountID, characterID, targetItemID)
|
|
if err != nil {
|
|
code := byte(currentEpicProductionGenericError)
|
|
if errors.Is(err, errCurrentEpicProductionAlreadyActive) {
|
|
code = currentEpicProductionAlreadyActive
|
|
}
|
|
s.logGameEvent(session, "game-upper-epic-production-start-blocked", "target_item_id", targetItemID, "character_id", characterID, "job", jobValue, "job_tag", jobTag, "result_code", code, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), code)
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketEpicProductionStartFinish), buildCurrentEpicProductionStartSuccessBody(targetItemID)); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-upper-epic-production-start-applied",
|
|
"target_item_id", targetItemID,
|
|
"character_id", characterID,
|
|
"job", jobValue,
|
|
"job_tag", jobTag,
|
|
"pvf_path", definition.PVFPath,
|
|
"result_index", currentEpicProductionStartIndex,
|
|
"state_changed", changed,
|
|
"body_source", "current_exe_op1417_success_u8_true_u32_target_u32_valid_index")
|
|
return nil
|
|
}
|
|
|
|
func parseCurrentEpicProductionAbilityCatalog(source initialEquipmentTextSource) (*currentEpicProductionAbilityCatalog, error) {
|
|
if source == nil {
|
|
return nil, errCurrentEpicProductionInvalid
|
|
}
|
|
text, err := source.ReadText(currentEpicProductionAbilityPVFPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", currentEpicProductionAbilityPVFPath, err)
|
|
}
|
|
document, err := dnfpvf.Parse(currentEpicProductionAbilityPVFPath, text)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", currentEpicProductionAbilityPVFPath, err)
|
|
}
|
|
abilityType, found := document.Text("custom ability type")
|
|
if !found || currentRentalJobTag(abilityType) != "epic production" {
|
|
return nil, fmt.Errorf("%w: invalid custom ability type", errCurrentEpicProductionInvalid)
|
|
}
|
|
|
|
indexCategories := make(map[uint32]byte)
|
|
inGroup := false
|
|
groupCategory := byte(math.MaxUint8)
|
|
for _, section := range document.Sections {
|
|
name := currentRentalSectionName(section.Name)
|
|
tokens, ok := currentRentalSectionTokens(document, section)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: invalid [%s] token range", errCurrentEpicProductionInvalid, section.Name)
|
|
}
|
|
values := currentEpicProductionUintTokens(tokens)
|
|
switch name {
|
|
case "group":
|
|
inGroup = true
|
|
groupCategory = math.MaxUint8
|
|
continue
|
|
case "/group":
|
|
inGroup = false
|
|
groupCategory = math.MaxUint8
|
|
continue
|
|
}
|
|
if !inGroup {
|
|
continue
|
|
}
|
|
switch name {
|
|
case "key":
|
|
groupKey := uint64(0)
|
|
if len(values) != 0 {
|
|
groupKey = uint64(values[0])
|
|
} else if text := currentEpicProductionFirstText(tokens); text != "" {
|
|
groupKey, _ = strconv.ParseUint(strings.TrimSpace(text), 10, 8)
|
|
}
|
|
if groupKey == 0 || groupKey > 3 {
|
|
return nil, fmt.Errorf("%w: ability group key=%d", errCurrentEpicProductionInvalid, groupKey)
|
|
}
|
|
groupCategory = byte(groupKey - 1)
|
|
case "indexes":
|
|
if len(values) == 0 {
|
|
continue
|
|
}
|
|
if groupCategory == math.MaxUint8 {
|
|
return nil, fmt.Errorf("%w: ability indexes precede group key", errCurrentEpicProductionInvalid)
|
|
}
|
|
for _, value := range values {
|
|
if value == 0 || value > math.MaxUint8 {
|
|
return nil, fmt.Errorf("%w: ability option=%d", errCurrentEpicProductionInvalid, value)
|
|
}
|
|
if _, duplicate := indexCategories[value]; duplicate {
|
|
return nil, fmt.Errorf("%w: duplicate ability option=%d", errCurrentEpicProductionInvalid, value)
|
|
}
|
|
indexCategories[value] = groupCategory
|
|
}
|
|
}
|
|
}
|
|
|
|
catalog := ¤tEpicProductionAbilityCatalog{Recipes: make(map[byte]map[byte]currentEpicProductionAbilityRecipe)}
|
|
inInfo := false
|
|
option := uint32(0)
|
|
materials := make([]currentEpicProductionAbilityMaterial, 0, 3)
|
|
commit := func() error {
|
|
if option == 0 && len(materials) == 0 {
|
|
return nil
|
|
}
|
|
category, ok := indexCategories[option]
|
|
if !ok || option > math.MaxUint8 || len(materials) == 0 {
|
|
return fmt.Errorf("%w: incomplete ability recipe option=%d", errCurrentEpicProductionInvalid, option)
|
|
}
|
|
if catalog.Recipes[category] == nil {
|
|
catalog.Recipes[category] = make(map[byte]currentEpicProductionAbilityRecipe)
|
|
}
|
|
optionByte := byte(option)
|
|
if _, duplicate := catalog.Recipes[category][optionByte]; duplicate {
|
|
return fmt.Errorf("%w: duplicate ability recipe option=%d", errCurrentEpicProductionInvalid, option)
|
|
}
|
|
catalog.Recipes[category][optionByte] = currentEpicProductionAbilityRecipe{
|
|
Category: category,
|
|
Option: optionByte,
|
|
Materials: append([]currentEpicProductionAbilityMaterial(nil), materials...),
|
|
}
|
|
return nil
|
|
}
|
|
for _, section := range document.Sections {
|
|
name := currentRentalSectionName(section.Name)
|
|
tokens, ok := currentRentalSectionTokens(document, section)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: invalid [%s] token range", errCurrentEpicProductionInvalid, section.Name)
|
|
}
|
|
values := currentEpicProductionUintTokens(tokens)
|
|
switch name {
|
|
case "info":
|
|
inInfo = true
|
|
option = 0
|
|
materials = materials[:0]
|
|
continue
|
|
case "/info":
|
|
if inInfo {
|
|
if err := commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
inInfo = false
|
|
continue
|
|
}
|
|
if !inInfo || len(values) == 0 {
|
|
continue
|
|
}
|
|
switch name {
|
|
case "index":
|
|
option = values[0]
|
|
case "list":
|
|
if len(values)%2 != 0 {
|
|
return nil, fmt.Errorf("%w: ability option=%d material token count=%d", errCurrentEpicProductionInvalid, option, len(values))
|
|
}
|
|
seen := make(map[uint32]struct{}, len(values)/2)
|
|
for index := 0; index < len(values); index += 2 {
|
|
itemID, count := values[index], values[index+1]
|
|
if itemID == 0 || count == 0 {
|
|
return nil, fmt.Errorf("%w: ability option=%d invalid material", errCurrentEpicProductionInvalid, option)
|
|
}
|
|
if _, duplicate := seen[itemID]; duplicate {
|
|
return nil, fmt.Errorf("%w: ability option=%d duplicate material=%d", errCurrentEpicProductionInvalid, option, itemID)
|
|
}
|
|
seen[itemID] = struct{}{}
|
|
materials = append(materials, currentEpicProductionAbilityMaterial{ItemID: itemID, Count: count})
|
|
}
|
|
}
|
|
}
|
|
if len(catalog.Recipes) == 0 {
|
|
return nil, fmt.Errorf("%w: no epic production ability recipes", errCurrentEpicProductionInvalid)
|
|
}
|
|
return catalog, nil
|
|
}
|
|
|
|
func decodeCurrentEpicProductionAbilityRequest(body []byte) (currentEpicProductionAbilityRequest, error) {
|
|
if len(body) != currentEpicProductionAbilityRequestSize {
|
|
return currentEpicProductionAbilityRequest{}, fmt.Errorf("%w: ability body length=%d", errCurrentEpicProductionInvalid, len(body))
|
|
}
|
|
slot := int16(binary.LittleEndian.Uint16(body[:2]))
|
|
if slot < 0 || dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, slot) {
|
|
return currentEpicProductionAbilityRequest{}, fmt.Errorf("%w: ability slot=%d", errCurrentEpicProductionInvalid, slot)
|
|
}
|
|
return currentEpicProductionAbilityRequest{Slot: slot, Category: body[2], Option: body[3]}, nil
|
|
}
|
|
|
|
func currentEpicProductionAbilityRecipeFor(
|
|
catalog *currentEpicProductionAbilityCatalog,
|
|
request currentEpicProductionAbilityRequest,
|
|
) (currentEpicProductionAbilityRecipe, error) {
|
|
if catalog == nil || catalog.Recipes == nil {
|
|
return currentEpicProductionAbilityRecipe{}, errCurrentEpicProductionInvalid
|
|
}
|
|
recipe, found := catalog.Recipes[request.Category][request.Option]
|
|
if !found || recipe.Category != request.Category || recipe.Option != request.Option || len(recipe.Materials) == 0 {
|
|
return currentEpicProductionAbilityRecipe{}, fmt.Errorf("%w: ability category=%d option=%d", errCurrentEpicProductionInvalid, request.Category, request.Option)
|
|
}
|
|
return recipe, nil
|
|
}
|
|
|
|
func (s *Service) loadCurrentEpicProductionAbilityTarget(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
request currentEpicProductionAbilityRequest,
|
|
) (currentEpicProductionAbilityTarget, error) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 || request.Slot < 0 ||
|
|
dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, request.Slot) {
|
|
return currentEpicProductionAbilityTarget{}, errCurrentEpicProductionInvalid
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil || repositories.Inventory == nil {
|
|
return currentEpicProductionAbilityTarget{}, errCurrentEpicProductionInvalid
|
|
}
|
|
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
if accountID == "" {
|
|
return currentEpicProductionAbilityTarget{}, errCurrentEpicProductionInvalid
|
|
}
|
|
character, found, err := repositories.Character.Load(ctx, characterID)
|
|
if err != nil {
|
|
return currentEpicProductionAbilityTarget{}, err
|
|
}
|
|
if !found || character.CharacterID != characterID || strings.TrimSpace(character.AccountID) != accountID {
|
|
return currentEpicProductionAbilityTarget{}, errCurrentEpicProductionInvalid
|
|
}
|
|
inventory, found, err := repositories.Inventory.Load(ctx, characterID)
|
|
if err != nil {
|
|
return currentEpicProductionAbilityTarget{}, err
|
|
}
|
|
if !found || inventory.CharacterID != characterID || inventory.Slots == nil {
|
|
return currentEpicProductionAbilityTarget{}, errCurrentEpicProductionInvalid
|
|
}
|
|
stack, found := inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, request.Slot)]
|
|
if !found || stack.ItemID <= 0 || stack.ItemID > math.MaxUint32 || stack.Count <= 0 || currentNPCShopItemLocked(stack) {
|
|
return currentEpicProductionAbilityTarget{}, fmt.Errorf("%w: ability target slot=%d", errCurrentEpicProductionInvalid, request.Slot)
|
|
}
|
|
return currentEpicProductionAbilityTarget{ItemID: uint32(stack.ItemID), CharacterJob: character.Job}, nil
|
|
}
|
|
|
|
func currentEpicProductionConsumeAbilityMaterial(
|
|
account *dnfrepo.AccountInventoryRecord,
|
|
inventory *dnfrepo.InventoryRecord,
|
|
material currentEpicProductionAbilityMaterial,
|
|
) ([]currentItemListEntry, bool, error) {
|
|
if material.ItemID == 0 || material.Count == 0 {
|
|
return nil, false, errCurrentEpicProductionMaterial
|
|
}
|
|
if fixedSlot, shared := currentDisjointWarehouseFixedSlots[material.ItemID]; shared {
|
|
entry, err := currentEpicProductionConsumeAccountStack(account, fixedSlot, material.ItemID, material.Count)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return []currentItemListEntry{entry}, true, nil
|
|
}
|
|
if inventory == nil || inventory.Slots == nil {
|
|
return nil, false, errCurrentEpicProductionMaterial
|
|
}
|
|
slots := make([]int16, 0)
|
|
var available uint64
|
|
for key, stack := range inventory.Slots {
|
|
listType, slot, ok := parseSceneInventorySlotKey(key)
|
|
if !ok || listType != dnfrepo.MainInventoryListType || slot < 0 || stack.ItemID != int64(material.ItemID) || stack.Count <= 0 || currentNPCShopItemLocked(stack) {
|
|
continue
|
|
}
|
|
slots = append(slots, slot)
|
|
available += uint64(stack.Count)
|
|
}
|
|
if available < uint64(material.Count) {
|
|
return nil, false, fmt.Errorf("%w: ability material=%d have=%d need=%d", errCurrentEpicProductionMaterial, material.ItemID, available, material.Count)
|
|
}
|
|
sort.Slice(slots, func(i, j int) bool { return slots[i] < slots[j] })
|
|
remaining := material.Count
|
|
updates := make([]currentItemListEntry, 0, len(slots))
|
|
for _, slot := range slots {
|
|
stack := inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)]
|
|
consume := uint32(stack.Count)
|
|
if consume > remaining {
|
|
consume = remaining
|
|
}
|
|
entry, err := currentEpicProductionConsumeStack(inventory, slot, material.ItemID, consume)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
updates = append(updates, entry)
|
|
remaining -= consume
|
|
if remaining == 0 {
|
|
break
|
|
}
|
|
}
|
|
return updates, false, nil
|
|
}
|
|
|
|
func (s *Service) commitCurrentEpicProductionAbility(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
recipe currentEpicProductionAbilityRecipe,
|
|
request currentEpicProductionAbilityRequest,
|
|
targetItemID uint32,
|
|
now time.Time,
|
|
) (currentEpicProductionAbilityResult, error) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 || targetItemID == 0 || recipe.Category != request.Category || recipe.Option != request.Option {
|
|
return currentEpicProductionAbilityResult{}, errCurrentEpicProductionInvalid
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.AccountAssets == nil {
|
|
return currentEpicProductionAbilityResult{}, errCurrentEpicProductionInvalid
|
|
}
|
|
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
if accountID == "" {
|
|
return currentEpicProductionAbilityResult{}, errCurrentEpicProductionInvalid
|
|
}
|
|
var result currentEpicProductionAbilityResult
|
|
err := repositories.AccountAssets.WithinAccountCharacterAssets(
|
|
ctx,
|
|
accountID,
|
|
characterID,
|
|
func(
|
|
accounts dnfrepo.AccountInventoryRepository,
|
|
characters dnfrepo.CharacterRepository,
|
|
inventories dnfrepo.InventoryRepository,
|
|
_ dnfrepo.EquipmentRepository,
|
|
) error {
|
|
character, found, err := characters.Load(ctx, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || character.CharacterID != characterID || strings.TrimSpace(character.AccountID) != accountID {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
inventory, found, err := inventories.Load(ctx, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || inventory.CharacterID != characterID || inventory.Slots == nil {
|
|
return errCurrentEpicProductionMaterial
|
|
}
|
|
account, found, err := accounts.Load(ctx, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
account = dnfrepo.AccountInventoryRecord{AccountID: accountID, Slots: make(map[string]dnfrepo.ItemStack)}
|
|
}
|
|
if account.AccountID != accountID {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
if account.Slots == nil {
|
|
account.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
account = dnfrepo.CloneAccountInventory(account)
|
|
|
|
targetKey := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, request.Slot)
|
|
target, found := inventory.Slots[targetKey]
|
|
if !found || target.ItemID != int64(targetItemID) || target.Count <= 0 || currentNPCShopItemLocked(target) {
|
|
return fmt.Errorf("%w: ability target slot=%d item=%d want=%d", errCurrentEpicProductionInvalid, request.Slot, target.ItemID, targetItemID)
|
|
}
|
|
targetEntry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, request.Slot, target)
|
|
tail := append([]byte(nil), targetEntry.data[currentEpicProductionAbilityTailOffset:currentEpicProductionAbilityTailOffset+currentEpicProductionAbilityTailSize]...)
|
|
if int(request.Category) >= len(tail) {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
if tail[request.Category] == request.Option {
|
|
result = currentEpicProductionAbilityResult{Applied: false}
|
|
return nil
|
|
}
|
|
|
|
updates := make([]currentItemListEntry, 0, len(recipe.Materials)+1)
|
|
accountChanged := false
|
|
for _, material := range recipe.Materials {
|
|
rows, shared, err := currentEpicProductionConsumeAbilityMaterial(&account, &inventory, material)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updates = append(updates, rows...)
|
|
accountChanged = accountChanged || shared
|
|
}
|
|
|
|
tail[request.Category] = request.Option
|
|
if target.Extra == nil {
|
|
target.Extra = make(map[string]string)
|
|
}
|
|
target.Extra["tail_data_72"] = hex.EncodeToString(tail)
|
|
target.Extra[fmt.Sprintf("epic_production_ability_%d", request.Category)] = strconv.Itoa(int(request.Option))
|
|
targetEntry = currentItemListEntryFromStack(dnfrepo.MainInventoryListType, request.Slot, target)
|
|
target.RawEntry = append([]byte(nil), targetEntry.data[:]...)
|
|
inventory.Slots[targetKey] = target
|
|
updates = append(updates, targetEntry)
|
|
|
|
if accountChanged {
|
|
account.UpdatedAt = now.UTC()
|
|
if err := accounts.Save(ctx, account); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
inventory.UpdatedAt = now.UTC()
|
|
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
|
return err
|
|
}
|
|
sortCurrentItemListEntries(updates)
|
|
result = currentEpicProductionAbilityResult{Applied: true, Updates: updates}
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return currentEpicProductionAbilityResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) handleCurrentEpicProductionAbility(session *gameSession, body []byte) error {
|
|
request, err := decodeCurrentEpicProductionAbilityRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-ability-blocked", "body_len", len(body), "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionAbilityOption), currentEpicProductionGenericError)
|
|
}
|
|
s.initialEquipmentMu.Lock()
|
|
archive, archiveErr := s.initialEquipmentArchiveLocked()
|
|
s.initialEquipmentMu.Unlock()
|
|
if archiveErr != nil {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionAbilityOption), currentEpicProductionGenericError)
|
|
}
|
|
catalog, err := parseCurrentEpicProductionAbilityCatalog(archive)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-ability-blocked", "slot", request.Slot, "category", request.Category, "option", request.Option, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionAbilityOption), currentEpicProductionGenericError)
|
|
}
|
|
recipe, err := currentEpicProductionAbilityRecipeFor(catalog, request)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-ability-blocked", "slot", request.Slot, "category", request.Category, "option", request.Option, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionAbilityOption), currentEpicProductionGenericError)
|
|
}
|
|
productionCatalog, err := parseCurrentEpicProductionCatalog(archive)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-ability-blocked", "slot", request.Slot, "category", request.Category, "option", request.Option, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionAbilityOption), currentEpicProductionGenericError)
|
|
}
|
|
items, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-ability-blocked", "slot", request.Slot, "category", request.Category, "option", request.Option, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionAbilityOption), currentEpicProductionGenericError)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
target, err := s.loadCurrentEpicProductionAbilityTarget(ctx, session, request)
|
|
if err == nil {
|
|
jobValue := numericCharacterStat(target.CharacterJob)
|
|
if jobValue < 0 || jobValue > math.MaxUint8 {
|
|
err = fmt.Errorf("%w: ability character job=%q", errCurrentEpicProductionInvalid, target.CharacterJob)
|
|
} else {
|
|
var jobTag string
|
|
jobTag, err = currentRentalCharacterJobTag(archive, byte(jobValue))
|
|
if err == nil {
|
|
_, err = validateCurrentEpicProductionTarget(archive, items, productionCatalog, jobTag, target.ItemID)
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-ability-blocked", "slot", request.Slot, "category", request.Category, "option", request.Option, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionAbilityOption), currentEpicProductionGenericError)
|
|
}
|
|
result, err := s.commitCurrentEpicProductionAbility(ctx, session, recipe, request, target.ItemID, time.Now().UTC())
|
|
if err != nil {
|
|
code := byte(currentEpicProductionGenericError)
|
|
if errors.Is(err, errCurrentEpicProductionMaterial) {
|
|
code = currentEpicProductionMaterialError
|
|
}
|
|
s.logGameEvent(session, "game-upper-epic-production-ability-blocked", "slot", request.Slot, "category", request.Category, "option", request.Option, "result_code", code, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionAbilityOption), code)
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketEpicProductionAbilityOption), nil); err != nil {
|
|
return err
|
|
}
|
|
if len(result.Updates) != 0 {
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, result.Updates), 0); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.logGameEvent(session, "game-upper-epic-production-ability-applied",
|
|
"character_id", session.selectedCharacterID,
|
|
"slot", request.Slot,
|
|
"category", request.Category,
|
|
"option", request.Option,
|
|
"material_types", len(recipe.Materials),
|
|
"state_changed", result.Applied,
|
|
"body_source", "current_exe_op1421_success_u8_true_no_additional_body_raw72_ability_state")
|
|
return nil
|
|
}
|
|
|
|
func decodeCurrentEpicProductionProcessRequest(body []byte) (currentEpicProductionProcessRequest, error) {
|
|
if len(body) < 2 {
|
|
return currentEpicProductionProcessRequest{}, fmt.Errorf("%w: process body length=%d", errCurrentEpicProductionInvalid, len(body))
|
|
}
|
|
count := int(binary.LittleEndian.Uint16(body[:2]))
|
|
if count <= 0 || count > 16 || len(body) != 2+count*currentEpicProductionProcessRowSize {
|
|
return currentEpicProductionProcessRequest{}, fmt.Errorf("%w: process count=%d body length=%d", errCurrentEpicProductionInvalid, count, len(body))
|
|
}
|
|
request := currentEpicProductionProcessRequest{Materials: make([]currentEpicProductionProcessMaterial, 0, count)}
|
|
seenSlots := make(map[int16]struct{}, count)
|
|
for index, offset := 0, 2; index < count; index, offset = index+1, offset+currentEpicProductionProcessRowSize {
|
|
rawSlot := binary.LittleEndian.Uint32(body[offset+4 : offset+8])
|
|
material := currentEpicProductionProcessMaterial{
|
|
ItemID: binary.LittleEndian.Uint32(body[offset : offset+4]),
|
|
Slot: int16(rawSlot),
|
|
}
|
|
if material.ItemID == 0 || rawSlot > math.MaxInt16 || material.Slot < 0 || dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, material.Slot) {
|
|
return currentEpicProductionProcessRequest{}, fmt.Errorf("%w: material index=%d item=%d slot=%d", errCurrentEpicProductionInvalid, index, material.ItemID, material.Slot)
|
|
}
|
|
if _, duplicate := seenSlots[material.Slot]; duplicate {
|
|
return currentEpicProductionProcessRequest{}, fmt.Errorf("%w: duplicate material slot=%d", errCurrentEpicProductionInvalid, material.Slot)
|
|
}
|
|
seenSlots[material.Slot] = struct{}{}
|
|
request.Materials = append(request.Materials, material)
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func decodeCurrentEpicProductionChangeRequest(body []byte) (currentEpicProductionChangeRequest, error) {
|
|
if len(body) < currentEpicProductionChangePrefixSize {
|
|
return currentEpicProductionChangeRequest{}, fmt.Errorf("%w: change body length=%d", errCurrentEpicProductionInvalid, len(body))
|
|
}
|
|
targetItemID := binary.LittleEndian.Uint32(body[:4])
|
|
count := int(binary.LittleEndian.Uint16(body[4:6]))
|
|
if targetItemID == 0 || count <= 0 || count > 16 || len(body) != currentEpicProductionChangePrefixSize+count*currentEpicProductionProcessRowSize {
|
|
return currentEpicProductionChangeRequest{}, fmt.Errorf("%w: change target=%d count=%d body length=%d", errCurrentEpicProductionInvalid, targetItemID, count, len(body))
|
|
}
|
|
request := currentEpicProductionChangeRequest{
|
|
TargetItemID: targetItemID,
|
|
Materials: make([]currentEpicProductionProcessMaterial, 0, count),
|
|
}
|
|
seenSlots := make(map[int16]struct{}, count)
|
|
for index, offset := 0, currentEpicProductionChangePrefixSize; index < count; index, offset = index+1, offset+currentEpicProductionProcessRowSize {
|
|
rawSlot := binary.LittleEndian.Uint32(body[offset+4 : offset+8])
|
|
material := currentEpicProductionProcessMaterial{
|
|
ItemID: binary.LittleEndian.Uint32(body[offset : offset+4]),
|
|
Slot: int16(rawSlot),
|
|
}
|
|
if material.ItemID == 0 || rawSlot > math.MaxInt16 || material.Slot < 0 {
|
|
return currentEpicProductionChangeRequest{}, fmt.Errorf("%w: change material index=%d item=%d slot=%d", errCurrentEpicProductionInvalid, index, material.ItemID, material.Slot)
|
|
}
|
|
if _, duplicate := seenSlots[material.Slot]; duplicate {
|
|
return currentEpicProductionChangeRequest{}, fmt.Errorf("%w: duplicate change material slot=%d", errCurrentEpicProductionInvalid, material.Slot)
|
|
}
|
|
seenSlots[material.Slot] = struct{}{}
|
|
request.Materials = append(request.Materials, material)
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func decodeCurrentEpicProductionCompoundRequest(body []byte) (currentEpicProductionCompoundRequest, error) {
|
|
if len(body) < currentEpicProductionCompoundPrefixSize {
|
|
return currentEpicProductionCompoundRequest{}, fmt.Errorf("%w: compound body length=%d", errCurrentEpicProductionInvalid, len(body))
|
|
}
|
|
catalystItemID := binary.LittleEndian.Uint32(body[:4])
|
|
count := int(binary.LittleEndian.Uint16(body[4:6]))
|
|
if catalystItemID == 0 || count <= 0 || count > 64 ||
|
|
len(body) != currentEpicProductionCompoundPrefixSize+count*currentEpicProductionCompoundRowSize {
|
|
return currentEpicProductionCompoundRequest{}, fmt.Errorf("%w: compound catalyst=%d count=%d body length=%d", errCurrentEpicProductionInvalid, catalystItemID, count, len(body))
|
|
}
|
|
request := currentEpicProductionCompoundRequest{
|
|
CatalystItemID: catalystItemID,
|
|
Materials: make([]currentEpicProductionCompoundMaterial, 0, count),
|
|
}
|
|
seenSlots := make(map[int16]struct{}, count)
|
|
for index, offset := 0, currentEpicProductionCompoundPrefixSize; index < count; index, offset = index+1, offset+currentEpicProductionCompoundRowSize {
|
|
rawSlot := binary.LittleEndian.Uint32(body[offset+4 : offset+8])
|
|
material := currentEpicProductionCompoundMaterial{
|
|
ItemID: binary.LittleEndian.Uint32(body[offset : offset+4]),
|
|
Slot: int16(rawSlot),
|
|
Count: binary.LittleEndian.Uint32(body[offset+8 : offset+12]),
|
|
}
|
|
if material.ItemID == 0 || material.Count == 0 || material.Count > math.MaxInt32 ||
|
|
rawSlot > math.MaxInt16 || material.Slot < 0 {
|
|
return currentEpicProductionCompoundRequest{}, fmt.Errorf("%w: compound material index=%d item=%d slot=%d count=%d", errCurrentEpicProductionInvalid, index, material.ItemID, material.Slot, material.Count)
|
|
}
|
|
if _, duplicate := seenSlots[material.Slot]; duplicate {
|
|
return currentEpicProductionCompoundRequest{}, fmt.Errorf("%w: duplicate compound slot=%d", errCurrentEpicProductionInvalid, material.Slot)
|
|
}
|
|
seenSlots[material.Slot] = struct{}{}
|
|
request.Materials = append(request.Materials, material)
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func currentEpicProductionItemRarity(source initialEquipmentTextSource, definition dungeonDropItemDefinition) (int64, error) {
|
|
if source == nil || definition.ItemID == 0 || definition.PVFPath == "" {
|
|
return 0, errCurrentEpicProductionMaterial
|
|
}
|
|
text, err := source.ReadText(definition.PVFPath)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%w: read item=%d path=%s: %v", errCurrentEpicProductionMaterial, definition.ItemID, definition.PVFPath, err)
|
|
}
|
|
document, err := dnfpvf.Parse(definition.PVFPath, text)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%w: parse item=%d path=%s: %v", errCurrentEpicProductionMaterial, definition.ItemID, definition.PVFPath, err)
|
|
}
|
|
rarity, found := document.Int("rarity")
|
|
if !found || rarity < 0 {
|
|
return 0, fmt.Errorf("%w: item=%d has no valid rarity", errCurrentEpicProductionMaterial, definition.ItemID)
|
|
}
|
|
return rarity, nil
|
|
}
|
|
|
|
func validateCurrentEpicProductionCompoundPlan(
|
|
source initialEquipmentTextSource,
|
|
items *pvfDungeonDropCatalog,
|
|
catalog *currentEpicProductionCatalog,
|
|
request currentEpicProductionCompoundRequest,
|
|
) (currentEpicProductionCompoundPlan, error) {
|
|
if source == nil || items == nil || catalog == nil || len(request.Materials) == 0 {
|
|
return currentEpicProductionCompoundPlan{}, errCurrentEpicProductionMaterial
|
|
}
|
|
recipe, found := catalog.catalysts[request.CatalystItemID]
|
|
if !found || recipe.ItemID == 0 || recipe.GroupKey < 0 || recipe.NeedCount == 0 ||
|
|
recipe.MinMakeCount == 0 || recipe.MaxMakeCount < recipe.MinMakeCount {
|
|
return currentEpicProductionCompoundPlan{}, fmt.Errorf("%w: catalyst=%d has no complete PVF recipe", errCurrentEpicProductionMaterial, request.CatalystItemID)
|
|
}
|
|
outputDefinition, err := items.ResolveItem(request.CatalystItemID)
|
|
if err != nil || outputDefinition.Kind != dungeonDropItemStackable {
|
|
return currentEpicProductionCompoundPlan{}, fmt.Errorf("%w: resolve catalyst=%d: %v", errCurrentEpicProductionMaterial, request.CatalystItemID, err)
|
|
}
|
|
var contribution uint64
|
|
for _, material := range request.Materials {
|
|
if len(recipe.NeedItemIDs) != 0 {
|
|
if _, allowed := recipe.NeedItemIDs[material.ItemID]; !allowed {
|
|
return currentEpicProductionCompoundPlan{}, fmt.Errorf("%w: catalyst=%d rejects item=%d", errCurrentEpicProductionMaterial, request.CatalystItemID, material.ItemID)
|
|
}
|
|
}
|
|
definition, resolveErr := items.ResolveItem(material.ItemID)
|
|
if resolveErr != nil {
|
|
return currentEpicProductionCompoundPlan{}, fmt.Errorf("%w: resolve material=%d: %v", errCurrentEpicProductionMaterial, material.ItemID, resolveErr)
|
|
}
|
|
if !currentEpicProductionMaterialMatchesCatalyst(definition, recipe) {
|
|
return currentEpicProductionCompoundPlan{}, fmt.Errorf("%w: catalyst=%d rejects item=%d", errCurrentEpicProductionMaterial, request.CatalystItemID, material.ItemID)
|
|
}
|
|
unitValue := uint64(1)
|
|
if recipe.IsMaterialPoint {
|
|
rarity, rarityErr := currentEpicProductionItemRarity(source, definition)
|
|
if rarityErr != nil {
|
|
return currentEpicProductionCompoundPlan{}, rarityErr
|
|
}
|
|
points := catalog.materialPointsByRarity[rarity]
|
|
if points == 0 {
|
|
return currentEpicProductionCompoundPlan{}, fmt.Errorf("%w: item=%d rarity=%d has no material point", errCurrentEpicProductionMaterial, material.ItemID, rarity)
|
|
}
|
|
unitValue = uint64(points)
|
|
}
|
|
if uint64(material.Count) > math.MaxUint64/unitValue || contribution > math.MaxUint64-uint64(material.Count)*unitValue {
|
|
return currentEpicProductionCompoundPlan{}, errCurrentEpicProductionMaterial
|
|
}
|
|
contribution += uint64(material.Count) * unitValue
|
|
if fixedSlot, shared := currentDisjointWarehouseFixedSlots[material.ItemID]; shared {
|
|
if material.Slot != fixedSlot {
|
|
return currentEpicProductionCompoundPlan{}, fmt.Errorf("%w: shared material=%d slot=%d want=%d", errCurrentEpicProductionMaterial, material.ItemID, material.Slot, fixedSlot)
|
|
}
|
|
} else if dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, material.Slot) {
|
|
return currentEpicProductionCompoundPlan{}, fmt.Errorf("%w: ordinary material=%d uses shared slot=%d", errCurrentEpicProductionMaterial, material.ItemID, material.Slot)
|
|
}
|
|
}
|
|
if contribution == 0 {
|
|
return currentEpicProductionCompoundPlan{}, errCurrentEpicProductionMaterial
|
|
}
|
|
return currentEpicProductionCompoundPlan{Recipe: recipe, OutputDefinition: outputDefinition, Contribution: contribution}, nil
|
|
}
|
|
|
|
func currentEpicProductionMaterialMatchesCatalyst(definition dungeonDropItemDefinition, recipe currentEpicProductionCatalyst) bool {
|
|
normalize := func(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(strings.Trim(value, "` []")))
|
|
}
|
|
switch normalize(recipe.NeedItemType) {
|
|
case "":
|
|
case "material":
|
|
if definition.Kind != dungeonDropItemStackable || normalize(normalizeDungeonDropStackableType(definition.StackableType)) != "material" {
|
|
return false
|
|
}
|
|
case "equipment":
|
|
if definition.Kind != dungeonDropItemEquipment {
|
|
return false
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
if part := normalize(recipe.NeedItemParts); part != "" && normalize(definition.EquipmentType) != part {
|
|
return false
|
|
}
|
|
if grade := normalize(recipe.NeedItemGrade); grade != "" && currentUpgradeRarityName(int(definition.Rarity)) != grade {
|
|
return false
|
|
}
|
|
if recipe.NeedItemMinLevel != 0 && (definition.MinimumLevel < 0 || uint64(definition.MinimumLevel) < uint64(recipe.NeedItemMinLevel)) {
|
|
return false
|
|
}
|
|
if recipe.NeedItemMaxLevel != 0 && (definition.MinimumLevel < 0 || uint64(definition.MinimumLevel) > uint64(recipe.NeedItemMaxLevel)) {
|
|
return false
|
|
}
|
|
// A recipe without an explicit item list must still carry at least one
|
|
// complete type predicate. This keeps unknown PVF shapes fail-closed while
|
|
// admitting the active advanced catalyst's equipment/weapon/unique/70..85
|
|
// definition.
|
|
return len(recipe.NeedItemIDs) != 0 || normalize(recipe.NeedItemType) != ""
|
|
}
|
|
|
|
func currentEpicProductionCarryStat(group int32) (string, byte) {
|
|
switch group {
|
|
case 1:
|
|
return currentEpicProductionCarryGroup1Stat, 1
|
|
case 2:
|
|
return currentEpicProductionCarryGroup2Stat, 2
|
|
default:
|
|
return "", 0
|
|
}
|
|
}
|
|
|
|
func validateCurrentEpicProductionChangeMaterials(catalog *currentEpicProductionCatalog, request currentEpicProductionChangeRequest) error {
|
|
if catalog == nil || len(catalog.changeMaterials) == 0 || len(request.Materials) != len(catalog.changeMaterials) {
|
|
return errCurrentEpicProductionMaterial
|
|
}
|
|
seenItems := make(map[uint32]struct{}, len(request.Materials))
|
|
for _, material := range request.Materials {
|
|
if _, duplicate := seenItems[material.ItemID]; duplicate {
|
|
return fmt.Errorf("%w: duplicate change material item=%d", errCurrentEpicProductionMaterial, material.ItemID)
|
|
}
|
|
seenItems[material.ItemID] = struct{}{}
|
|
if catalog.changeMaterials[material.ItemID] == 0 {
|
|
return fmt.Errorf("%w: unexpected change material item=%d", errCurrentEpicProductionMaterial, material.ItemID)
|
|
}
|
|
if sharedSlot, shared := currentDisjointWarehouseFixedSlots[material.ItemID]; shared {
|
|
if material.Slot != sharedSlot {
|
|
return fmt.Errorf("%w: shared change material item=%d slot=%d want=%d", errCurrentEpicProductionMaterial, material.ItemID, material.Slot, sharedSlot)
|
|
}
|
|
} else if dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, material.Slot) {
|
|
return fmt.Errorf("%w: ordinary change material item=%d uses shared slot=%d", errCurrentEpicProductionMaterial, material.ItemID, material.Slot)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func currentEpicProductionWeekStart(now time.Time) int64 {
|
|
now = now.UTC()
|
|
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
daysFromMonday := (int(day.Weekday()) + 6) % 7
|
|
return day.AddDate(0, 0, -daysFromMonday).Unix()
|
|
}
|
|
|
|
func validateCurrentEpicProductionMaterials(catalog *currentEpicProductionCatalog, request currentEpicProductionProcessRequest) (uint32, error) {
|
|
if catalog == nil || len(request.Materials) == 0 || catalog.processMaterialNeedCount == 0 {
|
|
return 0, errCurrentEpicProductionMaterial
|
|
}
|
|
groups := make(map[int32]struct{}, len(request.Materials))
|
|
var points uint64
|
|
for _, material := range request.Materials {
|
|
catalyst, ok := catalog.catalysts[material.ItemID]
|
|
if !ok || catalyst.GetPoint == 0 || catalyst.GroupKey < 0 {
|
|
return 0, fmt.Errorf("%w: item=%d", errCurrentEpicProductionMaterial, material.ItemID)
|
|
}
|
|
if _, duplicate := groups[catalyst.GroupKey]; duplicate {
|
|
return 0, fmt.Errorf("%w: duplicate catalyst group=%d", errCurrentEpicProductionMaterial, catalyst.GroupKey)
|
|
}
|
|
groups[catalyst.GroupKey] = struct{}{}
|
|
points += uint64(catalyst.GetPoint)
|
|
}
|
|
if points == 0 || points > math.MaxUint32 {
|
|
return 0, fmt.Errorf("%w: charge points=%d", errCurrentEpicProductionMaterial, points)
|
|
}
|
|
return uint32(points), nil
|
|
}
|
|
|
|
func currentEpicProductionBigSuccess(catalog *currentEpicProductionCatalog) bool {
|
|
if catalog == nil || catalog.bigChanceRate == 0 || catalog.bigChanceMaxRate == 0 {
|
|
return false
|
|
}
|
|
var random [4]byte
|
|
if _, err := cryptorand.Read(random[:]); err != nil {
|
|
return false
|
|
}
|
|
return uint64(binary.LittleEndian.Uint32(random[:]))*uint64(catalog.bigChanceMaxRate) <
|
|
uint64(catalog.bigChanceRate)*(uint64(math.MaxUint32)+1)
|
|
}
|
|
|
|
func currentEpicProductionConsumeStack(inventory *dnfrepo.InventoryRecord, slot int16, itemID uint32, count uint32) (currentItemListEntry, error) {
|
|
if inventory == nil || inventory.Slots == nil || count == 0 {
|
|
return currentItemListEntry{}, errCurrentEpicProductionMaterial
|
|
}
|
|
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
|
|
stack, found := inventory.Slots[key]
|
|
if !found || stack.ItemID != int64(itemID) || stack.Count < int64(count) || currentNPCShopItemLocked(stack) {
|
|
return currentItemListEntry{}, fmt.Errorf("%w: item=%d slot=%d need=%d", errCurrentEpicProductionMaterial, itemID, slot, count)
|
|
}
|
|
stack.Count -= int64(count)
|
|
if stack.Count == 0 {
|
|
delete(inventory.Slots, key)
|
|
var empty currentItemListEntry
|
|
empty.patchCore(slot, 0, 0)
|
|
return empty, nil
|
|
}
|
|
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
|
|
stack.RawEntry = append([]byte(nil), entry.data[:]...)
|
|
inventory.Slots[key] = stack
|
|
return entry, nil
|
|
}
|
|
|
|
func currentEpicProductionConsumeAccountStack(account *dnfrepo.AccountInventoryRecord, slot int16, itemID uint32, count uint32) (currentItemListEntry, error) {
|
|
if account == nil || account.Slots == nil || count == 0 || !dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, slot) {
|
|
return currentItemListEntry{}, errCurrentEpicProductionMaterial
|
|
}
|
|
key := dnfrepo.AccountSharedInventorySlotKey(slot)
|
|
stack, found := account.Slots[key]
|
|
if !found || stack.ItemID != int64(itemID) || stack.Count < int64(count) || currentNPCShopItemLocked(stack) {
|
|
return currentItemListEntry{}, fmt.Errorf("%w: account item=%d slot=%d need=%d", errCurrentEpicProductionMaterial, itemID, slot, count)
|
|
}
|
|
stack.Count -= int64(count)
|
|
if stack.Count == 0 {
|
|
delete(account.Slots, key)
|
|
var empty currentItemListEntry
|
|
empty.patchCore(slot, 0, 0)
|
|
return empty, nil
|
|
}
|
|
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
|
|
stack.RawEntry = append([]byte(nil), entry.data[:]...)
|
|
account.Slots[key] = stack
|
|
return entry, nil
|
|
}
|
|
|
|
func currentEpicProductionFindMaterialSlot(inventory dnfrepo.InventoryRecord, itemID uint32, count uint32) (int16, bool) {
|
|
slots := make([]int, 0)
|
|
for key, stack := range inventory.Slots {
|
|
listType, slot, ok := parseSceneInventorySlotKey(key)
|
|
if ok && listType == dnfrepo.MainInventoryListType && slot >= 0 && stack.ItemID == int64(itemID) && stack.Count >= int64(count) && !currentNPCShopItemLocked(stack) {
|
|
slots = append(slots, int(slot))
|
|
}
|
|
}
|
|
if len(slots) == 0 {
|
|
return 0, false
|
|
}
|
|
sort.Ints(slots)
|
|
return int16(slots[0]), true
|
|
}
|
|
|
|
func (s *Service) commitCurrentEpicProductionChange(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
catalog *currentEpicProductionCatalog,
|
|
jobTag string,
|
|
request currentEpicProductionChangeRequest,
|
|
now time.Time,
|
|
) (currentEpicProductionChangeResult, error) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 || catalog == nil ||
|
|
!catalog.allows(jobTag, request.TargetItemID) {
|
|
return currentEpicProductionChangeResult{}, errCurrentEpicProductionInvalid
|
|
}
|
|
if err := validateCurrentEpicProductionChangeMaterials(catalog, request); err != nil {
|
|
return currentEpicProductionChangeResult{}, err
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.AccountAssets == nil {
|
|
return currentEpicProductionChangeResult{}, errCurrentEpicProductionInvalid
|
|
}
|
|
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
if accountID == "" {
|
|
return currentEpicProductionChangeResult{}, errCurrentEpicProductionInvalid
|
|
}
|
|
var result currentEpicProductionChangeResult
|
|
err := repositories.AccountAssets.WithinAccountCharacterAssets(
|
|
ctx,
|
|
accountID,
|
|
characterID,
|
|
func(
|
|
accounts dnfrepo.AccountInventoryRepository,
|
|
characters dnfrepo.CharacterRepository,
|
|
inventories dnfrepo.InventoryRepository,
|
|
_ dnfrepo.EquipmentRepository,
|
|
) error {
|
|
character, found, loadErr := characters.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found || character.CharacterID != characterID || strings.TrimSpace(character.AccountID) != accountID ||
|
|
character.Stats == nil || character.Stats[currentEpicProductionTargetStat] <= 0 ||
|
|
character.Stats[currentEpicProductionTargetStat] == int64(request.TargetItemID) {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
account, found, loadErr := accounts.Load(ctx, accountID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found || account.AccountID != accountID || account.Slots == nil {
|
|
return errCurrentEpicProductionMaterial
|
|
}
|
|
inventory, found, loadErr := inventories.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found || inventory.CharacterID != characterID || inventory.Slots == nil {
|
|
return fmt.Errorf("%w: change inventory missing character=%s", errCurrentEpicProductionMaterial, characterID)
|
|
}
|
|
|
|
character = dnfrepo.CloneCharacter(character)
|
|
account = dnfrepo.CloneAccountInventory(account)
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
updates := make([]currentItemListEntry, 0, len(request.Materials))
|
|
accountChanged := false
|
|
inventoryChanged := false
|
|
for _, material := range request.Materials {
|
|
need := catalog.changeMaterials[material.ItemID]
|
|
var entry currentItemListEntry
|
|
var consumeErr error
|
|
if dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, material.Slot) {
|
|
entry, consumeErr = currentEpicProductionConsumeAccountStack(&account, material.Slot, material.ItemID, need)
|
|
accountChanged = consumeErr == nil
|
|
} else {
|
|
entry, consumeErr = currentEpicProductionConsumeStack(&inventory, material.Slot, material.ItemID, need)
|
|
inventoryChanged = consumeErr == nil
|
|
}
|
|
if consumeErr != nil {
|
|
return consumeErr
|
|
}
|
|
updates = append(updates, entry)
|
|
}
|
|
|
|
character.Stats[currentEpicProductionTargetStat] = int64(request.TargetItemID)
|
|
character.UpdatedAt = now.UTC()
|
|
if saveErr := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
if accountChanged {
|
|
account.UpdatedAt = now.UTC()
|
|
if saveErr := accounts.Save(ctx, account); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
}
|
|
if inventoryChanged {
|
|
inventory.UpdatedAt = now.UTC()
|
|
if saveErr := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
}
|
|
sortCurrentItemListEntries(updates)
|
|
result = currentEpicProductionChangeResult{TargetItemID: request.TargetItemID, Updates: updates}
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return currentEpicProductionChangeResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func buildCurrentEpicProductionChangeSuccessBody(targetItemID uint32) []byte {
|
|
var body packetWriter
|
|
body.writeUint32(targetItemID)
|
|
return body.bytes()
|
|
}
|
|
|
|
func (s *Service) handleCurrentEpicProductionChange(session *gameSession, body []byte) error {
|
|
if session == nil {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
request, err := decodeCurrentEpicProductionChangeRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-change-blocked", "body_len", len(body), "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), currentEpicProductionGenericError)
|
|
}
|
|
if session.selectedCharacterID == 0 {
|
|
s.logGameEvent(session, "game-upper-epic-production-change-blocked", "target_item_id", request.TargetItemID, "reason", "selected_character_missing")
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), currentEpicProductionGenericError)
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil || repositories.AccountAssets == nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-change-blocked", "target_item_id", request.TargetItemID, "reason", "account_asset_repository_missing")
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), currentEpicProductionGenericError)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
|
character, found, err := repositories.Character.Load(ctx, characterID)
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
if err != nil || !found || accountID == "" || strings.TrimSpace(character.AccountID) != accountID {
|
|
s.logGameEvent(session, "game-upper-epic-production-change-blocked", "target_item_id", request.TargetItemID, "character_id", characterID, "reason", errCurrentEpicProductionInvalid)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), currentEpicProductionGenericError)
|
|
}
|
|
jobValue := numericCharacterStat(character.Job)
|
|
if jobValue < 0 || jobValue > math.MaxUint8 {
|
|
s.logGameEvent(session, "game-upper-epic-production-change-blocked", "target_item_id", request.TargetItemID, "character_id", characterID, "job", character.Job, "reason", "job_out_of_u8_range")
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), currentEpicProductionGenericError)
|
|
}
|
|
|
|
s.initialEquipmentMu.Lock()
|
|
archive, archiveErr := s.initialEquipmentArchiveLocked()
|
|
s.initialEquipmentMu.Unlock()
|
|
if archiveErr != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-change-blocked", "target_item_id", request.TargetItemID, "character_id", characterID, "reason", archiveErr)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), currentEpicProductionGenericError)
|
|
}
|
|
catalog, err := parseCurrentEpicProductionCatalog(archive)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-change-blocked", "target_item_id", request.TargetItemID, "character_id", characterID, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), currentEpicProductionGenericError)
|
|
}
|
|
items, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-change-blocked", "target_item_id", request.TargetItemID, "character_id", characterID, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), currentEpicProductionGenericError)
|
|
}
|
|
jobTag, err := currentRentalCharacterJobTag(archive, byte(jobValue))
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-change-blocked", "target_item_id", request.TargetItemID, "character_id", characterID, "job", jobValue, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), currentEpicProductionGenericError)
|
|
}
|
|
definition, err := validateCurrentEpicProductionTarget(archive, items, catalog, jobTag, request.TargetItemID)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-change-blocked", "target_item_id", request.TargetItemID, "character_id", characterID, "job", jobValue, "job_tag", jobTag, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), currentEpicProductionGenericError)
|
|
}
|
|
|
|
result, err := s.commitCurrentEpicProductionChange(ctx, session, catalog, jobTag, request, time.Now().UTC())
|
|
if err != nil {
|
|
code := byte(currentEpicProductionGenericError)
|
|
if errors.Is(err, errCurrentEpicProductionMaterial) {
|
|
code = 4
|
|
}
|
|
s.logGameEvent(session, "game-upper-epic-production-change-blocked", "target_item_id", request.TargetItemID, "character_id", characterID, "material_count", len(request.Materials), "result_code", code, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), code)
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketEpicProductionChangeItem), buildCurrentEpicProductionChangeSuccessBody(result.TargetItemID)); err != nil {
|
|
return err
|
|
}
|
|
if len(result.Updates) != 0 {
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, result.Updates), 0); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.logGameEvent(session, "game-upper-epic-production-change-applied",
|
|
"target_item_id", result.TargetItemID,
|
|
"character_id", characterID,
|
|
"job", jobValue,
|
|
"job_tag", jobTag,
|
|
"pvf_path", definition.PVFPath,
|
|
"material_count", len(request.Materials),
|
|
"body_source", "current_exe_op1418_success_u8_true_u32_target_item_id")
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) commitCurrentEpicProductionCompound(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
plan currentEpicProductionCompoundPlan,
|
|
request currentEpicProductionCompoundRequest,
|
|
now time.Time,
|
|
) (currentEpicProductionCompoundResult, error) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 || plan.Recipe.ItemID != request.CatalystItemID ||
|
|
plan.Contribution == 0 || len(request.Materials) == 0 {
|
|
return currentEpicProductionCompoundResult{}, errCurrentEpicProductionInvalid
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.AccountAssets == nil {
|
|
return currentEpicProductionCompoundResult{}, errCurrentEpicProductionInvalid
|
|
}
|
|
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
if accountID == "" {
|
|
return currentEpicProductionCompoundResult{}, errCurrentEpicProductionInvalid
|
|
}
|
|
var result currentEpicProductionCompoundResult
|
|
err := repositories.AccountAssets.WithinAccountCharacterAssets(
|
|
ctx,
|
|
accountID,
|
|
characterID,
|
|
func(
|
|
accounts dnfrepo.AccountInventoryRepository,
|
|
characters dnfrepo.CharacterRepository,
|
|
inventories dnfrepo.InventoryRepository,
|
|
_ dnfrepo.EquipmentRepository,
|
|
) error {
|
|
character, found, loadErr := characters.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found || character.CharacterID != characterID || strings.TrimSpace(character.AccountID) != accountID ||
|
|
character.Level < int(plan.Recipe.LevelLimit) {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
inventory, found, loadErr := inventories.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found || inventory.CharacterID != characterID {
|
|
return fmt.Errorf("%w: compound inventory missing character=%s", errCurrentEpicProductionMaterial, characterID)
|
|
}
|
|
if inventory.Slots == nil {
|
|
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
account, accountFound, loadErr := accounts.Load(ctx, accountID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !accountFound {
|
|
account = dnfrepo.AccountInventoryRecord{AccountID: accountID, Slots: make(map[string]dnfrepo.ItemStack)}
|
|
}
|
|
if account.AccountID != accountID {
|
|
return fmt.Errorf("%w: compound account mismatch got=%s want=%s", errCurrentEpicProductionMaterial, account.AccountID, accountID)
|
|
}
|
|
if account.Slots == nil {
|
|
account.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
|
|
character = dnfrepo.CloneCharacter(character)
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
account = dnfrepo.CloneAccountInventory(account)
|
|
if character.Stats == nil {
|
|
character.Stats = make(map[string]int64)
|
|
}
|
|
updates := make(map[int16]currentItemListEntry, len(request.Materials)+2)
|
|
accountChanged := false
|
|
for _, material := range request.Materials {
|
|
if plan.Recipe.IsSeal {
|
|
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, material.Slot)
|
|
stack, found := inventory.Slots[key]
|
|
if !found || currentItemSealFlag(stack) == 0 {
|
|
return fmt.Errorf("%w: catalyst=%d requires sealed item=%d slot=%d", errCurrentEpicProductionMaterial, request.CatalystItemID, material.ItemID, material.Slot)
|
|
}
|
|
}
|
|
var entry currentItemListEntry
|
|
var consumeErr error
|
|
if dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, material.Slot) {
|
|
entry, consumeErr = currentEpicProductionConsumeAccountStack(&account, material.Slot, material.ItemID, material.Count)
|
|
if consumeErr == nil {
|
|
accountChanged = true
|
|
}
|
|
} else {
|
|
entry, consumeErr = currentEpicProductionConsumeStack(&inventory, material.Slot, material.ItemID, material.Count)
|
|
}
|
|
if consumeErr != nil {
|
|
return consumeErr
|
|
}
|
|
updates[material.Slot] = entry
|
|
}
|
|
|
|
carryStat, groupSelector := currentEpicProductionCarryStat(plan.Recipe.GroupKey)
|
|
carry := uint64(0)
|
|
if carryStat != "" {
|
|
stored := character.Stats[carryStat]
|
|
if stored < 0 {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
carry = uint64(stored)
|
|
}
|
|
if carry > math.MaxUint64-plan.Contribution {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
total := carry + plan.Contribution
|
|
units := total / uint64(plan.Recipe.NeedCount)
|
|
if units == 0 || units > math.MaxUint32 {
|
|
return fmt.Errorf("%w: compound points=%d need=%d", errCurrentEpicProductionMaterial, total, plan.Recipe.NeedCount)
|
|
}
|
|
output64 := units * uint64(plan.Recipe.MinMakeCount)
|
|
// The client preview derives the whole batch result from the
|
|
// accumulated points divided by [need item count], then applies the
|
|
// per-unit make multiplier. [max make item count] is not a cap on
|
|
// the whole request.
|
|
if output64 == 0 || output64 > math.MaxUint32 {
|
|
return fmt.Errorf("%w: compound output=%d", errCurrentEpicProductionMaterial, output64)
|
|
}
|
|
carryValue := uint32(0)
|
|
if carryStat != "" {
|
|
carryValue = uint32(total % uint64(plan.Recipe.NeedCount))
|
|
character.Stats[carryStat] = int64(carryValue)
|
|
character.UpdatedAt = now.UTC()
|
|
}
|
|
|
|
mustNeedItemID, mustNeedItemCount := uint32(0), uint32(0)
|
|
// [must need item] is a per-unit cost, not a per-request one. The
|
|
// catalyst recipe reads `[must need item] 10099775 1` next to
|
|
// `[min make item count] 1`, so a batch that makes 1000 catalysts
|
|
// owes 1000 legendary souls. Charging it once let a single request
|
|
// mint the whole batch for one soul, so it has to be scaled by the
|
|
// unit count and therefore charged after the units are known.
|
|
if plan.Recipe.MustNeedItemID != 0 && plan.Recipe.MustNeedItemCount != 0 {
|
|
if units > math.MaxUint32/uint64(plan.Recipe.MustNeedItemCount) {
|
|
return fmt.Errorf("%w: compound must item=%d units=%d overflows", errCurrentEpicProductionMaterial, plan.Recipe.MustNeedItemID, units)
|
|
}
|
|
mustNeedTotal := uint32(units * uint64(plan.Recipe.MustNeedItemCount))
|
|
if fixedSlot, shared := currentDisjointWarehouseFixedSlots[plan.Recipe.MustNeedItemID]; shared {
|
|
entry, consumeErr := currentEpicProductionConsumeAccountStack(&account, fixedSlot, plan.Recipe.MustNeedItemID, mustNeedTotal)
|
|
if consumeErr != nil {
|
|
return consumeErr
|
|
}
|
|
accountChanged = true
|
|
updates[fixedSlot] = entry
|
|
} else {
|
|
slot, found := currentEpicProductionFindMaterialSlot(inventory, plan.Recipe.MustNeedItemID, mustNeedTotal)
|
|
if !found {
|
|
return fmt.Errorf("%w: compound must item=%d need=%d", errCurrentEpicProductionMaterial, plan.Recipe.MustNeedItemID, mustNeedTotal)
|
|
}
|
|
entry, consumeErr := currentEpicProductionConsumeStack(&inventory, slot, plan.Recipe.MustNeedItemID, mustNeedTotal)
|
|
if consumeErr != nil {
|
|
return consumeErr
|
|
}
|
|
updates[slot] = entry
|
|
}
|
|
mustNeedItemID, mustNeedItemCount = plan.Recipe.MustNeedItemID, mustNeedTotal
|
|
}
|
|
|
|
// 产出同样要过账号共享落点:无色/灵魂类产物必须先落进 354..365
|
|
// (统一入口见 account_shared_grant.go)。这条路过去直接
|
|
// grantCurrentCeraShopProduct,产物会留在角色背包。
|
|
shared := grantCurrentAccountSharedFirst(&account, plan.OutputDefinition, uint32(output64), "epic_production_compound")
|
|
if shared.Changed {
|
|
accountChanged = true
|
|
accountKey := dnfrepo.AccountSharedInventorySlotKey(shared.Slot)
|
|
accountStack, accountExists := account.Slots[accountKey]
|
|
if !accountExists || accountStack.ItemID != int64(plan.OutputDefinition.ItemID) || accountStack.Count <= 0 {
|
|
return fmt.Errorf("%w: compound shared grant slot=%d missing=%t item=%d count=%d", errCurrentEpicProductionMaterial, shared.Slot, !accountExists, accountStack.ItemID, accountStack.Count)
|
|
}
|
|
// 账号共享槽也是 list-0 线地址:ACK 行按同一个槽号上报。
|
|
updates[shared.Slot] = currentItemListEntryFromStack(dnfrepo.MainInventoryListType, shared.Slot, accountStack)
|
|
}
|
|
if shared.Remainder > 0 {
|
|
grantedSlots, grantErr := grantCurrentCeraShopProduct(&inventory, plan.OutputDefinition, shared.Remainder, s.currentInventoryPlacementMainSlotCount(ctx, &inventory, ""))
|
|
if grantErr != nil {
|
|
return grantErr
|
|
}
|
|
for _, rawSlot := range grantedSlots {
|
|
slot := int16(rawSlot)
|
|
stack, exists := inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)]
|
|
if !exists || stack.ItemID != int64(request.CatalystItemID) || stack.Count <= 0 {
|
|
return fmt.Errorf("%w: compound grant slot=%d missing=%t item=%d count=%d", errCurrentEpicProductionMaterial, slot, !exists, stack.ItemID, stack.Count)
|
|
}
|
|
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
|
|
stack.RawEntry = append([]byte(nil), entry.data[:]...)
|
|
inventory.Slots[currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)] = stack
|
|
updates[slot] = entry
|
|
}
|
|
}
|
|
|
|
if carryStat != "" {
|
|
if saveErr := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
}
|
|
if accountChanged {
|
|
account.UpdatedAt = now.UTC()
|
|
if saveErr := accounts.Save(ctx, account); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
}
|
|
inventory.UpdatedAt = now.UTC()
|
|
if saveErr := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
rows := make([]currentItemListEntry, 0, len(updates))
|
|
for _, entry := range updates {
|
|
rows = append(rows, entry)
|
|
}
|
|
sortCurrentItemListEntries(rows)
|
|
result = currentEpicProductionCompoundResult{
|
|
CatalystItemID: request.CatalystItemID,
|
|
OutputCount: uint32(output64),
|
|
CarryValue: carryValue,
|
|
GroupSelector: groupSelector,
|
|
Updates: rows,
|
|
MustNeedItemID: mustNeedItemID,
|
|
MustNeedItemCount: mustNeedItemCount,
|
|
}
|
|
return nil
|
|
},
|
|
)
|
|
if err != nil {
|
|
return currentEpicProductionCompoundResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func buildCurrentEpicProductionCompoundSuccessBody(result currentEpicProductionCompoundResult) []byte {
|
|
var body packetWriter
|
|
body.writeUint32(result.OutputCount)
|
|
body.writeByte(result.GroupSelector)
|
|
body.writeUint32(result.CarryValue)
|
|
return body.bytes()
|
|
}
|
|
|
|
func (s *Service) handleCurrentEpicProductionCompound(session *gameSession, body []byte) error {
|
|
request, err := decodeCurrentEpicProductionCompoundRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-compound-blocked", "body_len", len(body), "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionMaterialCompound), currentEpicProductionGenericError)
|
|
}
|
|
s.initialEquipmentMu.Lock()
|
|
archive, archiveErr := s.initialEquipmentArchiveLocked()
|
|
s.initialEquipmentMu.Unlock()
|
|
if archiveErr != nil {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionMaterialCompound), currentEpicProductionGenericError)
|
|
}
|
|
catalog, err := parseCurrentEpicProductionCatalog(archive)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-compound-blocked", "catalyst_item_id", request.CatalystItemID, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionMaterialCompound), currentEpicProductionGenericError)
|
|
}
|
|
items, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionMaterialCompound), currentEpicProductionGenericError)
|
|
}
|
|
plan, err := validateCurrentEpicProductionCompoundPlan(archive, items, catalog, request)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-compound-blocked", "catalyst_item_id", request.CatalystItemID, "material_count", len(request.Materials), "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionMaterialCompound), 4)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
result, err := s.commitCurrentEpicProductionCompound(ctx, session, plan, request, time.Now().UTC())
|
|
if err != nil {
|
|
code := byte(currentEpicProductionGenericError)
|
|
if errors.Is(err, errCurrentEpicProductionMaterial) || errors.Is(err, errCurrentCeraShopProductUnavailable) || errors.Is(err, errDungeonPickupInventoryFull) {
|
|
code = 4
|
|
}
|
|
s.logGameEvent(session, "game-upper-epic-production-compound-blocked", "catalyst_item_id", request.CatalystItemID, "material_count", len(request.Materials), "result_code", code, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionMaterialCompound), code)
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketEpicProductionMaterialCompound), buildCurrentEpicProductionCompoundSuccessBody(result)); err != nil {
|
|
return err
|
|
}
|
|
if len(result.Updates) != 0 {
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, result.Updates), 0); err != nil {
|
|
return err
|
|
}
|
|
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(result.Updates))
|
|
for _, entry := range result.Updates {
|
|
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{
|
|
ListType: dnfrepo.MainInventoryListType,
|
|
SlotIndex: int16(binary.LittleEndian.Uint16(entry.data[0:2])),
|
|
})
|
|
}
|
|
if err := s.sendSelectedIncrementalItemSlotRefreshes(session, "epic_production_compound", refreshes); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.sendSelectedCurrentEpicProductionInfo(session, "epic_production_compound_success"); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-upper-epic-production-compound-applied",
|
|
"character_id", session.selectedCharacterID,
|
|
"catalyst_item_id", result.CatalystItemID,
|
|
"material_count", len(request.Materials),
|
|
"output_count", result.OutputCount,
|
|
"must_need_item_id", result.MustNeedItemID,
|
|
"must_need_item_count", result.MustNeedItemCount,
|
|
"group_selector", result.GroupSelector,
|
|
"carry_value", result.CarryValue,
|
|
"body_source", "current_exe_op1420_success_u8_true_u32_output_count_u8_group_u32_carry")
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) commitCurrentEpicProductionProcess(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
catalog *currentEpicProductionCatalog,
|
|
request currentEpicProductionProcessRequest,
|
|
now time.Time,
|
|
) (currentEpicProductionProcessResult, error) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 || catalog == nil {
|
|
return currentEpicProductionProcessResult{}, errCurrentEpicProductionInvalid
|
|
}
|
|
basePoints, err := validateCurrentEpicProductionMaterials(catalog, request)
|
|
if err != nil {
|
|
return currentEpicProductionProcessResult{}, err
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.CharacterAssets == nil {
|
|
return currentEpicProductionProcessResult{}, errCurrentEpicProductionInvalid
|
|
}
|
|
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
weekStart := currentEpicProductionWeekStart(now)
|
|
var result currentEpicProductionProcessResult
|
|
err = repositories.CharacterAssets.WithinCharacterAssets(ctx, characterID, func(
|
|
characters dnfrepo.CharacterRepository,
|
|
inventories dnfrepo.InventoryRepository,
|
|
_ dnfrepo.EquipmentRepository,
|
|
) error {
|
|
character, found, loadErr := characters.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found || character.CharacterID != characterID || accountID == "" || strings.TrimSpace(character.AccountID) != accountID {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
if character.Level < int(catalog.levelLimit) || character.Stats == nil || character.Stats[currentEpicProductionTargetStat] <= 0 {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
inventory, found, loadErr := inventories.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found || inventory.CharacterID != characterID || inventory.Slots == nil {
|
|
return errCurrentEpicProductionMaterial
|
|
}
|
|
character = dnfrepo.CloneCharacter(character)
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
weeklyCount := character.Stats[currentEpicProductionWeeklyCountStat]
|
|
if character.Stats[currentEpicProductionWeekStartStat] != weekStart {
|
|
weeklyCount = 0
|
|
}
|
|
if weeklyCount < 0 || weeklyCount >= int64(catalog.weeklyLimit) {
|
|
return errCurrentEpicProductionWeeklyLimit
|
|
}
|
|
|
|
updates := make([]currentItemListEntry, 0, len(request.Materials)+1)
|
|
for _, material := range request.Materials {
|
|
entry, consumeErr := currentEpicProductionConsumeStack(&inventory, material.Slot, material.ItemID, catalog.processMaterialNeedCount)
|
|
if consumeErr != nil {
|
|
return consumeErr
|
|
}
|
|
updates = append(updates, entry)
|
|
}
|
|
requiredSlot, found := currentEpicProductionFindMaterialSlot(inventory, catalog.requiredMaterialItemID, catalog.requiredMaterialItemCount)
|
|
if !found {
|
|
return fmt.Errorf("%w: required item=%d need=%d", errCurrentEpicProductionMaterial, catalog.requiredMaterialItemID, catalog.requiredMaterialItemCount)
|
|
}
|
|
requiredEntry, consumeErr := currentEpicProductionConsumeStack(&inventory, requiredSlot, catalog.requiredMaterialItemID, catalog.requiredMaterialItemCount)
|
|
if consumeErr != nil {
|
|
return consumeErr
|
|
}
|
|
updates = append(updates, requiredEntry)
|
|
|
|
bigSuccess := currentEpicProductionBigSuccess(catalog)
|
|
chargeGain := uint64(basePoints)
|
|
if bigSuccess {
|
|
chargeGain *= uint64(catalog.bigChanceMultiply)
|
|
}
|
|
if chargeGain > math.MaxInt64 {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
charge := character.Stats[currentEpicProductionChargeStat]
|
|
if charge < 0 {
|
|
return errCurrentEpicProductionInvalid
|
|
}
|
|
charge += int64(chargeGain)
|
|
if charge > int64(catalog.maxChargePoint) {
|
|
charge = int64(catalog.maxChargePoint)
|
|
}
|
|
character.Stats[currentEpicProductionChargeStat] = charge
|
|
character.Stats[currentEpicProductionWeeklyCountStat] = weeklyCount + 1
|
|
character.Stats[currentEpicProductionWeekStartStat] = weekStart
|
|
character.UpdatedAt = now.UTC()
|
|
inventory.UpdatedAt = now.UTC()
|
|
if saveErr := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
if saveErr := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
sortCurrentItemListEntries(updates)
|
|
result = currentEpicProductionProcessResult{
|
|
ChargePoint: uint32(charge),
|
|
WeeklyCount: uint32(weeklyCount + 1),
|
|
BigSuccess: bigSuccess,
|
|
Updates: updates,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return currentEpicProductionProcessResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func buildCurrentEpicProductionProcessSuccessBody(result currentEpicProductionProcessResult) []byte {
|
|
var body packetWriter
|
|
body.writeUint32(result.ChargePoint)
|
|
if result.BigSuccess {
|
|
body.writeByte(1)
|
|
} else {
|
|
body.writeByte(0)
|
|
}
|
|
body.writeByte(0)
|
|
return body.bytes()
|
|
}
|
|
|
|
func (s *Service) handleCurrentEpicProductionProcess(session *gameSession, body []byte) error {
|
|
request, err := decodeCurrentEpicProductionProcessRequest(body)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-process-blocked", "body_len", len(body), "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionProcess), currentEpicProductionGenericError)
|
|
}
|
|
s.initialEquipmentMu.Lock()
|
|
archive, archiveErr := s.initialEquipmentArchiveLocked()
|
|
s.initialEquipmentMu.Unlock()
|
|
if archiveErr != nil {
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionProcess), currentEpicProductionGenericError)
|
|
}
|
|
catalog, err := parseCurrentEpicProductionCatalog(archive)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-process-blocked", "body_len", len(body), "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionProcess), currentEpicProductionGenericError)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
result, err := s.commitCurrentEpicProductionProcess(ctx, session, catalog, request, time.Now().UTC())
|
|
if err != nil {
|
|
code := byte(currentEpicProductionGenericError)
|
|
if errors.Is(err, errCurrentEpicProductionWeeklyLimit) {
|
|
code = currentEpicProductionWeeklyLimitError
|
|
}
|
|
s.logGameEvent(session, "game-upper-epic-production-process-blocked", "body_len", len(body), "material_count", len(request.Materials), "result_code", code, "reason", err)
|
|
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketEpicProductionProcess), code)
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketEpicProductionProcess), buildCurrentEpicProductionProcessSuccessBody(result)); err != nil {
|
|
return err
|
|
}
|
|
if len(result.Updates) != 0 {
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, result.Updates), 0); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.sendSelectedCurrentEpicProductionInfo(session, "epic_production_process_success"); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-upper-epic-production-process-applied",
|
|
"character_id", session.selectedCharacterID,
|
|
"material_count", len(request.Materials),
|
|
"charge_point", result.ChargePoint,
|
|
"weekly_count", result.WeeklyCount,
|
|
"big_success", result.BigSuccess,
|
|
"body_source", "current_exe_op1419_success_u8_true_u32_charge_u8_state_u8_ui_flag")
|
|
return nil
|
|
}
|
|
|
|
func buildCurrentEpicProductionInfoBody(characterID uint16, targetItemID, chargePoint, remainingWeeklyCount, carryGroup1, carryGroup2 uint32) []byte {
|
|
var body packetWriter
|
|
body.writeUint32(uint32(characterID))
|
|
state := make([]byte, currentEpicProductionInfoStateSize)
|
|
binary.LittleEndian.PutUint32(state[0:4], remainingWeeklyCount)
|
|
binary.LittleEndian.PutUint32(state[4:8], targetItemID)
|
|
binary.LittleEndian.PutUint32(state[8:12], chargePoint)
|
|
binary.LittleEndian.PutUint32(state[16:20], carryGroup1)
|
|
binary.LittleEndian.PutUint32(state[20:24], carryGroup2)
|
|
body.writeBytes(state)
|
|
return body.bytes()
|
|
}
|
|
|
|
func currentEpicProductionRemainingWeeklyCount(usedCount, storedWeekStart int64, weeklyLimit uint32, now time.Time) uint32 {
|
|
if weeklyLimit == 0 {
|
|
return 0
|
|
}
|
|
if storedWeekStart != currentEpicProductionWeekStart(now.UTC()) || usedCount < 0 {
|
|
usedCount = 0
|
|
}
|
|
if usedCount >= int64(weeklyLimit) {
|
|
return 0
|
|
}
|
|
return weeklyLimit - uint32(usedCount)
|
|
}
|
|
|
|
func (s *Service) sendSelectedCurrentEpicProductionInfo(session *gameSession, source string) error {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return nil
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil {
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
|
|
character, found, err := repositories.Character.Load(ctx, characterID)
|
|
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
|
if err != nil || !found || accountID == "" || strings.TrimSpace(character.AccountID) != accountID {
|
|
return err
|
|
}
|
|
target := character.Stats[currentEpicProductionTargetStat]
|
|
if target < 0 || target > math.MaxUint32 {
|
|
target = 0
|
|
}
|
|
charge := character.Stats[currentEpicProductionChargeStat]
|
|
if target == 0 || charge < 0 {
|
|
charge = 0
|
|
}
|
|
if charge > math.MaxUint32 {
|
|
charge = math.MaxUint32
|
|
}
|
|
s.initialEquipmentMu.Lock()
|
|
archive, archiveErr := s.initialEquipmentArchiveLocked()
|
|
s.initialEquipmentMu.Unlock()
|
|
if archiveErr != nil {
|
|
// PVF 只用来取周限次数(catalog.weeklyLimit)。它不可用时跳过这一段投影,
|
|
// 不能让它把整个城镇进入流程带崩 —— 这条路径上的收集箱与自定义能力投影都是
|
|
// 这么处理的(各自的 -deferred / -skipped 日志)。
|
|
//
|
|
// 旧树在 target <= 0 时就提前 return nil、根本走不到这里;本树改成 target = 0
|
|
// 后继续往下发零值投影,于是这里成了城镇进入路径上唯一的致命 PVF 依赖,
|
|
// 14 个 dnfbridge 测试就是栽在它上面。生产环境 PVF 恒在(预检强制校验),
|
|
// 所以这个降级分支只在缺资产的环境里生效。
|
|
s.logGameEvent(session, "game-upper-epic-production-info-skipped",
|
|
"source", source,
|
|
"character_id", session.selectedCharacterID,
|
|
"reason", archiveErr)
|
|
return nil
|
|
}
|
|
catalog, catalogErr := parseCurrentEpicProductionCatalog(archive)
|
|
if catalogErr != nil {
|
|
s.logGameEvent(session, "game-upper-epic-production-info-skipped",
|
|
"source", source,
|
|
"character_id", session.selectedCharacterID,
|
|
"reason", catalogErr)
|
|
return nil
|
|
}
|
|
usedWeeklyCount := character.Stats[currentEpicProductionWeeklyCountStat]
|
|
storedWeekStart := character.Stats[currentEpicProductionWeekStartStat]
|
|
now := time.Now().UTC()
|
|
remainingWeeklyCount := currentEpicProductionRemainingWeeklyCount(usedWeeklyCount, storedWeekStart, catalog.weeklyLimit, now)
|
|
if storedWeekStart != currentEpicProductionWeekStart(now) || usedWeeklyCount < 0 {
|
|
usedWeeklyCount = 0
|
|
}
|
|
carryGroup1 := character.Stats[currentEpicProductionCarryGroup1Stat]
|
|
if carryGroup1 < 0 || carryGroup1 > math.MaxUint32 {
|
|
carryGroup1 = 0
|
|
}
|
|
carryGroup2 := character.Stats[currentEpicProductionCarryGroup2Stat]
|
|
if carryGroup2 < 0 || carryGroup2 > math.MaxUint32 {
|
|
carryGroup2 = 0
|
|
}
|
|
body := buildCurrentEpicProductionInfoBody(session.selectedCharacterID, uint32(target), uint32(charge), remainingWeeklyCount, uint32(carryGroup1), uint32(carryGroup2))
|
|
s.logGameEvent(session, "game-upper-epic-production-info-send",
|
|
"source", source,
|
|
"character_id", session.selectedCharacterID,
|
|
"msg_id", currentEpicProductionInfoMsgID,
|
|
"target_item_id", target,
|
|
"charge_point", charge,
|
|
"weekly_limit", catalog.weeklyLimit,
|
|
"weekly_used_count", usedWeeklyCount,
|
|
"weekly_remaining_count", remainingWeeklyCount,
|
|
"carry_group_1", carryGroup1,
|
|
"carry_group_2", carryGroup2,
|
|
"body_len", len(body),
|
|
"body_source", "current_exe_HandleEpicProductionInfoNotice_u32_prefix_plus_28_byte_state")
|
|
return s.sendGameUpperRawClass(session, currentEpicProductionInfoMsgID, body, 0)
|
|
}
|