DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,520 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package vending
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestOwnerUseDebitsExactClientMaterialSlotAndGrantsRewardAtomically(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{
|
||||
"0:140": {ItemID: 2749933, Count: 9},
|
||||
"0:142": {ItemID: 2749933, Count: 2, RawEntry: make([]byte, 61)},
|
||||
})
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedAt := time.Unix(1700000000, 0).UTC()
|
||||
result, err := owner.Use(ctx, Command{
|
||||
AccountID: "dnf:1",
|
||||
CharacterID: "77",
|
||||
CompletionKey: "vending/session-1/request-1",
|
||||
MaterialSlot: 142,
|
||||
MaterialItemID: 2749933,
|
||||
MaterialCount: 1,
|
||||
RewardItemID: 2749957,
|
||||
RewardCount: 2,
|
||||
UpdatedAt: updatedAt,
|
||||
Project: func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
|
||||
candidate.Slots["0:150"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
|
||||
return []int16{150}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.MaterialSlot != 142 || result.MaterialItemID != 2749933 || result.MaterialCount != 1 ||
|
||||
result.MaterialRemaining != 1 || result.MaterialRemoved || result.RewardItemID != 2749957 ||
|
||||
result.RewardCount != 2 || len(result.TouchedSlots) != 1 || result.TouchedSlots[0] != 150 ||
|
||||
len(result.RewardSnapshots) != 1 || result.RewardSnapshots[0].Slot != 150 ||
|
||||
result.RewardSnapshots[0].Stack.ItemID != 2749957 || result.RewardSnapshots[0].Stack.Count != 2 || result.Replayed {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
result.RewardSnapshots[0].Stack.Count = 99
|
||||
inventory := loadVendingInventory(t, ctx, repositories)
|
||||
if inventory.Slots["0:140"].Count != 9 || inventory.Slots["0:142"].Count != 1 ||
|
||||
inventory.Slots["0:150"].ItemID != 2749957 || inventory.Slots["0:150"].Count != 2 {
|
||||
t.Fatalf("inventory=%+v", inventory.Slots)
|
||||
}
|
||||
if got := inventory.Slots["0:142"].Extra["amount_or_count"]; got != "1" {
|
||||
t.Fatalf("material count projection=%q", got)
|
||||
}
|
||||
character, found, err := repositories.Character.Load(ctx, "77")
|
||||
if err != nil || !found || character.Stats[receiptPrefix+"version"] != receiptVersion {
|
||||
t.Fatalf("character found=%t err=%v stats=%+v", found, err, character.Stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerUseRemovesDepletedMaterialSlot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{
|
||||
"0:140": {ItemID: 7454, Count: 1},
|
||||
})
|
||||
owner, _ := NewOwner(repositories)
|
||||
result, err := owner.Use(ctx, vendingCommand(func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
|
||||
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
|
||||
return []int16{151}, nil
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.MaterialRemoved || result.MaterialRemaining != 0 {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
if inventory := loadVendingInventory(t, ctx, repositories); inventory.Slots["0:140"].ItemID != 0 {
|
||||
t.Fatalf("depleted material remains: %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerUseRollsBackMissingMaterialCapacityAndProjectionErrors(t *testing.T) {
|
||||
wantProjectionErr := errors.New("raw reward projection failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
material dnfrepo.ItemStack
|
||||
project Projector
|
||||
want error
|
||||
wantCalled bool
|
||||
}{
|
||||
{
|
||||
name: "missing material",
|
||||
material: dnfrepo.ItemStack{ItemID: 7454, Count: 0},
|
||||
project: func(*dnfrepo.InventoryRecord, uint32, uint32) ([]int16, error) {
|
||||
t.Fatal("projector called without material")
|
||||
return nil, nil
|
||||
},
|
||||
want: ErrMaterialMissing,
|
||||
},
|
||||
{
|
||||
name: "capacity",
|
||||
material: dnfrepo.ItemStack{ItemID: 7454, Count: 2},
|
||||
project: func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
|
||||
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
|
||||
return nil, ErrInventoryFull
|
||||
},
|
||||
want: ErrInventoryFull,
|
||||
},
|
||||
{
|
||||
name: "projection",
|
||||
material: dnfrepo.ItemStack{ItemID: 7454, Count: 2},
|
||||
project: func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
|
||||
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
|
||||
return nil, wantProjectionErr
|
||||
},
|
||||
want: wantProjectionErr,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{"0:140": test.material})
|
||||
owner, _ := NewOwner(repositories)
|
||||
_, err := owner.Use(ctx, vendingCommand(test.project))
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("error=%v want=%v", err, test.want)
|
||||
}
|
||||
inventory := loadVendingInventory(t, ctx, repositories)
|
||||
if inventory.Slots["0:140"].ItemID != test.material.ItemID ||
|
||||
inventory.Slots["0:140"].Count != test.material.Count {
|
||||
t.Fatalf("material changed after failure: before=%+v after=%+v", test.material, inventory.Slots["0:140"])
|
||||
}
|
||||
if _, found := inventory.Slots["0:151"]; found {
|
||||
t.Fatalf("reward leaked after failure: %+v", inventory.Slots)
|
||||
}
|
||||
character, _, err := repositories.Character.Load(ctx, "77")
|
||||
if err != nil || character.Stats[receiptPrefix+"version"] != 0 {
|
||||
t.Fatalf("receipt leaked after failure: stats=%+v err=%v", character.Stats, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerUseRejectsWrongAccountAndWrongExplicitSlot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{
|
||||
"0:140": {ItemID: 7454, Count: 2},
|
||||
"0:142": {ItemID: 7454, Count: 2},
|
||||
})
|
||||
owner, _ := NewOwner(repositories)
|
||||
command := vendingCommand(func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
|
||||
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
|
||||
return []int16{151}, nil
|
||||
})
|
||||
command.AccountID = "dnf:2"
|
||||
if _, err := owner.Use(ctx, command); !errors.Is(err, ErrAccountMismatch) {
|
||||
t.Fatalf("account error=%v", err)
|
||||
}
|
||||
command.AccountID = "dnf:1"
|
||||
command.MaterialSlot = 141
|
||||
if _, err := owner.Use(ctx, command); !errors.Is(err, ErrMaterialMissing) {
|
||||
t.Fatalf("slot error=%v", err)
|
||||
}
|
||||
inventory := loadVendingInventory(t, ctx, repositories)
|
||||
if inventory.Slots["0:140"].Count != 2 || inventory.Slots["0:142"].Count != 2 {
|
||||
t.Fatalf("wrong slot fallback consumed another stack: %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerUseReceiptReplaysCommitThenWrapperErrorExactlyOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{
|
||||
"0:140": {ItemID: 7454, Count: 2},
|
||||
})
|
||||
wantErr := errors.New("transaction committed but response was lost")
|
||||
indeterminate := &commitThenErrorCharacterAssets{
|
||||
base: repositories.CharacterAssets,
|
||||
failOnce: true,
|
||||
err: wantErr,
|
||||
}
|
||||
repositories.CharacterAssets = indeterminate
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectCalls := 0
|
||||
command := vendingCommand(func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
|
||||
projectCalls++
|
||||
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
|
||||
return []int16{151}, nil
|
||||
})
|
||||
command.CompletionKey = "vending/session-77/request-0001"
|
||||
if _, err := owner.Use(ctx, command); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("first error=%v", err)
|
||||
}
|
||||
inventory := loadVendingInventory(t, ctx, repositories)
|
||||
if inventory.Slots["0:140"].Count != 1 || inventory.Slots["0:151"].Count != 1 {
|
||||
t.Fatalf("inner commit did not persist exactly once: %+v", inventory.Slots)
|
||||
}
|
||||
|
||||
replayed, err := owner.Use(ctx, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !replayed.Replayed || replayed.MaterialSlot != 140 || replayed.MaterialRemaining != 1 ||
|
||||
replayed.MaterialRemoved || replayed.RewardItemID != 31 || replayed.RewardCount != 1 ||
|
||||
len(replayed.TouchedSlots) != 1 || replayed.TouchedSlots[0] != 151 ||
|
||||
len(replayed.RewardSnapshots) != 1 || replayed.RewardSnapshots[0].Slot != 151 ||
|
||||
replayed.RewardSnapshots[0].Stack.ItemID != 31 || replayed.RewardSnapshots[0].Stack.Count != 1 {
|
||||
t.Fatalf("replayed=%+v", replayed)
|
||||
}
|
||||
inventory = loadVendingInventory(t, ctx, repositories)
|
||||
if inventory.Slots["0:140"].Count != 1 || inventory.Slots["0:151"].Count != 1 ||
|
||||
projectCalls != 1 || indeterminate.calls != 2 {
|
||||
t.Fatalf("replay duplicated mutation: slots=%+v projectCalls=%d txCalls=%d", inventory.Slots, projectCalls, indeterminate.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerUseSameReceiptKeyRejectsChangedRewardAuthority(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := vendingOwnerTestRepositories(t, ctx, map[string]dnfrepo.ItemStack{
|
||||
"0:140": {ItemID: 7454, Count: 2},
|
||||
})
|
||||
owner, _ := NewOwner(repositories)
|
||||
command := vendingCommand(func(candidate *dnfrepo.InventoryRecord, itemID, count uint32) ([]int16, error) {
|
||||
candidate.Slots["0:151"] = dnfrepo.ItemStack{ItemID: int64(itemID), Count: int64(count)}
|
||||
return []int16{151}, nil
|
||||
})
|
||||
command.CompletionKey = "stable-key"
|
||||
if _, err := owner.Use(ctx, command); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command.RewardItemID = 44
|
||||
if _, err := owner.Use(ctx, command); !errors.Is(err, ErrReceiptInvalid) {
|
||||
t.Fatalf("changed authority replay error=%v", err)
|
||||
}
|
||||
inventory := loadVendingInventory(t, ctx, repositories)
|
||||
if inventory.Slots["0:140"].Count != 1 || inventory.Slots["0:151"].ItemID != 31 {
|
||||
t.Fatalf("changed replay mutated inventory: %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func vendingCommand(project Projector) Command {
|
||||
return Command{
|
||||
AccountID: "dnf:1",
|
||||
CharacterID: "77",
|
||||
CompletionKey: "vending/test-request",
|
||||
MaterialSlot: 140,
|
||||
MaterialItemID: 7454,
|
||||
MaterialCount: 1,
|
||||
RewardItemID: 31,
|
||||
RewardCount: 1,
|
||||
Project: project,
|
||||
}
|
||||
}
|
||||
|
||||
func vendingOwnerTestRepositories(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
slots map[string]dnfrepo.ItemStack,
|
||||
) dnfrepo.Group {
|
||||
t.Helper()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "77",
|
||||
AccountID: "dnf:1",
|
||||
Stats: map[string]int64{"gold": 100},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "77",
|
||||
Slots: slots,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return repositories
|
||||
}
|
||||
|
||||
func loadVendingInventory(t *testing.T, ctx context.Context, repositories dnfrepo.Group) dnfrepo.InventoryRecord {
|
||||
t.Helper()
|
||||
inventory, found, err := repositories.Inventory.Load(ctx, "77")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load inventory found=%t err=%v", found, err)
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
|
||||
type commitThenErrorCharacterAssets struct {
|
||||
base dnfrepo.CharacterAssetUnitOfWork
|
||||
failOnce bool
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (unit *commitThenErrorCharacterAssets) WithinCharacterAssets(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
apply func(dnfrepo.CharacterRepository, dnfrepo.InventoryRepository, dnfrepo.EquipmentRepository) error,
|
||||
) error {
|
||||
unit.calls++
|
||||
err := unit.base.WithinCharacterAssets(ctx, characterID, apply)
|
||||
if err == nil && unit.failOnce {
|
||||
unit.failOnce = false
|
||||
return unit.err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var _ dnfrepo.CharacterAssetUnitOfWork = (*commitThenErrorCharacterAssets)(nil)
|
||||
Reference in new issue
Block a user