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,299 @@
|
||||
package equipmenteffect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("equipment effect owner is unavailable")
|
||||
ErrCharacterRequired = errors.New("equipment effect character id is required")
|
||||
ErrRequestInvalid = errors.New("equipment effect request is invalid")
|
||||
ErrInventoryMissing = errors.New("equipment effect inventory is missing")
|
||||
ErrSourceMissing = errors.New("equipment effect rune is missing")
|
||||
ErrSourceAmbiguous = errors.New("equipment effect rune source is ambiguous")
|
||||
ErrTargetMissing = errors.New("equipment effect target is missing")
|
||||
ErrTargetInvalid = errors.New("equipment effect target is invalid")
|
||||
ErrTargetSealed = errors.New("equipment effect target is sealed")
|
||||
ErrCatalogUnavailable = errors.New("equipment effect item catalog is unavailable")
|
||||
)
|
||||
|
||||
// ItemDefinition is the small, PVF-derived subset needed to validate one
|
||||
// weapon-effect operation. The bridge owns PVF decoding; this owner owns the
|
||||
// durable business rule and transaction.
|
||||
type ItemDefinition struct {
|
||||
IsEquipment bool
|
||||
EquipmentType string
|
||||
Grade int64
|
||||
StackableType string
|
||||
EffectID uint16
|
||||
}
|
||||
|
||||
type ItemCatalog interface {
|
||||
ResolveEquipmentEffectItem(uint32) (ItemDefinition, error)
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
CharacterID string
|
||||
RequestedSourceSlot int16
|
||||
TargetListType byte
|
||||
TargetSlot int16
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
CharacterID string
|
||||
RequestedSourceSlot int16
|
||||
SourceSlot int16
|
||||
SourceRecovered bool
|
||||
SourceItemID int64
|
||||
SourceRemainingCount int64
|
||||
SourceRemoved bool
|
||||
TargetListType byte
|
||||
TargetSlot int16
|
||||
TargetItemID int64
|
||||
EffectID uint16
|
||||
TargetStack dnfrepo.ItemStack
|
||||
}
|
||||
|
||||
// Owner atomically consumes a real [equipment effect] rune and records its
|
||||
// effect id on one un-equipped weapon in the main inventory.
|
||||
type Owner struct {
|
||||
items dnfrepo.CharacterItemUnitOfWork
|
||||
catalog ItemCatalog
|
||||
}
|
||||
|
||||
func NewOwner(repositories dnfrepo.Group, catalog ItemCatalog) (*Owner, error) {
|
||||
if repositories.CharacterItems == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
if catalog == nil {
|
||||
return nil, ErrCatalogUnavailable
|
||||
}
|
||||
return &Owner{items: repositories.CharacterItems, catalog: catalog}, nil
|
||||
}
|
||||
|
||||
func (o *Owner) Apply(ctx context.Context, command Command) (Result, error) {
|
||||
if o == nil || o.items == nil || o.catalog == nil {
|
||||
return Result{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if characterID == "" {
|
||||
return Result{}, ErrCharacterRequired
|
||||
}
|
||||
if command.RequestedSourceSlot < 0 || command.TargetSlot < 0 ||
|
||||
command.TargetListType != dnfrepo.MainInventoryListType {
|
||||
return Result{}, ErrRequestInvalid
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
result := Result{
|
||||
CharacterID: characterID,
|
||||
RequestedSourceSlot: command.RequestedSourceSlot,
|
||||
TargetListType: command.TargetListType,
|
||||
TargetSlot: command.TargetSlot,
|
||||
}
|
||||
err := o.items.WithinCharacterItems(ctx, characterID, func(inventories dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
|
||||
if inventories == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
inventory, found, err := inventories.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
|
||||
return ErrInventoryMissing
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
|
||||
targetKey := inventoryKey(command.TargetListType, command.TargetSlot)
|
||||
target, found := inventory.Slots[targetKey]
|
||||
if !found || target.ItemID <= 0 {
|
||||
return fmt.Errorf("%w: list=%d slot=%d", ErrTargetMissing, command.TargetListType, command.TargetSlot)
|
||||
}
|
||||
targetDefinition, err := o.definition(target.ItemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !targetDefinition.IsEquipment || !strings.EqualFold(strings.TrimSpace(targetDefinition.EquipmentType), "[weapon]") || targetDefinition.Grade <= 2 {
|
||||
return fmt.Errorf("%w: item=%d", ErrTargetInvalid, target.ItemID)
|
||||
}
|
||||
if isSealed(target) {
|
||||
return fmt.Errorf("%w: item=%d", ErrTargetSealed, target.ItemID)
|
||||
}
|
||||
if target.Count != 1 {
|
||||
// 一枚符文只能作用一件装备。装备一件一行,Count != 1 只可能是魔盒发奖
|
||||
// 缺陷留下的历史堆叠行(现场 2026-09-27 线上 90 行 357 件);放它进来
|
||||
// 就是一枚符文给整行 N 件全部附上效果。
|
||||
return fmt.Errorf("%w: item=%d count=%d", ErrTargetInvalid, target.ItemID, target.Count)
|
||||
}
|
||||
|
||||
sourceSlot, source, recovered, err := o.resolveRuneSource(inventory, command.RequestedSourceSlot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceDefinition, err := o.definition(source.ItemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isEquipmentEffectRune(sourceDefinition) || source.Count <= 0 {
|
||||
return fmt.Errorf("%w: slot=%d item=%d", ErrSourceMissing, sourceSlot, source.ItemID)
|
||||
}
|
||||
|
||||
if target.Extra == nil {
|
||||
target.Extra = make(map[string]string)
|
||||
}
|
||||
target.Extra["item_kind"] = "equipment"
|
||||
target.Extra["equipment_effect_id"] = strconv.FormatUint(uint64(sourceDefinition.EffectID), 10)
|
||||
target.Extra["equipment_effect_source_item_id"] = strconv.FormatInt(source.ItemID, 10)
|
||||
// The current client reads the weapon-effect word from the equipment
|
||||
// row itself at +0x45, not only from the preferred-extra projection.
|
||||
// Persist it into the raw row so the mode1 create row carries it even
|
||||
// before the op14 rune hydration refresh.
|
||||
if len(target.RawEntry) >= 0x47 {
|
||||
raw := append([]byte(nil), target.RawEntry...)
|
||||
binary.LittleEndian.PutUint16(raw[0x45:0x47], sourceDefinition.EffectID)
|
||||
target.RawEntry = raw
|
||||
}
|
||||
inventory.Slots[targetKey] = target
|
||||
|
||||
source.Count--
|
||||
removed := source.Count == 0
|
||||
sourceKey := inventoryKey(dnfrepo.MainInventoryListType, sourceSlot)
|
||||
if removed {
|
||||
delete(inventory.Slots, sourceKey)
|
||||
} else {
|
||||
inventory.Slots[sourceKey] = source
|
||||
}
|
||||
inventory.UpdatedAt = now
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result.SourceSlot = sourceSlot
|
||||
result.SourceRecovered = recovered
|
||||
result.SourceItemID = source.ItemID
|
||||
result.SourceRemainingCount = source.Count
|
||||
result.SourceRemoved = removed
|
||||
result.TargetItemID = target.ItemID
|
||||
result.EffectID = sourceDefinition.EffectID
|
||||
result.TargetStack = target
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (o *Owner) resolveRuneSource(inventory dnfrepo.InventoryRecord, requestedSlot int16) (int16, dnfrepo.ItemStack, bool, error) {
|
||||
requestedKey := inventoryKey(dnfrepo.MainInventoryListType, requestedSlot)
|
||||
if stack, found := inventory.Slots[requestedKey]; found && stack.ItemID > 0 && stack.Count > 0 {
|
||||
definition, err := o.definition(stack.ItemID)
|
||||
if err != nil {
|
||||
return 0, dnfrepo.ItemStack{}, false, err
|
||||
}
|
||||
if isEquipmentEffectRune(definition) {
|
||||
return requestedSlot, stack, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// A historical raw-entry slot can remain in this 2018 client after a Cera
|
||||
// grant/move. The request then names a non-rune slot although only one real
|
||||
// effect rune is present. Recover only that unique rune; never consume a
|
||||
// different stack or guess when two candidates exist.
|
||||
var (
|
||||
candidateSlot int16
|
||||
candidate dnfrepo.ItemStack
|
||||
candidateCount int
|
||||
)
|
||||
for key, stack := range inventory.Slots {
|
||||
listType, slot, ok := parseInventoryKey(key)
|
||||
if !ok || listType != dnfrepo.MainInventoryListType || slot < 0 || stack.ItemID <= 0 || stack.Count <= 0 {
|
||||
continue
|
||||
}
|
||||
definition, err := o.definition(stack.ItemID)
|
||||
if err != nil {
|
||||
return 0, dnfrepo.ItemStack{}, false, err
|
||||
}
|
||||
if !isEquipmentEffectRune(definition) {
|
||||
continue
|
||||
}
|
||||
candidateSlot = slot
|
||||
candidate = stack
|
||||
candidateCount++
|
||||
}
|
||||
if candidateCount == 0 {
|
||||
return 0, dnfrepo.ItemStack{}, false, fmt.Errorf("%w: requested_slot=%d", ErrSourceMissing, requestedSlot)
|
||||
}
|
||||
if candidateCount > 1 {
|
||||
return 0, dnfrepo.ItemStack{}, false, fmt.Errorf("%w: requested_slot=%d matches=%d", ErrSourceAmbiguous, requestedSlot, candidateCount)
|
||||
}
|
||||
return candidateSlot, candidate, true, nil
|
||||
}
|
||||
|
||||
func (o *Owner) definition(itemID int64) (ItemDefinition, error) {
|
||||
if itemID <= 0 || itemID > int64(^uint32(0)) {
|
||||
return ItemDefinition{}, fmt.Errorf("%w: item=%d", ErrCatalogUnavailable, itemID)
|
||||
}
|
||||
definition, err := o.catalog.ResolveEquipmentEffectItem(uint32(itemID))
|
||||
if err != nil {
|
||||
return ItemDefinition{}, fmt.Errorf("%w: item=%d: %v", ErrCatalogUnavailable, itemID, err)
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func isEquipmentEffectRune(definition ItemDefinition) bool {
|
||||
return !definition.IsEquipment && definition.EffectID != 0 &&
|
||||
strings.Contains(strings.ToLower(definition.StackableType), "equipment effect")
|
||||
}
|
||||
|
||||
func isSealed(stack dnfrepo.ItemStack) bool {
|
||||
if stack.Extra == nil {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"seal_flag", "seal", "sealed"} {
|
||||
value := strings.TrimSpace(stack.Extra[key])
|
||||
if value == "" || value == "0" || strings.EqualFold(value, "false") {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func inventoryKey(listType byte, slot int16) string {
|
||||
return fmt.Sprintf("%d:%d", listType, slot)
|
||||
}
|
||||
|
||||
func parseInventoryKey(key string) (byte, int16, bool) {
|
||||
parts := strings.Split(strings.TrimSpace(key), ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
list, err := strconv.ParseUint(parts[0], 10, 8)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
slot, err := strconv.ParseInt(parts[1], 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return byte(list), int16(slot), true
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package equipmenteffect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
type catalogStub map[uint32]ItemDefinition
|
||||
|
||||
func (c catalogStub) ResolveEquipmentEffectItem(itemID uint32) (ItemDefinition, error) {
|
||||
definition, found := c[itemID]
|
||||
if !found {
|
||||
return ItemDefinition{}, errors.New("item missing")
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func TestOwnerApplyRecoversOneRuneFromStaleRequestedSlot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "1",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:11": {ItemID: 101, Count: 1},
|
||||
"0:81": {ItemID: 201, Count: 18},
|
||||
"0:83": {ItemID: 301, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories, catalogStub{
|
||||
101: {IsEquipment: true, EquipmentType: "[weapon]", Grade: 96},
|
||||
201: {StackableType: "[etc]"},
|
||||
301: {StackableType: "[equipment effect]", EffectID: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := owner.Apply(ctx, Command{
|
||||
CharacterID: "1", RequestedSourceSlot: 81, TargetListType: 0, TargetSlot: 11,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if !result.SourceRecovered || result.SourceSlot != 83 || result.SourceItemID != 301 ||
|
||||
!result.SourceRemoved || result.EffectID != 1 || result.TargetItemID != 101 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
inventory, found, err := repositories.Inventory.Load(ctx, "1")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load inventory found=%t err=%v", found, err)
|
||||
}
|
||||
if _, found := inventory.Slots["0:83"]; found {
|
||||
t.Fatalf("rune remains in inventory: %+v", inventory.Slots["0:83"])
|
||||
}
|
||||
if inventory.Slots["0:81"].Count != 18 {
|
||||
t.Fatalf("non-rune source was changed: %+v", inventory.Slots["0:81"])
|
||||
}
|
||||
if got := inventory.Slots["0:11"].Extra["equipment_effect_id"]; got != "1" {
|
||||
t.Fatalf("target effect id = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerApplyRejectsAmbiguousRuneFallbackWithoutMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "1",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:11": {ItemID: 101, Count: 1},
|
||||
"0:81": {ItemID: 201, Count: 1},
|
||||
"0:83": {ItemID: 301, Count: 1},
|
||||
"0:84": {ItemID: 302, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories, catalogStub{
|
||||
101: {IsEquipment: true, EquipmentType: "[weapon]", Grade: 96},
|
||||
201: {StackableType: "[etc]"},
|
||||
301: {StackableType: "[equipment effect]", EffectID: 1},
|
||||
302: {StackableType: "[equipment effect]", EffectID: 2},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = owner.Apply(ctx, Command{CharacterID: "1", RequestedSourceSlot: 81, TargetListType: 0, TargetSlot: 11})
|
||||
if !errors.Is(err, ErrSourceAmbiguous) {
|
||||
t.Fatalf("Apply error = %v", err)
|
||||
}
|
||||
inventory, _, err := repositories.Inventory.Load(ctx, "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inventory.Slots["0:83"].Count != 1 || inventory.Slots["0:84"].Count != 1 ||
|
||||
inventory.Slots["0:11"].Extra["equipment_effect_id"] != "" {
|
||||
t.Fatalf("inventory mutated on rejected request: %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerApplyRejectsNonWeaponBeforeConsumingRune(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "1",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:11": {ItemID: 101, Count: 1},
|
||||
"0:83": {ItemID: 301, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories, catalogStub{
|
||||
101: {IsEquipment: true, EquipmentType: "[ring]", Grade: 96},
|
||||
301: {StackableType: "[equipment effect]", EffectID: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = owner.Apply(ctx, Command{CharacterID: "1", RequestedSourceSlot: 83, TargetListType: 0, TargetSlot: 11})
|
||||
if !errors.Is(err, ErrTargetInvalid) {
|
||||
t.Fatalf("Apply error = %v", err)
|
||||
}
|
||||
inventory, _, err := repositories.Inventory.Load(ctx, "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inventory.Slots["0:83"].Count != 1 {
|
||||
t.Fatalf("rune was consumed for invalid target")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user