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

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

530 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package inventory
import (
"context"
cryptorand "crypto/rand"
"errors"
"fmt"
"math"
"math/big"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrRandomOptionResolverRequired = errors.New("random-option mutation requires a runtime-PVF resolver")
const (
randomOptionCountOffset = 0x47
randomOptionTypeOffset = 0x48
randomOptionValue1Offset = 0x4B
randomOptionValue2Offset = 0x4E
randomOptionStateOffset = 0x51
randomOptionChangedIndexOffset = 0x52
randomOptionCandidateOffset = 0x53
randomOptionMaximumCount = 3
)
type RandomOptionValue struct {
Type byte
Value1 byte
Value2 byte
}
type RandomOptionFailure string
const (
RandomOptionFailureNone RandomOptionFailure = ""
RandomOptionFailureInsufficientGold RandomOptionFailure = "insufficient_gold"
// op455 (seal-grade adjustment) outcomes; each maps to the current
// NoPack error code the client's op455 result handler can render.
RandomOptionFailureNoSealOption RandomOptionFailure = "no_seal_option"
RandomOptionFailureGradeFixed RandomOptionFailure = "grade_fixed"
RandomOptionFailureGradesUnavailable RandomOptionFailure = "grades_unavailable"
RandomOptionFailureMaterialMissing RandomOptionFailure = "material_missing"
RandomOptionFailureMaterialLocked RandomOptionFailure = "material_locked"
)
type RandomOptionMutationResult struct {
CharacterID string
Success bool
Failure RandomOptionFailure
Mode string
TargetSlotIndex int16
TargetItemID int64
OptionIndex byte
Options []RandomOptionValue
GoldCost int64
UpdatedGold int64
UpdatedStack dnfrepo.ItemStack
Changed bool
// PercentileFallback is true when the runtime PVF shipped no
// [different weight] window for this rarity and the value roll had to fall
// back to the whole 0..100 domain. It is surfaced in the bridge log so a
// server that keeps unsealing into odd grades is diagnosable.
PercentileFallback bool
}
// 【封印属性数值 = 品级百分位,不是绝对数值】
//
// 90 客户端 X 服同源的 df_game_r(1) 里,random_option 命名空间把这件事写死了:
//
// - 常量 MAX_OPTION_VALUE = 100(random_option::MAX_OPTION_VALUE,1 字节)。
// - RandomOption 结构:+0..+8 三个槽各 {type, value1, value2};+0xA..+0xC 是本次
// "变换候选",+0xD 是被变换槽号。RandomOption::set_first_value / set_second_value
// 写的两个字节是一对【同值】的百分位(reset_option 里还会 `if (p > 100) p = 100`)。
// - 展示值由客户端自己按 PVF 区间算:_get_option_value(min,max) 做的是
// min + 量化((max-min) * 百分位),即两个字节是"在这条属性区间里的位置"。
//
// 所以服务端写词条值时必须掷百分位;把 PVF 的 [level] 区间端点 (min,max) 直接塞进
// 这两个字节是错的 —— 那样等于把 min 当成了"min% 的位置",品级会常年落在最低档
// (72 号属性 10 级是 (5,39),写进去就是 5% → C 级)。
//
// 窗口来自 etc/randomoption/randomizedoptionoverall2.etc 的 [different weight]:
// 行格式 "稀有度 槽位 下限% 上限%",当前 PVF 是
//
// 2 1 0 25 2 2 10 30 2 3 30 55 (稀有:第 1 条 0~25%,第 2 条 10~30%,第 3 条 30~55%)
// 3 1 10 35 3 2 20 40 3 3 40 65 (史诗:10~35% / 20~40% / 40~65%)
//
// 与 [postfix grade modification] 的品级区间处在同一个 0~100 百分位空间(稀有第 3
// 条上限 55 对 S 级上限 56),也就是说解封掷完之后,客户端自己就能算出 C/B/A/S。
//
// 若 PVF 没有这张表(自定义 PVF 删段),退化成在整个 0..100 里均匀掷,并把
// RandomOptionMutationResult.PercentileFallback 置位,日志里能看到。
func sealPercentileForSlot(windows []alignedcmd.RandomOptionPercentWindow, slot int64) (byte, error) {
minPct, maxPct := int64(0), currentRandomOptionResetMaxPercent
for _, window := range windows {
if window.Slot != slot || window.MaxPct < window.MinPct {
continue
}
minPct, maxPct = window.MinPct, window.MaxPct
break
}
if maxPct > currentRandomOptionResetMaxPercent {
maxPct = currentRandomOptionResetMaxPercent
}
if minPct < 0 {
minPct = 0
}
if maxPct < minPct {
maxPct = minPct
}
span := maxPct - minPct
offset := int64(0)
if span > 0 {
var err error
offset, err = secureRandomOptionInt(span + 1)
if err != nil {
return 0, err
}
}
return byte(minPct + offset), nil
}
func (o *Owner) UnsealRandomOption(ctx context.Context, cmd Command, resolver alignedcmd.RandomOptionResolver) (RandomOptionMutationResult, error) {
return o.mutateRandomOption(ctx, cmd, resolver)
}
func (o *Owner) ChangeRandomOption(ctx context.Context, cmd Command, resolver alignedcmd.RandomOptionResolver) (RandomOptionMutationResult, error) {
return o.mutateRandomOption(ctx, cmd, resolver)
}
func (o *Owner) mutateRandomOption(ctx context.Context, cmd Command, resolver alignedcmd.RandomOptionResolver) (RandomOptionMutationResult, error) {
base := RandomOptionMutationResult{
Mode: cmd.Operation,
TargetSlotIndex: cmd.TargetSlotIndex,
OptionIndex: cmd.RandomOptionIndex,
}
if o == nil || o.repo == nil || o.characters == nil {
return RandomOptionMutationResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return RandomOptionMutationResult{}, ErrCharacterRequired
}
if resolver == nil {
return RandomOptionMutationResult{}, ErrRandomOptionResolverRequired
}
if cmd.Operation != "unseal_random_option" && cmd.Operation != "change_random_option" {
return RandomOptionMutationResult{}, fmt.Errorf("unsupported random-option operation %q", cmd.Operation)
}
if cmd.TargetSlotIndex < 0 {
return base, nil
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
base.CharacterID = characterID
if !o.inItemTransaction {
var result RandomOptionMutationResult
err := o.withinCharacterAssetTransaction(ctx, characterID, func(txOwner *Owner) error {
var err error
result, err = txOwner.mutateRandomOption(ctx, cmd, resolver)
return err
})
if err != nil {
return RandomOptionMutationResult{}, err
}
if result.CharacterID == "" {
result = base
}
return result, nil
}
record, found, err := o.repo.Load(ctx, characterID)
if err != nil {
return RandomOptionMutationResult{}, err
}
if !found {
return RandomOptionMutationResult{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
record.CharacterID = characterID
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
result := base
targetKey := slotKey(listTypeMain, cmd.TargetSlotIndex)
target, found := record.Slots[targetKey]
if !found || target.Count <= 0 || target.ItemID <= 0 || isEquipmentLocked(target) {
return result, nil
}
result.TargetItemID = target.ItemID
resolution, err := resolver(target.ItemID)
if err != nil {
return RandomOptionMutationResult{}, err
}
eligible := resolution.Eligible &&
strings.EqualFold(strings.TrimSpace(resolution.TargetKind), "equipment") &&
strings.TrimSpace(resolution.TargetPVFPath) != "" &&
resolution.TargetMinimumLevel >= 0 &&
(resolution.TargetRarity == 2 || resolution.TargetRarity == 3)
if cmd.Operation == "unseal_random_option" && !eligible {
// 客户端右键「解封」走的就是这条 op401 —— 它是通用的撕膜命令,只有稀有 / 神器才
// 顺带开随机属性。称号、传说、史诗这类没有随机属性的贴膜物品以前在这里直接被拒,
// 客户端本地已经把膜撕了,服务端却一直存着 seal_flag=1:重登、进称号簿都又变回
// "封装"。没有随机属性可开就只撕膜,不收金币。
// (移植上游 fc3d6d2,2026-09-22)
if !stackSealFilmSet(target) {
return result, nil
}
target = cloneStack(target)
clearStackSealFilm(&target)
record.Slots[targetKey] = target
record.UpdatedAt = time.Now()
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, dnfrepo.InventoryFieldSlots); err != nil {
return RandomOptionMutationResult{}, err
}
result.Success = true
result.Changed = true
result.Mode = "unseal_seal_film"
result.UpdatedStack = cloneStack(target)
return result, nil
}
if !eligible {
return result, nil
}
raw := currentRawEntryForStack(cmd.TargetSlotIndex, target)
if len(raw) != currentItemListEntrySize {
return RandomOptionMutationResult{}, fmt.Errorf("random-option target row length %d, want %d", len(raw), currentItemListEntrySize)
}
current, valid := randomOptionsFromRaw(raw)
var next []RandomOptionValue
var goldCost int64
switch cmd.Operation {
case "unseal_random_option":
if valid && len(current) > 0 {
return result, nil
}
quantity, err := rollRandomOptionQuantity(resolution.QuantityWeights)
if err != nil {
return RandomOptionMutationResult{}, err
}
if quantity == 0 || int(quantity) > len(resolution.InitialGroups) || quantity > randomOptionMaximumCount {
return result, nil
}
next, err = rollRandomOptionSet(resolution.InitialGroups[:quantity], nil)
if err != nil {
return RandomOptionMutationResult{}, err
}
// 解封:每个词条的数值要按"槽位窗口"掷品级百分位(见文件末尾的
// sealPercentileForSlot 注释),槽位从 1 开始。
for index := range next {
percent, percentErr := sealPercentileForSlot(resolution.InitialSlotWindows, int64(index)+1)
if percentErr != nil {
return RandomOptionMutationResult{}, percentErr
}
next[index].Value1 = percent
next[index].Value2 = percent
}
result.PercentileFallback = len(resolution.InitialSlotWindows) == 0
goldCost = resolution.BreakSealGoldCost
case "change_random_option":
if !valid || len(current) == 0 || int(cmd.RandomOptionIndex) >= len(current) || int(cmd.RandomOptionIndex) >= len(resolution.ModifiedGroups) {
return result, nil
}
used := make(map[byte]struct{}, len(current)-1)
for index, option := range current {
if index != int(cmd.RandomOptionIndex) {
used[option.Type] = struct{}{}
}
}
candidate, err := rollRandomOptionCandidate(resolution.ModifiedGroups[cmd.RandomOptionIndex], used)
if err != nil {
return RandomOptionMutationResult{}, err
}
next = append([]RandomOptionValue(nil), current...)
next[cmd.RandomOptionIndex] = candidate
// 变更是"换类型 + 重掷数值":当前 PVF 没有 [changed postfix],所以沿用
// [different weight] 的同槽位窗口(见文件末尾 sealPercentileForSlot)。
changeWindows := resolution.ChangeSlotWindows
if len(changeWindows) == 0 {
changeWindows = resolution.InitialSlotWindows
}
percent, err := sealPercentileForSlot(changeWindows, int64(cmd.RandomOptionIndex)+1)
if err != nil {
return RandomOptionMutationResult{}, err
}
next[cmd.RandomOptionIndex].Value1 = percent
next[cmd.RandomOptionIndex].Value2 = percent
result.PercentileFallback = len(changeWindows) == 0
goldCost = resolution.ModificationGoldCost
}
if goldCost < 0 {
return RandomOptionMutationResult{}, fmt.Errorf("random-option negative PVF gold cost %d", goldCost)
}
character, found, err := o.characters.Load(ctx, characterID)
if err != nil {
return RandomOptionMutationResult{}, err
}
if !found {
return RandomOptionMutationResult{}, fmt.Errorf("%w: character=%s", ErrWalletTxnRequired, characterID)
}
if character.Stats == nil {
character.Stats = make(map[string]int64, 1)
}
result.UpdatedGold = character.Stats["gold"]
result.GoldCost = goldCost
if result.UpdatedGold < goldCost {
result.Failure = RandomOptionFailureInsufficientGold
return result, nil
}
target = cloneStack(target)
setRandomOptions(&target, cmd.TargetSlotIndex, next)
record.Slots[targetKey] = target
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64, 1)
}
character.Stats["gold"] -= goldCost
character.UpdatedAt = time.Now()
if err := dnfrepo.SaveCharacterFields(ctx, o.characters, character, dnfrepo.CharacterFieldStats); err != nil {
return RandomOptionMutationResult{}, err
}
record.UpdatedAt = time.Now()
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, dnfrepo.InventoryFieldSlots); err != nil {
return RandomOptionMutationResult{}, err
}
result.Success = true
result.Options = append([]RandomOptionValue(nil), next...)
result.UpdatedGold = character.Stats["gold"]
result.UpdatedStack = cloneStack(target)
result.Changed = true
return result, nil
}
// stackSealFilmSet 判断物品当前是否贴着膜(封装):持久 seal 别名或 0x77 行 +0x0D 非 0。
// 只看 seal 语义的键,不把 bind(绑定)当封装。
//
// 移植上游 fc3d6d2(2026-09-22)。
func stackSealFilmSet(stack dnfrepo.ItemStack) bool {
if firstExtraInt(stack.Extra, 0, "seal_flag", "seal") != 0 {
return true
}
return len(stack.RawEntry) > 0x0D && stack.RawEntry[0x0D] != 0
}
// clearStackSealFilm 撕膜:与 equip.removeSealOnFirstEquipmentUse 同一组键与同一个原始行字节。
//
// 我们 equip 侧(equip/owner.go:1215)用的是同一组键(seal_flag / seal /
// equipment_update_seal_flag / update_seal_flag)与同一个 RawEntry[0x0D],所以两处的口径一致。
func clearStackSealFilm(stack *dnfrepo.ItemStack) {
if stack == nil {
return
}
if len(stack.RawEntry) > 0x0D {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
stack.RawEntry[0x0D] = 0
}
if stack.Extra == nil {
stack.Extra = make(map[string]string, 4)
}
for _, key := range []string{"seal_flag", "seal", "equipment_update_seal_flag", "update_seal_flag"} {
delete(stack.Extra, key)
}
if hexRaw := strings.TrimSpace(stack.Extra["raw_entry_hex"]); len(hexRaw) >= 0x1C {
// 0x0D 字节在十六进制串里是第 26、27 个字符;有些路径只读这份副本。
stack.Extra["raw_entry_hex"] = hexRaw[:0x1A] + "00" + hexRaw[0x1C:]
}
stack.Extra["seal_removed_by_unseal"] = "1"
}
func randomOptionsFromRaw(raw []byte) ([]RandomOptionValue, bool) {
if len(raw) != currentItemListEntrySize {
return nil, false
}
count := int(raw[randomOptionCountOffset])
if count < 0 || count > randomOptionMaximumCount {
return nil, false
}
options := make([]RandomOptionValue, 0, count)
seen := make(map[byte]struct{}, count)
for index := 0; index < count; index++ {
option := RandomOptionValue{
Type: raw[randomOptionTypeOffset+index],
Value1: raw[randomOptionValue1Offset+index],
Value2: raw[randomOptionValue2Offset+index],
}
if option.Type == 0 {
return nil, false
}
if _, duplicate := seen[option.Type]; duplicate {
return nil, false
}
seen[option.Type] = struct{}{}
options = append(options, option)
}
return options, true
}
func setRandomOptions(stack *dnfrepo.ItemStack, slot int16, options []RandomOptionValue) {
if stack == nil || len(options) == 0 || len(options) > randomOptionMaximumCount {
return
}
raw := currentRawEntryForStack(slot, *stack)
for index := randomOptionCountOffset; index <= randomOptionCandidateOffset+3; index++ {
raw[index] = 0
}
raw[randomOptionCountOffset] = byte(len(options))
for index, option := range options {
raw[randomOptionTypeOffset+index] = option.Type
raw[randomOptionValue1Offset+index] = option.Value1
raw[randomOptionValue2Offset+index] = option.Value2
}
raw[randomOptionStateOffset] = 0
raw[randomOptionChangedIndexOffset] = math.MaxUint8
stack.RawEntry = raw
if stack.Extra == nil {
stack.Extra = make(map[string]string, 16)
}
stack.Extra["random_option_count"] = strconv.Itoa(len(options))
stack.Extra["random_option_state"] = "0"
stack.Extra["random_option_changed_index"] = "255"
for index := 0; index < randomOptionMaximumCount; index++ {
prefix := fmt.Sprintf("random_option_%d_", index+1)
if index < len(options) {
stack.Extra[prefix+"type"] = strconv.Itoa(int(options[index].Type))
stack.Extra[prefix+"value1"] = strconv.Itoa(int(options[index].Value1))
stack.Extra[prefix+"value2"] = strconv.Itoa(int(options[index].Value2))
} else {
stack.Extra[prefix+"type"] = "0"
stack.Extra[prefix+"value1"] = "0"
stack.Extra[prefix+"value2"] = "0"
}
}
}
func rollRandomOptionQuantity(entries []alignedcmd.RandomOptionWeightedQuantity) (byte, error) {
total := int64(0)
for _, entry := range entries {
if entry.Quantity == 0 || entry.Quantity > randomOptionMaximumCount || entry.Weight <= 0 {
continue
}
if entry.Weight > math.MaxInt64-total {
return 0, fmt.Errorf("random-option quantity weights overflow")
}
total += entry.Weight
}
roll, err := secureRandomOptionInt(total)
if err != nil {
return 0, err
}
for _, entry := range entries {
if entry.Quantity == 0 || entry.Quantity > randomOptionMaximumCount || entry.Weight <= 0 {
continue
}
if roll < entry.Weight {
return entry.Quantity, nil
}
roll -= entry.Weight
}
return 0, fmt.Errorf("random-option quantity selection exhausted")
}
func rollRandomOptionSet(groups [][]alignedcmd.RandomOptionCandidate, used map[byte]struct{}) ([]RandomOptionValue, error) {
if used == nil {
used = make(map[byte]struct{}, len(groups))
}
options := make([]RandomOptionValue, 0, len(groups))
for _, group := range groups {
option, err := rollRandomOptionCandidate(group, used)
if err != nil {
return nil, err
}
used[option.Type] = struct{}{}
options = append(options, option)
}
return options, nil
}
func rollRandomOptionCandidate(entries []alignedcmd.RandomOptionCandidate, excluded map[byte]struct{}) (RandomOptionValue, error) {
total := int64(0)
for _, entry := range entries {
if entry.Type == 0 || entry.Weight <= 0 {
continue
}
if _, skip := excluded[entry.Type]; skip {
continue
}
if entry.Weight > math.MaxInt64-total {
return RandomOptionValue{}, fmt.Errorf("random-option candidate weights overflow")
}
total += entry.Weight
}
roll, err := secureRandomOptionInt(total)
if err != nil {
return RandomOptionValue{}, err
}
for _, entry := range entries {
if entry.Type == 0 || entry.Weight <= 0 {
continue
}
if _, skip := excluded[entry.Type]; skip {
continue
}
if roll < entry.Weight {
return RandomOptionValue{Type: entry.Type, Value1: entry.Value1, Value2: entry.Value2}, nil
}
roll -= entry.Weight
}
return RandomOptionValue{}, fmt.Errorf("random-option candidate selection exhausted")
}
func secureRandomOptionInt(upper int64) (int64, error) {
if upper <= 0 {
return 0, fmt.Errorf("random-option random upper bound %d invalid", upper)
}
value, err := cryptorand.Int(cryptorand.Reader, big.NewInt(upper))
if err != nil {
return 0, fmt.Errorf("random-option random: %w", err)
}
return value.Int64(), nil
}