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

521 lines
18 KiB
Go

package vending
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
receiptPrefix = "vending_machine_use_receipt_"
receiptVersion int64 = 2
receiptRewardRawEntryMax = 0x77
receiptRewardRawChunkSize = 8
)
var (
ErrOwnerUnavailable = errors.New("vending-machine owner is unavailable")
ErrAccountRequired = errors.New("vending-machine account id is required")
ErrCharacterRequired = errors.New("vending-machine character id is required")
ErrCommandInvalid = errors.New("vending-machine command is invalid")
ErrCharacterNotFound = errors.New("vending-machine character is not found")
ErrAccountMismatch = errors.New("vending-machine character account mismatch")
ErrInventoryNotFound = errors.New("vending-machine inventory is not found")
ErrMaterialMissing = errors.New("vending-machine material is missing")
ErrInventoryFull = errors.New("vending-machine reward inventory is full")
ErrProjectionInvalid = errors.New("vending-machine reward projection is invalid")
ErrReceiptInvalid = errors.New("vending-machine receipt is invalid")
)
// Projector grants the already selected reward into the cloned candidate
// inventory. It returns every main-inventory slot whose reward stack changed.
// Persistence and the material debit remain owned by Use's transaction.
type Projector func(candidate *dnfrepo.InventoryRecord, rewardItemID, rewardCount uint32) ([]int16, error)
type Command struct {
AccountID string
CharacterID string
CompletionKey string
MaterialSlot int16
MaterialItemID uint32
MaterialCount uint32
RewardItemID uint32
RewardCount uint32
UpdatedAt time.Time
Project Projector
}
type Result struct {
MaterialSlot int16
MaterialItemID uint32
MaterialCount uint32
MaterialRemaining uint32
MaterialRemoved bool
RewardItemID uint32
RewardCount uint32
TouchedSlots []int16
RewardSnapshots []RewardSnapshot
Replayed bool
}
// RewardSnapshot is the final committed state of one reward slot. Keeping the
// row source in the transaction result prevents the bridge from reloading the
// slot after commit and accidentally projecting a later inventory mutation in
// the current op215 response.
type RewardSnapshot struct {
Slot int16
Stack dnfrepo.ItemStack
}
// Owner is the repository-neutral atomic boundary for one vending-machine use.
// Packet parsing, PVF selection, random choice and current-client projection
// stay outside this package.
type Owner struct {
assets dnfrepo.CharacterAssetUnitOfWork
}
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
if repositories.CharacterAssets == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{assets: repositories.CharacterAssets}, nil
}
// Use validates the selected character and exact client-provided material slot,
// debits the PVF-owned material and grants one preselected reward in the same
// CharacterAssets transaction. A non-empty CompletionKey stores a compact
// receipt in Character.Stats so an indeterminate commit can be replayed without
// consuming or granting twice.
func (o *Owner) Use(ctx context.Context, command Command) (Result, error) {
if o == nil || o.assets == nil {
return Result{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(command.AccountID)
characterID := strings.TrimSpace(command.CharacterID)
if accountID == "" {
return Result{}, ErrAccountRequired
}
if characterID == "" {
return Result{}, ErrCharacterRequired
}
if command.MaterialSlot < 0 ||
dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, command.MaterialSlot) ||
command.MaterialItemID == 0 || command.MaterialCount == 0 ||
command.RewardItemID == 0 || command.RewardCount == 0 || command.Project == nil {
return Result{}, ErrCommandInvalid
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return Result{}, err
}
now := command.UpdatedAt.UTC()
if now.IsZero() {
now = time.Now().UTC()
}
completionKey := strings.TrimSpace(command.CompletionKey)
durableReceipt := completionKey != ""
var receiptHash [sha256.Size]byte
if durableReceipt {
receiptHash = sha256.Sum256([]byte(completionKey))
}
var result Result
err := dnfrepo.WithinCharacterAssetsScoped(ctx, o.assets, characterID, func(
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
if characters == nil || inventories == nil {
return ErrOwnerUnavailable
}
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(character.CharacterID) != characterID {
return fmt.Errorf("%w: character=%s", ErrCharacterNotFound, characterID)
}
if strings.TrimSpace(character.AccountID) != accountID {
return fmt.Errorf("%w: account=%s character=%s", ErrAccountMismatch, accountID, characterID)
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
if durableReceipt && receiptMatches(character.Stats, receiptHash) {
replayed, err := receiptResult(character.Stats, command)
if err != nil {
return err
}
replayed.Replayed = true
result = replayed
return nil
}
inventory, found, err := inventories.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(inventory.CharacterID) != characterID || inventory.Slots == nil {
return fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
}
inventory = dnfrepo.CloneInventory(inventory)
materialKey := mainInventorySlotKey(command.MaterialSlot)
material, found := inventory.Slots[materialKey]
if !found || material.ItemID != int64(command.MaterialItemID) ||
material.Count < int64(command.MaterialCount) {
return fmt.Errorf(
"%w: slot=%d item=%d need=%d",
ErrMaterialMissing,
command.MaterialSlot,
command.MaterialItemID,
command.MaterialCount,
)
}
remaining := material.Count - int64(command.MaterialCount)
if remaining < 0 || remaining > math.MaxUint32 {
return ErrCommandInvalid
}
if remaining == 0 {
delete(inventory.Slots, materialKey)
} else {
material.Count = remaining
material = stackWithCount(material, command.MaterialSlot, remaining)
inventory.Slots[materialKey] = material
}
beforeReward, err := mainInventoryItemCount(inventory, command.RewardItemID)
if err != nil {
return err
}
touched, err := command.Project(&inventory, command.RewardItemID, command.RewardCount)
if err != nil {
return err
}
touched, err = validateTouchedRewardSlots(inventory, command.RewardItemID, touched)
if err != nil {
return err
}
rewardSnapshots, err := snapshotTouchedRewardSlots(inventory, command.RewardItemID, touched)
if err != nil {
return err
}
afterReward, err := mainInventoryItemCount(inventory, command.RewardItemID)
if err != nil || afterReward < beforeReward || afterReward-beforeReward != uint64(command.RewardCount) {
return fmt.Errorf(
"%w: item=%d before=%d after=%d want_delta=%d",
ErrProjectionInvalid,
command.RewardItemID,
beforeReward,
afterReward,
command.RewardCount,
)
}
result = Result{
MaterialSlot: command.MaterialSlot,
MaterialItemID: command.MaterialItemID,
MaterialCount: command.MaterialCount,
MaterialRemaining: uint32(remaining),
MaterialRemoved: remaining == 0,
RewardItemID: command.RewardItemID,
RewardCount: command.RewardCount,
TouchedSlots: touched,
RewardSnapshots: rewardSnapshots,
}
if durableReceipt {
writeReceipt(character.Stats, receiptHash, result)
}
inventory.UpdatedAt = now
character.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
return dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats)
})
result.TouchedSlots = append([]int16(nil), result.TouchedSlots...)
result.RewardSnapshots = cloneRewardSnapshots(result.RewardSnapshots)
if err != nil {
// The callback can have prepared the exact final rows before a backend
// reports an indeterminate commit outcome. Return that frozen candidate
// with the error; a receipt-confirmed immediate replay can then publish
// the committed rows without a post-commit inventory load race.
return result, err
}
return result, nil
}
func mainInventorySlotKey(slot int16) string {
return fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, slot)
}
func stackWithCount(stack dnfrepo.ItemStack, slot int16, count int64) dnfrepo.ItemStack {
if stack.Extra == nil {
stack.Extra = make(map[string]string, 2)
}
value := strconv.FormatInt(count, 10)
stack.Extra["count"] = value
stack.Extra["amount_or_count"] = value
if len(stack.RawEntry) >= 10 && stack.ItemID > 0 && stack.ItemID <= math.MaxUint32 && count > 0 && count <= math.MaxUint32 {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
binary.LittleEndian.PutUint16(stack.RawEntry[0:2], uint16(slot))
binary.LittleEndian.PutUint32(stack.RawEntry[2:6], uint32(stack.ItemID))
binary.LittleEndian.PutUint32(stack.RawEntry[6:10], uint32(count))
}
return stack
}
func mainInventoryItemCount(inventory dnfrepo.InventoryRecord, itemID uint32) (uint64, error) {
var total uint64
for key, stack := range inventory.Slots {
if stack.ItemID != int64(itemID) || !strings.HasPrefix(key, "0:") {
continue
}
listRaw, slotRaw, ok := strings.Cut(key, ":")
if !ok || listRaw != "0" {
continue
}
slot, err := strconv.ParseInt(slotRaw, 10, 16)
if err != nil || slot < 0 || dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, int16(slot)) {
continue
}
if stack.Count < 0 {
return 0, ErrProjectionInvalid
}
count := uint64(stack.Count)
if math.MaxUint64-total < count {
return 0, ErrProjectionInvalid
}
total += count
}
return total, nil
}
func validateTouchedRewardSlots(
inventory dnfrepo.InventoryRecord,
rewardItemID uint32,
touched []int16,
) ([]int16, error) {
if len(touched) == 0 || len(touched) > math.MaxUint16 {
return nil, ErrProjectionInvalid
}
out := append([]int16(nil), touched...)
seen := make(map[int16]struct{}, len(out))
for _, slot := range out {
if slot < 0 || dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, slot) {
return nil, ErrProjectionInvalid
}
if _, duplicate := seen[slot]; duplicate {
return nil, ErrProjectionInvalid
}
seen[slot] = struct{}{}
stack, found := inventory.Slots[mainInventorySlotKey(slot)]
if !found || stack.ItemID != int64(rewardItemID) || stack.Count <= 0 {
return nil, ErrProjectionInvalid
}
}
return out, nil
}
func snapshotTouchedRewardSlots(
inventory dnfrepo.InventoryRecord,
rewardItemID uint32,
touched []int16,
) ([]RewardSnapshot, error) {
if len(touched) == 0 || len(touched) > math.MaxUint16 {
return nil, ErrProjectionInvalid
}
snapshots := make([]RewardSnapshot, 0, len(touched))
seen := make(map[int16]struct{}, len(touched))
for _, slot := range touched {
if slot < 0 || dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, slot) {
return nil, ErrProjectionInvalid
}
if _, duplicate := seen[slot]; duplicate {
return nil, ErrProjectionInvalid
}
seen[slot] = struct{}{}
stack, found := inventory.Slots[mainInventorySlotKey(slot)]
if !found || stack.ItemID != int64(rewardItemID) || stack.Count <= 0 {
return nil, ErrProjectionInvalid
}
if len(stack.RawEntry) > receiptRewardRawEntryMax {
return nil, ErrProjectionInvalid
}
snapshots = append(snapshots, RewardSnapshot{Slot: slot, Stack: cloneRewardStack(stack)})
}
return snapshots, nil
}
func cloneRewardSnapshots(snapshots []RewardSnapshot) []RewardSnapshot {
if len(snapshots) == 0 {
return nil
}
out := make([]RewardSnapshot, len(snapshots))
for index, snapshot := range snapshots {
out[index] = RewardSnapshot{Slot: snapshot.Slot, Stack: cloneRewardStack(snapshot.Stack)}
}
return out
}
func cloneRewardStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
if stack.Extra != nil {
extra := make(map[string]string, len(stack.Extra))
for key, value := range stack.Extra {
extra[key] = value
}
stack.Extra = extra
}
return stack
}
func receiptMatches(stats map[string]int64, want [sha256.Size]byte) bool {
if stats == nil || stats[receiptPrefix+"version"] != receiptVersion {
return false
}
for index := 0; index < 4; index++ {
if uint64(stats[fmt.Sprintf("%shash_%d", receiptPrefix, index)]) !=
binary.LittleEndian.Uint64(want[index*8:(index+1)*8]) {
return false
}
}
return true
}
func writeReceipt(stats map[string]int64, hash [sha256.Size]byte, result Result) {
stats[receiptPrefix+"version"] = receiptVersion
for index := 0; index < 4; index++ {
stats[fmt.Sprintf("%shash_%d", receiptPrefix, index)] =
int64(binary.LittleEndian.Uint64(hash[index*8 : (index+1)*8]))
}
stats[receiptPrefix+"material_slot"] = int64(result.MaterialSlot)
stats[receiptPrefix+"material_item_id"] = int64(result.MaterialItemID)
stats[receiptPrefix+"material_count"] = int64(result.MaterialCount)
stats[receiptPrefix+"material_remaining"] = int64(result.MaterialRemaining)
stats[receiptPrefix+"reward_item_id"] = int64(result.RewardItemID)
stats[receiptPrefix+"reward_count"] = int64(result.RewardCount)
stats[receiptPrefix+"touched_slot_count"] = int64(len(result.TouchedSlots))
for index, slot := range result.TouchedSlots {
stats[fmt.Sprintf("%stouched_slot_%d", receiptPrefix, index)] = int64(slot)
}
stats[receiptPrefix+"reward_snapshot_count"] = int64(len(result.RewardSnapshots))
for index, snapshot := range result.RewardSnapshots {
base := fmt.Sprintf("%sreward_snapshot_%d_", receiptPrefix, index)
stats[base+"slot"] = int64(snapshot.Slot)
stats[base+"item_id"] = snapshot.Stack.ItemID
stats[base+"count"] = snapshot.Stack.Count
raw := snapshot.Stack.RawEntry
stats[base+"raw_len"] = int64(len(raw))
for chunk := 0; chunk*receiptRewardRawChunkSize < len(raw); chunk++ {
start := chunk * receiptRewardRawChunkSize
end := start + receiptRewardRawChunkSize
if end > len(raw) {
end = len(raw)
}
var encoded [receiptRewardRawChunkSize]byte
copy(encoded[:], raw[start:end])
stats[fmt.Sprintf("%sraw_%d", base, chunk)] = int64(binary.LittleEndian.Uint64(encoded[:]))
}
}
}
func receiptResult(stats map[string]int64, command Command) (Result, error) {
materialSlot, slotFound := stats[receiptPrefix+"material_slot"]
materialItemID, materialFound := stats[receiptPrefix+"material_item_id"]
materialCount, materialCountFound := stats[receiptPrefix+"material_count"]
remaining, remainingFound := stats[receiptPrefix+"material_remaining"]
rewardItemID, rewardFound := stats[receiptPrefix+"reward_item_id"]
rewardCount, rewardCountFound := stats[receiptPrefix+"reward_count"]
touchedCount, touchedCountFound := stats[receiptPrefix+"touched_slot_count"]
if !slotFound || !materialFound || !materialCountFound || !remainingFound ||
!rewardFound || !rewardCountFound || !touchedCountFound ||
materialSlot != int64(command.MaterialSlot) || materialSlot < 0 || materialSlot > math.MaxInt16 ||
materialItemID != int64(command.MaterialItemID) || materialItemID <= 0 || materialItemID > math.MaxUint32 ||
materialCount != int64(command.MaterialCount) || materialCount <= 0 || materialCount > math.MaxUint32 ||
remaining < 0 || remaining > math.MaxUint32 ||
rewardItemID != int64(command.RewardItemID) || rewardItemID <= 0 || rewardItemID > math.MaxUint32 ||
rewardCount != int64(command.RewardCount) || rewardCount <= 0 || rewardCount > math.MaxUint32 ||
touchedCount <= 0 || touchedCount > math.MaxUint16 {
return Result{}, ErrReceiptInvalid
}
result := Result{
MaterialSlot: int16(materialSlot),
MaterialItemID: uint32(materialItemID),
MaterialCount: uint32(materialCount),
MaterialRemaining: uint32(remaining),
MaterialRemoved: remaining == 0,
RewardItemID: uint32(rewardItemID),
RewardCount: uint32(rewardCount),
TouchedSlots: make([]int16, 0, int(touchedCount)),
}
seen := make(map[int16]struct{}, int(touchedCount))
for index := 0; index < int(touchedCount); index++ {
value, found := stats[fmt.Sprintf("%stouched_slot_%d", receiptPrefix, index)]
if !found || value < 0 || value > math.MaxInt16 ||
dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, int16(value)) {
return Result{}, ErrReceiptInvalid
}
slot := int16(value)
if _, duplicate := seen[slot]; duplicate {
return Result{}, ErrReceiptInvalid
}
seen[slot] = struct{}{}
result.TouchedSlots = append(result.TouchedSlots, slot)
}
snapshotCount, snapshotCountFound := stats[receiptPrefix+"reward_snapshot_count"]
if !snapshotCountFound || snapshotCount != touchedCount {
return Result{}, ErrReceiptInvalid
}
result.RewardSnapshots = make([]RewardSnapshot, 0, int(snapshotCount))
for index := 0; index < int(snapshotCount); index++ {
base := fmt.Sprintf("%sreward_snapshot_%d_", receiptPrefix, index)
slotValue, slotFound := stats[base+"slot"]
itemValue, itemFound := stats[base+"item_id"]
countValue, countFound := stats[base+"count"]
rawLength, rawLengthFound := stats[base+"raw_len"]
if !slotFound || !itemFound || !countFound || !rawLengthFound ||
slotValue != int64(result.TouchedSlots[index]) ||
itemValue != int64(result.RewardItemID) || countValue <= 0 ||
rawLength < 0 || rawLength > receiptRewardRawEntryMax {
return Result{}, ErrReceiptInvalid
}
raw := make([]byte, int(rawLength))
for chunk := 0; chunk*receiptRewardRawChunkSize < len(raw); chunk++ {
encoded, found := stats[fmt.Sprintf("%sraw_%d", base, chunk)]
if !found {
return Result{}, ErrReceiptInvalid
}
var decoded [receiptRewardRawChunkSize]byte
binary.LittleEndian.PutUint64(decoded[:], uint64(encoded))
start := chunk * receiptRewardRawChunkSize
copy(raw[start:], decoded[:])
}
if len(raw) >= 10 &&
(binary.LittleEndian.Uint16(raw[0:2]) != uint16(slotValue) ||
binary.LittleEndian.Uint32(raw[2:6]) != result.RewardItemID ||
binary.LittleEndian.Uint32(raw[6:10]) == 0) {
return Result{}, ErrReceiptInvalid
}
result.RewardSnapshots = append(result.RewardSnapshots, RewardSnapshot{
Slot: int16(slotValue),
Stack: dnfrepo.ItemStack{
ItemID: itemValue,
Count: countValue,
RawEntry: raw,
},
})
}
return result, nil
}