Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

403 lines
14 KiB
Go

package inventory
import (
"context"
"encoding/binary"
"fmt"
"math/rand"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentForgingSuccessBase = 10000
// Current NoPack maps raw+0x57 to the encoded equipment-state field at
// +0x78, which AppendWeaponForgingTooltipLines uses as its level value.
currentForgingRawOffset = 0x57
)
// ForgingResult is the durable outcome of one current-client op438 attempt.
// Success means the request was accepted and material was consumed; a chance
// failure keeps the old level while still returning Success=true.
type ForgingResult struct {
CharacterID string
Success bool
UpgradeSucceeded bool
TargetListType byte
TargetSlotIndex int16
TargetItemTemplateID int32
MaterialSlotIndex int16
MaterialItemID int64
MaterialCost int64
MaterialRemainingCount int64
OldLevel byte
NewLevel byte
ReinforceLevel byte
IndependentAttack int64
SuccessWeight int
TargetUpdatedStack dnfrepo.ItemStack
MaterialUpdatedStack dnfrepo.ItemStack
Changed bool
}
// Forge validates and commits a PVF-backed weapon-forging attempt. Target and
// material changes share one character-item transaction for both list 0 and 3.
func (o *Owner) Forge(ctx context.Context, cmd Command, resolver alignedcmd.ForgingResolver) (ForgingResult, error) {
base := ForgingResult{
TargetListType: cmd.TargetListType,
TargetSlotIndex: cmd.TargetSlotIndex,
TargetItemTemplateID: cmd.TargetItemTemplateID,
MaterialSlotIndex: cmd.MaterialSlotIndex,
}
if o == nil || o.repo == nil || o.equipment == nil {
return base, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return base, ErrCharacterRequired
}
if resolver == nil {
return base, fmt.Errorf("forging runtime-PVF resolver required")
}
if cmd.TargetListType != listTypeMain && cmd.TargetListType != listTypeEquipment {
return base, nil
}
if cmd.TargetSlotIndex < 0 || cmd.MaterialSlotIndex < 0 || dnfrepo.IsAccountSharedInventorySlot(listTypeMain, cmd.MaterialSlotIndex) {
return base, nil
}
if cmd.TargetListType == listTypeMain && cmd.TargetSlotIndex == cmd.MaterialSlotIndex {
return base, nil
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
base.CharacterID = characterID
if !o.inItemTransaction {
var result ForgingResult
err := o.withinInventoryTransaction(ctx, characterID, func(txOwner *Owner) error {
var innerErr error
result, innerErr = txOwner.Forge(ctx, cmd, resolver)
return innerErr
})
if err != nil {
return base, err
}
if result.CharacterID == "" {
result = base
}
return result, nil
}
inventoryRecord, found, err := o.repo.Load(ctx, characterID)
if err != nil {
return base, err
}
if !found {
return base, ErrInventoryNotFound
}
inventoryRecord = dnfrepo.CloneInventory(inventoryRecord)
if inventoryRecord.Slots == nil {
inventoryRecord.Slots = make(map[string]dnfrepo.ItemStack)
}
var target dnfrepo.ItemStack
var equipmentRecord dnfrepo.EquipmentRecord
var equipmentEntry dnfrepo.EquipmentEntry
var targetKey string
switch cmd.TargetListType {
case listTypeMain:
targetKey = slotKey(listTypeMain, cmd.TargetSlotIndex)
stack, ok := inventoryRecord.Slots[targetKey]
if !ok || stack.ItemID != int64(cmd.TargetItemTemplateID) || stack.Count <= 0 {
return base, nil
}
target = cloneStack(stack)
case listTypeEquipment:
loaded, ok, loadErr := o.equipment.Load(ctx, characterID)
if loadErr != nil {
return base, loadErr
}
if !ok {
return base, nil
}
equipmentRecord = dnfrepo.CloneEquipment(loaded)
targetKey = strconv.FormatInt(int64(cmd.TargetSlotIndex), 10)
entry, ok := equipmentRecord.Entries[targetKey]
if !ok || entry.ItemID != int64(cmd.TargetItemTemplateID) {
return base, nil
}
equipmentEntry = entry
target = forgingStackFromEquipmentEntry(entry)
}
if isEquipmentLocked(target) {
return base, nil
}
if !equipmentStackCountIsSingle(target) {
// 一份锻造材料只能作用一件装备:历史堆叠行(装备 Count>1)一律拒绝。
return base, nil
}
oldLevel := forgingLevelOf(target)
base.OldLevel = oldLevel
base.NewLevel = oldLevel
base.ReinforceLevel = upgradeLevelOf(target)
materialKey := slotKey(listTypeMain, cmd.MaterialSlotIndex)
material, ok := inventoryRecord.Slots[materialKey]
if !ok || material.ItemID <= 0 || material.Count <= 0 ||
(!material.ExpireAt.IsZero() && !material.ExpireAt.After(time.Now())) {
return base, nil
}
// The request-scoped forging resolver proves the material template against
// the active PVF below. Do not require the durable inventory row to already
// carry item_kind/pvf_path metadata: older, mailed and package-granted
// one-time forging devices can be valid stackables while retaining a legacy
// minimal row. Requiring pvfStackContract here rejected those tools before
// their authoritative [portable genuine damage upgrade upgrede] document
// was ever consulted.
resolution, err := resolver(material.ItemID, int64(cmd.TargetItemTemplateID), oldLevel)
if err != nil {
return base, err
}
switch {
case resolution.TicketRandom:
// [enchant random] + [action type] `[random upgrade separate]` family
// (e.g. +6锻造券): the er_* target gates and the weighted candidate
// roll are shared with the op50 ticket scene, applied here to the
// forging level instead of the reinforcement level.
if !validForgingRandomTicketResolution(resolution) {
return base, nil
}
if !enchantRandomRestrictionAllows(resolution.RandomTicket, resolution.TargetEquipmentType, resolution.TargetRarity, target) {
return base, nil
}
entry, ok := o.selectEnchantRandomEntry(resolution.RandomTicket.Entries)
if !ok || entry.TargetLevel <= 0 || entry.TargetLevel > int64(resolution.MaxLevel) {
return base, nil
}
resolution.TargetLevel = byte(entry.TargetLevel)
resolution.SuccessWeight = forgingWeightFromEnchantRandom(entry.Weight)
resolution.IndependentAttack = resolution.RandomIndependentAttacks[entry.TargetLevel]
case strings.TrimSpace(resolution.TicketMode) == "":
if !validForgingResolution(resolution, oldLevel) || !forgingDurabilityOK(target, resolution.TargetDurability) {
return base, nil
}
default:
if !validForgingTicketResolution(resolution, oldLevel) {
return base, nil
}
}
if material.ItemID != resolution.MaterialItemID || material.Count < resolution.MaterialCount {
return base, nil
}
upgradeSucceeded := rand.Intn(currentForgingSuccessBase) < resolution.SuccessWeight
newLevel := oldLevel
if upgradeSucceeded {
newLevel = resolution.TargetLevel
setForgingLevel(&target, newLevel)
switch cmd.TargetListType {
case listTypeMain:
inventoryRecord.Slots[targetKey] = target
case listTypeEquipment:
equipmentEntry.RawEntry = append([]byte(nil), target.RawEntry...)
equipmentEntry.Extra = cloneStringMapForForging(target.Extra)
equipmentRecord.Entries[targetKey] = equipmentEntry
equipmentRecord.UpdatedAt = time.Now()
if err := dnfrepo.SaveEquipmentFields(ctx, o.equipment, equipmentRecord, dnfrepo.EquipmentFieldEntries); err != nil {
return base, err
}
}
}
material = cloneStack(material)
material.Count -= resolution.MaterialCount
if material.Count <= 0 {
material = dnfrepo.ItemStack{}
delete(inventoryRecord.Slots, materialKey)
} else {
updateStackRawAmount(&material)
inventoryRecord.Slots[materialKey] = material
}
inventoryRecord.UpdatedAt = time.Now()
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, inventoryRecord, dnfrepo.InventoryFieldSlots); err != nil {
return base, err
}
if cmd.TargetListType == listTypeEquipment {
if upgradeSucceeded {
target = forgingStackFromEquipmentEntry(equipmentRecord.Entries[targetKey])
} else {
target = forgingStackFromEquipmentEntry(equipmentEntry)
}
}
base.Success = true
base.UpgradeSucceeded = upgradeSucceeded
base.MaterialItemID = resolution.MaterialItemID
base.MaterialCost = resolution.MaterialCount
base.MaterialRemainingCount = material.Count
base.NewLevel = newLevel
if upgradeSucceeded {
base.IndependentAttack = resolution.IndependentAttack
}
base.SuccessWeight = resolution.SuccessWeight
base.TargetUpdatedStack = target
base.MaterialUpdatedStack = material
base.Changed = true
return base, nil
}
func validForgingResolution(resolution alignedcmd.ForgingResolution, oldLevel byte) bool {
if !strings.EqualFold(strings.TrimSpace(resolution.TargetKind), "equipment") {
return false
}
equipmentType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(resolution.TargetEquipmentType, "`", "")))
if equipmentType != "[weapon]" && equipmentType != "weapon" {
return false
}
if resolution.MaxLevel == 0 || oldLevel >= resolution.MaxLevel || resolution.TargetLevel != oldLevel+1 || resolution.TargetLevel > resolution.MaxLevel {
return false
}
return resolution.IndependentAttack >= 0 && resolution.SuccessWeight >= 0 && resolution.SuccessWeight <= currentForgingSuccessBase && resolution.MaterialItemID > 0 && resolution.MaterialCount > 0
}
func validForgingTicketResolution(resolution alignedcmd.ForgingResolution, oldLevel byte) bool {
if !strings.EqualFold(strings.TrimSpace(resolution.TargetKind), "equipment") {
return false
}
equipmentType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(resolution.TargetEquipmentType, "`", "")))
if equipmentType != "[weapon]" && equipmentType != "weapon" {
return false
}
mode := strings.ToLower(strings.TrimSpace(resolution.TicketMode))
if mode != "fixed" && mode != "additional" {
return false
}
if resolution.MaxLevel == 0 || resolution.TargetLevel == 0 || resolution.TargetLevel > resolution.MaxLevel {
return false
}
if mode == "additional" && resolution.TargetLevel <= oldLevel {
return false
}
return resolution.IndependentAttack >= 0 &&
resolution.SuccessWeight >= 0 && resolution.SuccessWeight <= currentForgingSuccessBase &&
resolution.MaterialItemID > 0 && resolution.MaterialCount == 1
}
// validForgingRandomTicketResolution accepts the [enchant random] forging
// coupon family before the candidate roll. The concrete level is not known
// yet, so only the target class, the table bound and the one-coupon material
// contract can be checked here.
func validForgingRandomTicketResolution(resolution alignedcmd.ForgingResolution) bool {
if !strings.EqualFold(strings.TrimSpace(resolution.TargetKind), "equipment") {
return false
}
equipmentType := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(resolution.TargetEquipmentType, "`", "")))
if equipmentType != "[weapon]" && equipmentType != "weapon" {
return false
}
return resolution.MaxLevel > 0 && len(resolution.RandomTicket.Entries) > 0 &&
resolution.MaterialItemID > 0 && resolution.MaterialCount == 1
}
// forgingWeightFromEnchantRandom rescales an er_enchant weight (out of 100000)
// to the native op438 probability base of 10000.
func forgingWeightFromEnchantRandom(weight int64) int {
scaled := weight / (enchantRandomWeightBase / currentForgingSuccessBase)
if scaled < 0 {
return 0
}
if scaled > currentForgingSuccessBase {
return currentForgingSuccessBase
}
return int(scaled)
}
func forgingDurabilityOK(stack dnfrepo.ItemStack, expected uint16) bool {
if value := firstExtraInt(stack.Extra, -1, "durability"); value >= 0 {
return value == int64(expected)
}
if len(stack.RawEntry) == currentItemListEntrySize {
return binary.LittleEndian.Uint16(stack.RawEntry[0x0B:0x0D]) == expected
}
return expected == 0
}
func forgingLevelOf(stack dnfrepo.ItemStack) byte {
if raw := strings.TrimSpace(stack.Extra["forging_level"]); raw != "" {
if value, err := strconv.ParseUint(raw, 0, 8); err == nil {
return byte(value)
}
}
if len(stack.RawEntry) == currentItemListEntrySize {
return stack.RawEntry[currentForgingRawOffset]
}
return 0
}
func setForgingLevel(stack *dnfrepo.ItemStack, level byte) {
if stack == nil {
return
}
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
stack.Extra = cloneStringMapForForging(stack.Extra)
stack.Extra["forging_level"] = strconv.Itoa(int(level))
if len(stack.RawEntry) == currentItemListEntrySize {
stack.RawEntry[currentForgingRawOffset] = level
// buildCommonItemListUpdateBody owns these opaque vectors through
// Extra. Snapshot the current raw bytes so an op14 forging refresh
// cannot clear adjacent current-client state.
stack.Extra["raw_data_65"] = fmt.Sprintf("%x", stack.RawEntry[0x65:0x6E])
if _, ok := stack.Extra["tail_data_72"]; !ok {
stack.Extra["tail_data_72"] = fmt.Sprintf("%x", stack.RawEntry[0x72:0x77])
}
}
}
func forgingStackFromEquipmentEntry(entry dnfrepo.EquipmentEntry) dnfrepo.ItemStack {
raw := append([]byte(nil), entry.RawEntry...)
extra := cloneStringMapForForging(entry.Extra)
if len(raw) == currentItemListEntrySize {
setForgingProjectionFallback(extra, "amount_or_count", uint64(binary.LittleEndian.Uint32(raw[0x06:0x0A])))
setForgingProjectionFallback(extra, "ext_data0", uint64(raw[0x0A]))
setForgingProjectionFallback(extra, "durability", uint64(binary.LittleEndian.Uint16(raw[0x0B:0x0D])))
setForgingProjectionFallback(extra, "seal_flag", uint64(raw[0x0D]))
setForgingProjectionFallback(extra, "value_a", uint64(binary.LittleEndian.Uint32(raw[0x0E:0x12])))
setForgingProjectionFallback(extra, "byte_12", uint64(raw[0x12]))
setForgingProjectionFallback(extra, "byte_13", uint64(raw[0x13]))
setForgingProjectionFallback(extra, "marker_16", uint64(binary.LittleEndian.Uint16(raw[0x14:0x16])))
setForgingProjectionFallback(extra, "byte_57", uint64(raw[0x57]))
setForgingProjectionFallback(extra, "byte_58", uint64(raw[0x58]))
setForgingProjectionFallback(extra, "byte_59", uint64(raw[0x59]))
if _, ok := extra["raw_data_65"]; !ok {
extra["raw_data_65"] = fmt.Sprintf("%x", raw[0x65:0x6E])
}
if _, ok := extra["tail_data_72"]; !ok {
extra["tail_data_72"] = fmt.Sprintf("%x", raw[0x72:0x77])
}
}
return dnfrepo.ItemStack{
ItemID: entry.ItemID,
Count: 1,
Bind: entry.Bind,
ExpireAt: entry.ExpireAt,
RawEntry: raw,
Extra: extra,
}
}
func setForgingProjectionFallback(extra map[string]string, key string, value uint64) {
if _, ok := extra[key]; !ok {
extra[key] = strconv.FormatUint(value, 10)
}
}
func cloneStringMapForForging(source map[string]string) map[string]string {
out := make(map[string]string, len(source)+1)
for key, value := range source {
out[key] = value
}
return out
}