DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,70 @@
package itemlock
import "encoding/binary"
const (
msgItemLockList uint16 = 0x00FB
msgItemUnlockNotice uint16 = 0x00FC
itemLockStateActive byte = 1
itemLockStatePending byte = 2
)
// LockListMessageID is the class-0 notification that replaces the client's
// complete item-lock cache for the selected character.
const LockListMessageID = msgItemLockList
type packetWriter struct {
data []byte
}
func (w *packetWriter) writeByte(value byte) {
w.data = append(w.data, value)
}
func (w *packetWriter) writeUint16(value uint16) {
var buf [2]byte
binary.LittleEndian.PutUint16(buf[:], value)
w.data = append(w.data, buf[:]...)
}
func (w *packetWriter) writeInt16(value int16) {
w.writeUint16(uint16(value))
}
func (w *packetWriter) writeInt32(value int32) {
var buf [4]byte
binary.LittleEndian.PutUint32(buf[:], uint32(value))
w.data = append(w.data, buf[:]...)
}
func (w *packetWriter) bytes() []byte {
return append([]byte(nil), w.data...)
}
func buildLockAck(listType byte, slotIndex int16) []byte {
var writer packetWriter
writer.writeByte(listType)
writer.writeInt16(slotIndex)
return writer.bytes()
}
func buildUnlockAck(listType byte, slotIndex int16, remainingSeconds int32) []byte {
var writer packetWriter
writer.writeByte(listType)
writer.writeInt16(slotIndex)
writer.writeInt32(remainingSeconds)
return writer.bytes()
}
func buildLockListDelta(listType byte, slotIndex int16, state byte, remainingSeconds int32) []byte {
var writer packetWriter
writer.writeUint16(1)
writer.writeByte(listType)
writer.writeInt16(slotIndex)
writer.writeByte(state)
if state == 2 {
writer.writeInt32(remainingSeconds)
}
return writer.bytes()
}
@@ -0,0 +1,38 @@
// 本文件定义物品锁命令计划。
// 计划只承接协议解析后的锁定意图,不直接修改物品锁状态或发送 NOTI。
package itemlock
import (
"fmt"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
)
// Command 是物品锁 handler 交给后续 owner 的最小命令计划。
// 物品锁会影响出售、移动、分解等关键写路径,必须由背包/装备 owner 持久化后再回包。
type Command struct {
Operation string
AccountID string
SelectedCharacterID uint16
ListType byte
SlotIndex int16
NeedsOwner string
}
// NewCommand 构造物品锁命令计划。
// 当前只用于日志和后续 owner 接入点,不写角色背包或装备状态。
func NewCommand(req alignedcmd.Request, operation string, lock Request) Command {
return Command{
Operation: operation,
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
ListType: lock.ListType,
SlotIndex: lock.SlotIndex,
NeedsOwner: "inventory/equipment owner + lock-state persistence + NOTI order",
}
}
func (c Command) String() string {
return fmt.Sprintf("account=%q char=%d list=%d slot=%d needs=%s", c.AccountID, c.SelectedCharacterID, c.ListType, c.SlotIndex, c.NeedsOwner)
}
@@ -0,0 +1,2 @@
// Package itemlock 承接 DNF 物品锁定、解锁和取消解锁类 C2S 协议。
package itemlock
@@ -0,0 +1,188 @@
package itemlock
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
)
type handler struct{}
func NewHandler() alignedcmd.Handler {
return handler{}
}
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainItemLock
}
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
switch dnfenum.CmdPacket(req.Opcode) {
case dnfenum.CmdPacketRequestItemLock:
parsed, err := DecodeRequest(req.Body)
cmd := NewCommand(req, "request_item_lock", parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketRequestItemUnlock:
parsed, err := DecodeRequest(req.Body)
cmd := NewCommand(req, "request_item_unlock", parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketRequestItemUnlockCancel:
parsed, err := DecodeRequest(req.Body)
cmd := NewCommand(req, "request_item_unlock_cancel", parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketRequestItemSortLock:
parsed, err := DecodeSortLockRequest(req.Body)
cmd := NewCommand(req, "request_item_sort_lock", parsed)
return applySortLockOwner(ctx, req, cmd, true, err), nil
case dnfenum.CmdPacketRequestItemSortUnlock:
parsed, err := DecodeSortLockRequest(req.Body)
cmd := NewCommand(req, "request_item_sort_unlock", parsed)
return applySortLockOwner(ctx, req, cmd, false, err), nil
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("itemlock module has no opcode %d handler", req.Opcode),
}, nil
}
}
func applySortLockOwner(ctx context.Context, req alignedcmd.Request, cmd Command, locked bool, parseErr error) alignedcmd.Result {
if parseErr != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd, err)
}
result, err := owner.ApplySortLock(ctx, cmd, locked)
if err != nil {
return ownerBlockedResult(cmd, err)
}
response := class1Response(req.Opcode, BuildSortLockStateBody(result.ListType, result.SlotIndex))
return alignedcmd.Result{
Handled: true,
ResponseAllowed: true,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"item sort-lock owner applied: %s state=%q changed=%t; opened success+list+slot ACK",
cmd.String(), result.State, result.Changed,
),
UpperResponses: []alignedcmd.UpperResponse{response},
}
}
func applyOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
if parseErr != nil {
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
}
owner, err := NewOwner(req.Repositories)
if err != nil {
return ownerBlockedResult(cmd, err)
}
result, err := owner.Apply(ctx, cmd)
if err != nil {
return ownerBlockedResult(cmd, err)
}
return ownerAppliedResult(req.Opcode, cmd, result)
}
func ownerBlockedResult(cmd Command, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf("parsed %s: %s; itemlock owner failed: %v; success ACK is blocked", cmd.Operation, cmd.String(), err),
}
}
func ownerAppliedResult(opcode uint16, cmd Command, result Result) alignedcmd.Result {
responses := itemLockUpperResponses(opcode, cmd.Operation, result)
return alignedcmd.Result{
Handled: true,
ResponseAllowed: len(responses) > 0,
Operation: cmd.Operation,
Reason: fmt.Sprintf(
"itemlock owner applied: %s state=%q changed=%t; opened %s response in C# order",
cmd.String(),
result.State,
result.Changed,
ackEvidenceLabel(cmd.Operation),
),
UpperResponses: responses,
}
}
func itemLockUpperResponses(opcode uint16, operation string, result Result) []alignedcmd.UpperResponse {
switch operation {
case "request_item_lock":
return []alignedcmd.UpperResponse{
class1Response(opcode, buildLockAck(result.ListType, result.SlotIndex)),
class0Response(msgItemLockList, buildLockListDelta(result.ListType, result.SlotIndex, itemLockStateActive, 0)),
}
case "request_item_unlock":
return []alignedcmd.UpperResponse{
class1Response(opcode, buildUnlockAck(result.ListType, result.SlotIndex, 0)),
class0Response(msgItemUnlockNotice, buildLockAck(result.ListType, result.SlotIndex)),
}
case "request_item_unlock_cancel":
return []alignedcmd.UpperResponse{
class1Response(opcode, buildLockAck(result.ListType, result.SlotIndex)),
class0Response(msgItemLockList, buildLockListDelta(result.ListType, result.SlotIndex, itemLockStateActive, 0)),
}
default:
return nil
}
}
func class1Response(opcode uint16, body []byte) alignedcmd.UpperResponse {
return alignedcmd.UpperResponse{
MsgID: opcode,
Body: body,
Classification: dnfproto.DefaultChannelClassification,
AllowCodec: true,
}
}
func class0Response(msgID uint16, body []byte) alignedcmd.UpperResponse {
return alignedcmd.UpperResponse{
MsgID: msgID,
Body: body,
Classification: 0,
AllowCodec: true,
}
}
func ackEvidenceLabel(operation string) string {
switch operation {
case "request_item_lock":
return "0x010B"
case "request_item_unlock":
return "0x010C"
case "request_item_unlock_cancel":
return "0x010D"
default:
return "ACK"
}
}
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
if err != nil {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("%s body parse failed: %v; success ACK is blocked", operation, err),
}
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("parsed %s: %s; itemlock persistence and NOTI order are not closed", operation, summary),
}
}
@@ -0,0 +1,282 @@
package itemlock
import (
"context"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestDecodeRequest(t *testing.T) {
got, err := DecodeRequest([]byte{3, 0x08, 0x00})
if err != nil {
t.Fatalf("DecodeRequest error = %v", err)
}
if got.ListType != 3 || got.SlotIndex != 8 {
t.Fatalf("got = %+v", got)
}
}
func TestHandlerBlocksItemLockAck(t *testing.T) {
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketRequestItemUnlockCancel),
Body: []byte{3, 0x08, 0x00},
AccountID: " acc-lock ",
SelectedCharacterID: 27,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
if got.Operation != "request_item_unlock_cancel" {
t.Fatalf("operation = %q", got.Operation)
}
if !strings.Contains(got.Reason, `account="acc-lock"`) || !strings.Contains(got.Reason, "char=27") || !strings.Contains(got.Reason, "list=3") || !strings.Contains(got.Reason, "slot=8") {
t.Fatalf("reason should include command plan, got %q", got.Reason)
}
}
func TestCommandRecordsOwnerGap(t *testing.T) {
cmd := NewCommand(alignedcmd.Request{
AccountID: " acc-lock-2 ",
SelectedCharacterID: 88,
}, "request_item_lock", Request{ListType: 3, SlotIndex: 9})
if cmd.AccountID != "acc-lock-2" || cmd.SelectedCharacterID != 88 || cmd.ListType != 3 || cmd.SlotIndex != 9 {
t.Fatalf("cmd = %+v", cmd)
}
if !strings.Contains(cmd.String(), "NOTI order") {
t.Fatalf("command plan must name notify/order gap: %s", cmd.String())
}
}
func TestOwnerLockPersists(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "27",
Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 8): {ItemID: 1001, Count: 1},
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Apply(ctx, NewCommand(alignedcmd.Request{
AccountID: "acc-lock",
SelectedCharacterID: 27,
}, "request_item_lock", Request{ListType: listTypeMain, SlotIndex: 8}))
if err != nil {
t.Fatalf("Apply error = %v", err)
}
if !result.Changed || result.State != lockStateActive {
t.Fatalf("result = %+v", result)
}
loaded := loadTestInventory(t, ctx, repos, "27")
got := loaded.Slots[slotKey(listTypeMain, 8)].Extra["equipment_lock_state"]
if got != lockStateActive {
t.Fatalf("lock state = %q, want %q", got, lockStateActive)
}
}
func TestOwnerUnlockClearsLock(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "27",
Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeAvatar, 4): {ItemID: 2002, Count: 1, Extra: map[string]string{"equipment_lock_state": lockStateActive}},
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.Apply(ctx, NewCommand(alignedcmd.Request{
AccountID: "acc-lock",
SelectedCharacterID: 27,
}, "request_item_unlock", Request{ListType: listTypeAvatar, SlotIndex: 4}))
if err != nil {
t.Fatalf("Apply error = %v", err)
}
if !result.Changed || result.State != "" {
t.Fatalf("result = %+v", result)
}
loaded := loadTestInventory(t, ctx, repos, "27")
if _, ok := loaded.Slots[slotKey(listTypeAvatar, 4)].Extra["equipment_lock_state"]; ok {
t.Fatalf("lock state should be cleared: %+v", loaded.Slots[slotKey(listTypeAvatar, 4)].Extra)
}
}
func TestOwnerRejectsEquipmentListUntilEquipmentOwnerExists(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "27"})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Apply(ctx, NewCommand(alignedcmd.Request{
AccountID: "acc-lock",
SelectedCharacterID: 27,
}, "request_item_lock", Request{ListType: listTypeEquipment, SlotIndex: 1}))
if err != ErrEquipmentOwnerRequired {
t.Fatalf("Apply error = %v, want ErrEquipmentOwnerRequired", err)
}
}
func TestHandlerUsesOwnerAndReturnsLockAckThenListDelta(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "27",
Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 8): {ItemID: 1001, Count: 1},
},
})
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketRequestItemLock),
Body: []byte{listTypeMain, 0x08, 0x00},
AccountID: " acc-lock ",
SelectedCharacterID: 27,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || !got.ResponseAllowed || got.Operation != "request_item_lock" {
t.Fatalf("result = %+v", got)
}
for _, want := range []string{"itemlock owner applied", "0x010B"} {
if !strings.Contains(got.Reason, want) {
t.Fatalf("reason %q missing %q", got.Reason, want)
}
}
if len(got.UpperResponses) != 2 {
t.Fatalf("response count = %d, want 2", len(got.UpperResponses))
}
ack := got.UpperResponses[0]
if ack.MsgID != uint16(dnfenum.CmdPacketRequestItemLock) || ack.Classification != dnfproto.DefaultChannelClassification || !ack.AllowCodec {
t.Fatalf("ack = %+v", ack)
}
if want := []byte{listTypeMain, 0x08, 0x00}; string(ack.Body) != string(want) {
t.Fatalf("ack body = % X, want % X", ack.Body, want)
}
refresh := got.UpperResponses[1]
if refresh.MsgID != msgItemLockList || refresh.Classification != 0 || !refresh.AllowCodec {
t.Fatalf("refresh = %+v", refresh)
}
if want := []byte{1, 0, listTypeMain, 0x08, 0x00, itemLockStateActive}; string(refresh.Body) != string(want) {
t.Fatalf("refresh body = % X, want % X", refresh.Body, want)
}
loaded := loadTestInventory(t, ctx, repos, "27")
if got := loaded.Slots[slotKey(listTypeMain, 8)].Extra["equipment_lock_state"]; got != lockStateActive {
t.Fatalf("lock state = %q, want %q", got, lockStateActive)
}
}
func TestHandlerUnlockReturnsAckThenUnlockNotice(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "27",
Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeAvatar, 4): {ItemID: 2002, Count: 1, Extra: map[string]string{"equipment_lock_state": lockStateActive}},
},
})
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketRequestItemUnlock),
Body: []byte{listTypeAvatar, 0x04, 0x00},
AccountID: "acc-lock",
SelectedCharacterID: 27,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || !got.ResponseAllowed || got.Operation != "request_item_unlock" {
t.Fatalf("result = %+v", got)
}
if len(got.UpperResponses) != 2 {
t.Fatalf("response count = %d, want 2", len(got.UpperResponses))
}
if ack := got.UpperResponses[0]; ack.MsgID != uint16(dnfenum.CmdPacketRequestItemUnlock) || string(ack.Body) != string([]byte{listTypeAvatar, 0x04, 0x00, 0, 0, 0, 0}) {
t.Fatalf("ack = %+v", ack)
}
if notice := got.UpperResponses[1]; notice.MsgID != msgItemUnlockNotice || notice.Classification != 0 || string(notice.Body) != string([]byte{listTypeAvatar, 0x04, 0x00}) {
t.Fatalf("notice = %+v", notice)
}
loaded := loadTestInventory(t, ctx, repos, "27")
if _, ok := loaded.Slots[slotKey(listTypeAvatar, 4)].Extra["equipment_lock_state"]; ok {
t.Fatalf("lock state should be cleared: %+v", loaded.Slots[slotKey(listTypeAvatar, 4)].Extra)
}
}
func TestHandlerUnlockCancelReturnsAckThenListDelta(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "27",
Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypePet, 2): {ItemID: 3003, Count: 1, Extra: map[string]string{"equipment_lock_state": lockStateActive}},
},
})
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketRequestItemUnlockCancel),
Body: []byte{listTypePet, 0x02, 0x00},
AccountID: "acc-lock",
SelectedCharacterID: 27,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || !got.ResponseAllowed || got.Operation != "request_item_unlock_cancel" {
t.Fatalf("result = %+v", got)
}
if len(got.UpperResponses) != 2 {
t.Fatalf("response count = %d, want 2", len(got.UpperResponses))
}
if ack := got.UpperResponses[0]; ack.MsgID != uint16(dnfenum.CmdPacketRequestItemUnlockCancel) || string(ack.Body) != string([]byte{listTypePet, 0x02, 0x00}) {
t.Fatalf("ack = %+v", ack)
}
if refresh := got.UpperResponses[1]; refresh.MsgID != msgItemLockList || string(refresh.Body) != string([]byte{1, 0, listTypePet, 0x02, 0x00, itemLockStateActive}) {
t.Fatalf("refresh = %+v", refresh)
}
}
func saveTestInventory(t *testing.T, ctx context.Context, repos dnfrepo.Group, record dnfrepo.InventoryRecord) {
t.Helper()
if err := repos.Inventory.Save(ctx, record); err != nil {
t.Fatalf("save inventory: %v", err)
}
}
func loadTestInventory(t *testing.T, ctx context.Context, repos dnfrepo.Group, characterID string) dnfrepo.InventoryRecord {
t.Helper()
record, ok, err := repos.Inventory.Load(ctx, characterID)
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if !ok {
t.Fatalf("inventory %s not found", characterID)
}
return record
}
@@ -0,0 +1,208 @@
package itemlock
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
listTypeMain byte = 0
listTypeAvatar byte = 1
listTypePersonalCargo byte = 2
listTypeEquipment byte = 3
listTypePet byte = 7
listTypeAccountCargo byte = 12
lockStateActive = "1"
)
var (
ErrOwnerUnavailable = errors.New("itemlock owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrInventoryNotFound = errors.New("inventory record not found")
ErrUnsupportedList = errors.New("itemlock list type is not supported")
ErrSlotNotFound = errors.New("itemlock slot not found")
ErrAlreadyLocked = errors.New("item is already locked")
ErrNotLocked = errors.New("item is not locked")
ErrEquipmentOwnerRequired = errors.New("equipment item lock requires equipment owner")
ErrAccountRequired = errors.New("authenticated account id required")
ErrAccountOwnerRequired = errors.New("account cargo item lock requires the account inventory owner")
)
type Owner struct {
repo dnfrepo.InventoryRepository
accountRepo dnfrepo.AccountInventoryRepository
accountItems dnfrepo.AccountCharacterItemUnitOfWork
}
type Result struct {
CharacterID string
ListType byte
SlotIndex int16
State string
Changed bool
}
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
if repos.Inventory == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{
repo: repos.Inventory,
accountRepo: repos.AccountInventory,
accountItems: repos.AccountItems,
}, nil
}
func (o *Owner) Apply(ctx context.Context, cmd Command) (Result, error) {
if o == nil || o.repo == nil {
return Result{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return Result{}, ErrCharacterRequired
}
if err := checkListType(cmd.ListType); err != nil {
return Result{}, err
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
record, ok, err := o.repo.Load(ctx, characterID)
if err != nil {
return Result{}, err
}
if !ok {
return Result{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
record.CharacterID = characterID
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
if record.Warehouse == nil {
record.Warehouse = make(map[string]dnfrepo.ItemStack)
}
items, field := itemMapForList(&record, cmd.ListType)
key := slotKey(cmd.ListType, cmd.SlotIndex)
stack, ok := items[key]
if !ok {
return Result{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.ListType, cmd.SlotIndex)
}
result := Result{
CharacterID: characterID,
ListType: cmd.ListType,
SlotIndex: cmd.SlotIndex,
State: currentLockState(stack),
}
switch cmd.Operation {
case "request_item_lock":
if isLocked(stack) {
return Result{}, fmt.Errorf("%w: list=%d slot=%d", ErrAlreadyLocked, cmd.ListType, cmd.SlotIndex)
}
stack = setLockState(stack, lockStateActive, cmd.ListType, cmd.SlotIndex)
result.State = lockStateActive
result.Changed = true
case "request_item_unlock":
if !isLocked(stack) {
return Result{}, fmt.Errorf("%w: list=%d slot=%d", ErrNotLocked, cmd.ListType, cmd.SlotIndex)
}
stack = clearLockState(stack)
result.State = ""
result.Changed = true
case "request_item_unlock_cancel":
if !isLocked(stack) {
return Result{}, fmt.Errorf("%w: list=%d slot=%d", ErrNotLocked, cmd.ListType, cmd.SlotIndex)
}
if currentLockState(stack) != lockStateActive {
stack = setLockState(stack, lockStateActive, cmd.ListType, cmd.SlotIndex)
result.Changed = true
}
result.State = lockStateActive
default:
return Result{}, fmt.Errorf("unsupported itemlock operation %q", cmd.Operation)
}
if !result.Changed {
return result, nil
}
items[key] = stack
record.UpdatedAt = time.Now()
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, field); err != nil {
return Result{}, err
}
return result, nil
}
func checkListType(listType byte) error {
switch listType {
case listTypeMain, listTypeAvatar, listTypePersonalCargo, listTypePet:
return nil
case listTypeEquipment:
return ErrEquipmentOwnerRequired
default:
return fmt.Errorf("%w: %d", ErrUnsupportedList, listType)
}
}
func itemMapForList(record *dnfrepo.InventoryRecord, listType byte) (map[string]dnfrepo.ItemStack, dnfrepo.InventoryField) {
if listType == listTypePersonalCargo {
return record.Warehouse, dnfrepo.InventoryFieldWarehouse
}
return record.Slots, dnfrepo.InventoryFieldSlots
}
func slotKey(listType byte, slotIndex int16) string {
return fmt.Sprintf("%d:%d", listType, slotIndex)
}
func isLocked(stack dnfrepo.ItemStack) bool {
switch strings.ToLower(strings.TrimSpace(currentLockState(stack))) {
case "1", "2", "active", "locked", "unlocking", "pending_unlock":
return true
default:
return false
}
}
func currentLockState(stack dnfrepo.ItemStack) string {
if stack.Extra == nil {
return ""
}
return strings.TrimSpace(stack.Extra["equipment_lock_state"])
}
func setLockState(stack dnfrepo.ItemStack, state string, listType byte, slotIndex int16) dnfrepo.ItemStack {
if stack.Extra == nil {
stack.Extra = make(map[string]string, 5)
}
stack.Extra["equipment_lock_state"] = state
stack.Extra["equipment_lock_kind"] = "equipment"
stack.Extra["equipment_lock_list_type"] = strconv.Itoa(int(listType))
stack.Extra["equipment_lock_slot"] = strconv.Itoa(int(slotIndex))
delete(stack.Extra, "equipment_lock_remaining_seconds")
return stack
}
func clearLockState(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
if stack.Extra == nil {
return stack
}
delete(stack.Extra, "equipment_lock_state")
delete(stack.Extra, "equipment_lock_kind")
delete(stack.Extra, "equipment_lock_list_type")
delete(stack.Extra, "equipment_lock_slot")
delete(stack.Extra, "equipment_lock_remaining_seconds")
if len(stack.Extra) == 0 {
stack.Extra = nil
}
return stack
}
@@ -0,0 +1,29 @@
// 本文件解析装备锁 C2S 请求体。
// 请求体来自 C# TryParseEquipmentItemLockRequest:u8 listType + i16 slotIndex。
package itemlock
import (
"encoding/binary"
"fmt"
)
// Request 描述装备锁定、解锁和取消解锁共用请求。
type Request struct {
ListType byte
SlotIndex int16
}
// DecodeRequest 解析装备锁请求。
func DecodeRequest(body []byte) (Request, error) {
if len(body) < 3 {
return Request{}, fmt.Errorf("body too short: got %d want >= 3", len(body))
}
return Request{
ListType: body[0],
SlotIndex: int16(binary.LittleEndian.Uint16(body[1:])),
}, nil
}
func (r Request) String() string {
return fmt.Sprintf("list=%d slot=%d", r.ListType, r.SlotIndex)
}
@@ -0,0 +1,103 @@
package itemlock
import (
"sort"
"strconv"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type lockSnapshotEntry struct {
listType byte
slotIndex int16
state byte
remainingSeconds int32
}
// BuildLockListSnapshot serializes the current selected-character lock state
// in the current client's op251 list format. An empty inventory or one with no
// locked rows deliberately produces the explicit empty-list sentinel 00 00.
func BuildLockListSnapshot(record dnfrepo.InventoryRecord) []byte {
entries := make([]lockSnapshotEntry, 0)
entries = appendLockSnapshotEntries(entries, record.Slots, listTypeMain)
entries = appendLockSnapshotEntries(entries, record.Slots, listTypeAvatar)
entries = appendLockSnapshotEntries(entries, record.Warehouse, listTypePersonalCargo)
entries = appendLockSnapshotEntries(entries, record.Slots, listTypePet)
sort.Slice(entries, func(left, right int) bool {
if entries[left].listType != entries[right].listType {
return entries[left].listType < entries[right].listType
}
return entries[left].slotIndex < entries[right].slotIndex
})
var writer packetWriter
writer.writeUint16(uint16(len(entries)))
for _, entry := range entries {
writer.writeByte(entry.listType)
writer.writeInt16(entry.slotIndex)
writer.writeByte(entry.state)
if entry.state == itemLockStatePending {
writer.writeInt32(entry.remainingSeconds)
}
}
return writer.bytes()
}
func appendLockSnapshotEntries(entries []lockSnapshotEntry, items map[string]dnfrepo.ItemStack, wantListType byte) []lockSnapshotEntry {
for key, stack := range items {
listType, slotIndex, ok := parseLockSnapshotSlotKey(key)
if !ok || listType != wantListType || stack.ItemID <= 0 || stack.Count <= 0 {
continue
}
state, remainingSeconds, locked := lockSnapshotState(stack)
if !locked {
continue
}
entries = append(entries, lockSnapshotEntry{
listType: listType,
slotIndex: slotIndex,
state: state,
remainingSeconds: remainingSeconds,
})
}
return entries
}
func parseLockSnapshotSlotKey(key string) (byte, int16, bool) {
listText, slotText, found := strings.Cut(strings.TrimSpace(key), ":")
if !found {
return 0, 0, false
}
listValue, err := strconv.ParseUint(strings.TrimSpace(listText), 10, 8)
if err != nil {
return 0, 0, false
}
slotValue, err := strconv.ParseInt(strings.TrimSpace(slotText), 10, 16)
if err != nil || slotValue < 0 {
return 0, 0, false
}
return byte(listValue), int16(slotValue), true
}
func lockSnapshotState(stack dnfrepo.ItemStack) (byte, int32, bool) {
switch strings.ToLower(strings.TrimSpace(currentLockState(stack))) {
case "1", "active", "locked":
return itemLockStateActive, 0, true
case "2", "unlocking", "pending_unlock":
return itemLockStatePending, lockSnapshotRemainingSeconds(stack), true
default:
return 0, 0, false
}
}
func lockSnapshotRemainingSeconds(stack dnfrepo.ItemStack) int32 {
if stack.Extra == nil {
return 0
}
seconds, err := strconv.ParseInt(strings.TrimSpace(stack.Extra["equipment_lock_remaining_seconds"]), 10, 32)
if err != nil || seconds < 0 {
return 0
}
return int32(seconds)
}
@@ -0,0 +1,53 @@
package itemlock
import (
"bytes"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestBuildLockListSnapshotUsesPersistedRowsInContainerOrder(t *testing.T) {
record := dnfrepo.InventoryRecord{
Slots: map[string]dnfrepo.ItemStack{
"0:8": {ItemID: 1001, Count: 1, Extra: map[string]string{"equipment_lock_state": "1"}},
"1:3": {ItemID: 1002, Count: 1, Extra: map[string]string{"equipment_lock_state": "active"}},
"7:9": {
ItemID: 1003,
Count: 1,
Extra: map[string]string{
"equipment_lock_state": "2",
"equipment_lock_remaining_seconds": "540",
},
},
"0:12": {ItemID: 1004, Count: 1},
},
Warehouse: map[string]dnfrepo.ItemStack{
"2:5": {ItemID: 2001, Count: 1, Extra: map[string]string{"equipment_lock_state": "locked"}},
},
}
got := BuildLockListSnapshot(record)
want := []byte{
4, 0,
0, 8, 0, itemLockStateActive,
1, 3, 0, itemLockStateActive,
2, 5, 0, itemLockStateActive,
7, 9, 0, itemLockStatePending, 0x1c, 0x02, 0, 0,
}
if !bytes.Equal(got, want) {
t.Fatalf("snapshot=%x want=%x", got, want)
}
}
func TestBuildLockListSnapshotWritesExplicitEmptyList(t *testing.T) {
got := BuildLockListSnapshot(dnfrepo.InventoryRecord{
Slots: map[string]dnfrepo.ItemStack{
"0:108": {ItemID: 490701734, Count: 36},
"0:112": {ItemID: 490007240, Count: 1},
},
})
if want := []byte{0, 0}; !bytes.Equal(got, want) {
t.Fatalf("snapshot=%x want=%x", got, want)
}
}
@@ -0,0 +1,207 @@
package itemlock
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
sortLockStateKey = "item_sort_lock_state"
sortLockStateActive = "1"
)
// SortLockPosition is one client-visible sorting lock at its current item slot.
type SortLockPosition struct {
ListType byte
SlotIndex int16
}
// DecodeSortLockRequest keeps the exact op713/op714 request separate from the
// response's leading success byte.
func DecodeSortLockRequest(body []byte) (Request, error) {
if len(body) != 3 {
return Request{}, fmt.Errorf("sort-lock body length: got %d want 3", len(body))
}
return DecodeRequest(body)
}
// BuildSortLockStateBody builds success + list + slot. The opcode selects lock
// or unlock; both successful operations use this current-client layout.
func BuildSortLockStateBody(listType byte, slotIndex int16) []byte {
return append([]byte{1}, buildLockAck(listType, slotIndex)...)
}
// ApplySortLock persists the sorting-only state without touching the ordinary
// equipment protection lock. Repeating either operation remains successful and
// idempotent because the EXE uses the ACK to converge its blue icon.
func (o *Owner) ApplySortLock(ctx context.Context, cmd Command, locked bool) (Result, error) {
if o == nil || o.repo == nil {
return Result{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return Result{}, ErrCharacterRequired
}
// 86JP IsSupportedLockListType 把 PersonalCargo / AccountCargo 都算进整理锁定
// (装备锁 IsSupportedEquipmentLockListType 只含 PersonalCargo,不含 AccountCargo)。
// 这里只放行本 owner 能正确落库的容器:list 0/1 在角色 Slots,list 2(个人金库)
// 在角色 Warehouse,list 12(账号金库)在 AccountInventory。Equipment / Pet /
// GuildMedal 继续 fail-close:它们的行不在这个 owner 持有的仓储里,放行只会变成
// "改错表"或"永远找不到行",两者都会让客户端拿不到 ACK。
if cmd.ListType != listTypeMain && cmd.ListType != listTypeAvatar &&
cmd.ListType != listTypePersonalCargo && cmd.ListType != listTypeAccountCargo {
return Result{}, fmt.Errorf("%w: %d", ErrUnsupportedList, cmd.ListType)
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
if cmd.ListType == listTypeAccountCargo {
// 账号金库的行不在角色 inventory 记录里,只能由账号 owner 翻转。
return o.applyAccountCargoSortLock(ctx, characterID, cmd, locked)
}
record, found, err := o.repo.Load(ctx, characterID)
if err != nil {
return Result{}, err
}
if !found {
return Result{}, ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
// 个人金库(list 2)的行在 Warehouse 而不是 Slots:取错表会一律判成空槽,
// 客户端于是拿不到 ACK(现场 2026-09-27 11:37 的 op713 list=2 slot=118)。
// 行所在的表与落库字段必须成对取自 itemMapForList,和装备锁 Apply 同一口径。
items, field := itemMapForList(&record, cmd.ListType)
key := slotKey(cmd.ListType, cmd.SlotIndex)
stack, found := items[key]
if !found || stack.ItemID <= 0 || stack.Count <= 0 {
return Result{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.ListType, cmd.SlotIndex)
}
wasLocked := IsSortLocked(stack)
result := Result{
CharacterID: characterID,
ListType: cmd.ListType,
SlotIndex: cmd.SlotIndex,
Changed: wasLocked != locked,
}
if locked {
result.State = sortLockStateActive
}
if !result.Changed {
return result, nil
}
if locked {
stack = setSortLockState(stack)
} else {
stack = clearSortLockState(stack)
}
items[key] = stack
record.UpdatedAt = time.Now()
if err := dnfrepo.SaveInventoryFields(ctx, o.repo, record, field); err != nil {
return Result{}, err
}
return result, nil
}
// IsSortLocked reports only the independent sorting lock state. Equipment
// protection locks deliberately use different Extra keys.
func IsSortLocked(stack dnfrepo.ItemStack) bool {
switch strings.ToLower(strings.TrimSpace(currentSortLockState(stack))) {
case sortLockStateActive, "locked", "active":
return true
default:
return false
}
}
func currentSortLockState(stack dnfrepo.ItemStack) string {
if stack.Extra == nil {
return ""
}
return strings.TrimSpace(stack.Extra[sortLockStateKey])
}
func setSortLockState(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
if stack.Extra == nil {
stack.Extra = make(map[string]string, 1)
}
stack.Extra[sortLockStateKey] = sortLockStateActive
return stack
}
// SetSortLockState 给物品行打上整理锁。供移动 / 合并路径把来源行已有的锁带到新落点:
// 整理锁挂在物品上,搬动与合并都不该让它消失(运营口径 2026-09-27)。
func SetSortLockState(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
return setSortLockState(stack)
}
// ClearSortLockState 清掉整理锁定标记,供移动物品时的 86JP PrepareForDestination 规则使用。
func ClearSortLockState(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
return clearSortLockState(stack)
}
func clearSortLockState(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
if stack.Extra == nil {
return stack
}
delete(stack.Extra, sortLockStateKey)
if len(stack.Extra) == 0 {
stack.Extra = nil
}
return stack
}
// SortLockPositions returns deterministic positions for native op713 replay
// after op13 container snapshots have rebuilt the client's item objects.
//
// 每个容器只认自己该有的 list:角色 Slots 是 list 0/1,Warehouse 是 list 2
// (个人金库)。Warehouse 里历史遗留的 12:* 残留**不能**重发 —— 账号金库唯一的
// durable owner 是 AccountInventory,它的位置由 AccountCargoSortLockPositions 提供。
// 漏掉个人金库的后果是:锁虽然落了库,但任何一次 op13 重建(过图 / 开包 / 整理 /
// 重上)都会让客户端的蓝锁消失,而服务端 op13/op14 物品行并不携带排序锁 flag,
// 客户端只能靠这里的重发得知锁状态。
func SortLockPositions(record dnfrepo.InventoryRecord) []SortLockPosition {
positions := make([]SortLockPosition, 0)
positions = appendSortLockPositions(positions, record.Slots, listTypeMain, listTypeAvatar)
positions = appendSortLockPositions(positions, record.Warehouse, listTypePersonalCargo)
sort.Slice(positions, func(left, right int) bool {
if positions[left].ListType != positions[right].ListType {
return positions[left].ListType < positions[right].ListType
}
return positions[left].SlotIndex < positions[right].SlotIndex
})
return positions
}
// appendSortLockPositions 只接受调用方点名的 list:同一个 key 可能因为历史迁移留在
// 别的表里(例如 Warehouse 的 12:* 残留),按表限定 list 才不会把它当成真的锁。
func appendSortLockPositions(
positions []SortLockPosition,
items map[string]dnfrepo.ItemStack,
allowedListTypes ...byte,
) []SortLockPosition {
for key, stack := range items {
listType, slotIndex, ok := parseLockSnapshotSlotKey(key)
if !ok || stack.ItemID <= 0 || stack.Count <= 0 || !IsSortLocked(stack) {
continue
}
if !containsListType(allowedListTypes, listType) {
continue
}
positions = append(positions, SortLockPosition{ListType: listType, SlotIndex: slotIndex})
}
return positions
}
func containsListType(allowedListTypes []byte, listType byte) bool {
for _, allowed := range allowedListTypes {
if allowed == listType {
return true
}
}
return false
}
@@ -0,0 +1,88 @@
// 本文件实现账号金库(list 12)的整理锁定。
// 86JP IsSupportedLockListType 把 AccountCargo 算在整理锁定内(装备锁不含它),
// 而 list 12 的行只存在 AccountInventory,因此必须由账号 owner 翻转标记。
package itemlock
import (
"context"
"fmt"
"sort"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func (o *Owner) applyAccountCargoSortLock(ctx context.Context, characterID string, cmd Command, locked bool) (Result, error) {
accountID := strings.TrimSpace(cmd.AccountID)
if accountID == "" {
return Result{}, ErrAccountRequired
}
if o == nil || o.accountRepo == nil || o.accountItems == nil {
return Result{}, ErrAccountOwnerRequired
}
var result Result
err := o.accountItems.WithinAccountCharacterItems(ctx, accountID, characterID, func(accounts dnfrepo.AccountInventoryRepository, _ dnfrepo.InventoryRepository) error {
cargo, found, err := accounts.Load(ctx, accountID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.ListType, cmd.SlotIndex)
}
cargo = dnfrepo.CloneAccountInventory(cargo)
cargo.AccountID = accountID
if cargo.Slots == nil {
cargo.Slots = make(map[string]dnfrepo.ItemStack)
}
key := slotKey(cmd.ListType, cmd.SlotIndex)
stack, ok := cargo.Slots[key]
if !ok || stack.ItemID <= 0 || stack.Count <= 0 {
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.ListType, cmd.SlotIndex)
}
wasLocked := IsSortLocked(stack)
result = Result{
CharacterID: characterID,
ListType: cmd.ListType,
SlotIndex: cmd.SlotIndex,
Changed: wasLocked != locked,
}
if locked {
result.State = sortLockStateActive
}
if !result.Changed {
return nil
}
if locked {
stack = setSortLockState(stack)
} else {
stack = clearSortLockState(stack)
}
cargo.Slots[key] = stack
cargo.UpdatedAt = time.Now()
return accounts.Save(ctx, cargo)
})
if err != nil {
return Result{}, err
}
return result, nil
}
// AccountCargoSortLockPositions 给出账号金库里仍然锁定的槽位,供 op13 重建 list 12 之后
// 重发 op713。账号金库的表格和角色容器一样会被重建,padlock 也必须跟着重发。
func AccountCargoSortLockPositions(record dnfrepo.AccountInventoryRecord) []SortLockPosition {
positions := make([]SortLockPosition, 0)
for key, stack := range record.Slots {
listType, slotIndex, ok := parseLockSnapshotSlotKey(key)
if !ok || listType != listTypeAccountCargo || stack.ItemID <= 0 || stack.Count <= 0 || !IsSortLocked(stack) {
continue
}
positions = append(positions, SortLockPosition{ListType: listType, SlotIndex: slotIndex})
}
sort.Slice(positions, func(left, right int) bool {
return positions[left].SlotIndex < positions[right].SlotIndex
})
return positions
}
@@ -0,0 +1,169 @@
package itemlock
import (
"context"
"errors"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func saveAccountCargoRow(t *testing.T, ctx context.Context, repos dnfrepo.Group, accountID string, slot int16, stack dnfrepo.ItemStack) {
t.Helper()
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: accountID,
Slots: map[string]dnfrepo.ItemStack{slotKey(listTypeAccountCargo, slot): stack},
}); err != nil {
t.Fatalf("save account cargo: %v", err)
}
}
// 86JP IsSupportedLockListType 把 AccountCargo 算进整理锁定;实况日志里
// list=12 的 op713 曾被 "itemlock list type is not supported: 12" 直接拒掉。
func TestApplySortLockTogglesAccountCargoRow(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77"}); err != nil {
t.Fatal(err)
}
saveAccountCargoRow(t, ctx, repos, "dnf:acc", 5, dnfrepo.ItemStack{ItemID: 300, Count: 1})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
cmd := Command{
Operation: "request_item_sort_lock",
AccountID: "dnf:acc",
SelectedCharacterID: 77,
ListType: listTypeAccountCargo,
SlotIndex: 5,
}
result, err := owner.ApplySortLock(ctx, cmd, true)
if err != nil {
t.Fatalf("ApplySortLock error = %v", err)
}
if result.State != sortLockStateActive || !result.Changed || result.ListType != listTypeAccountCargo || result.SlotIndex != 5 {
t.Fatalf("result = %+v", result)
}
cargo, found, err := repos.AccountInventory.Load(ctx, "dnf:acc")
if err != nil || !found {
t.Fatalf("load account cargo found=%t err=%v", found, err)
}
if !IsSortLocked(cargo.Slots[slotKey(listTypeAccountCargo, 5)]) {
t.Fatalf("account cargo row is not locked: %+v", cargo.Slots)
}
result, err = owner.ApplySortLock(ctx, cmd, false)
if err != nil {
t.Fatalf("ApplySortLock unlock error = %v", err)
}
if result.State != "" || !result.Changed {
t.Fatalf("unlock result = %+v", result)
}
cargo, _, err = repos.AccountInventory.Load(ctx, "dnf:acc")
if err != nil {
t.Fatal(err)
}
if IsSortLocked(cargo.Slots[slotKey(listTypeAccountCargo, 5)]) {
t.Fatalf("account cargo row stayed locked: %+v", cargo.Slots)
}
}
func TestApplySortLockAccountCargoRejectsEmptySlot(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77"}); err != nil {
t.Fatal(err)
}
saveAccountCargoRow(t, ctx, repos, "dnf:acc", 5, dnfrepo.ItemStack{ItemID: 300, Count: 1})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.ApplySortLock(ctx, Command{
Operation: "request_item_sort_lock",
AccountID: "dnf:acc",
SelectedCharacterID: 77,
ListType: listTypeAccountCargo,
SlotIndex: 6,
}, true)
if !errors.Is(err, ErrSlotNotFound) {
t.Fatalf("ApplySortLock error = %v, want ErrSlotNotFound", err)
}
}
// 装备锁按 86JP IsSupportedEquipmentLockListType 不覆盖账号金库,保持 fail-close。
func TestApplyEquipmentLockRejectsAccountCargo(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77"}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Apply(ctx, Command{
Operation: "request_item_lock",
AccountID: "dnf:acc",
SelectedCharacterID: 77,
ListType: listTypeAccountCargo,
SlotIndex: 5,
})
if !errors.Is(err, ErrUnsupportedList) {
t.Fatalf("Apply error = %v, want ErrUnsupportedList", err)
}
}
// 账号金库的锁定行必须进入重发位置表,否则容器重建后 padlock 不会回来。
func TestAccountCargoSortLockPositions(t *testing.T) {
record := dnfrepo.AccountInventoryRecord{
AccountID: "dnf:acc",
Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeAccountCargo, 5): {ItemID: 300, Count: 1, Extra: map[string]string{sortLockStateKey: "1"}},
slotKey(listTypeAccountCargo, 2): {ItemID: 301, Count: 1},
// 数量为零的残留行不重发。
slotKey(listTypeAccountCargo, 7): {ItemID: 302, Count: 0, Extra: map[string]string{sortLockStateKey: "1"}},
},
}
positions := AccountCargoSortLockPositions(record)
if len(positions) != 1 || positions[0] != (SortLockPosition{ListType: listTypeAccountCargo, SlotIndex: 5}) {
t.Fatalf("positions = %+v", positions)
}
}
// handler 侧:list 12 的 op713 现在必须回 class1 锁定包而不是被拒。
func TestHandlerSortLockAccountCargoAnswersLockPacket(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "77"}); err != nil {
t.Fatal(err)
}
saveAccountCargoRow(t, ctx, repos, "dnf:acc", 5, dnfrepo.ItemStack{ItemID: 300, Count: 1})
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketRequestItemSortLock),
AccountID: "dnf:acc",
SelectedCharacterID: 77,
Repositories: repos,
Body: []byte{listTypeAccountCargo, 5, 0},
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || !got.ResponseAllowed {
t.Fatalf("result = %+v", got)
}
if len(got.UpperResponses) != 1 || got.UpperResponses[0].MsgID != uint16(dnfenum.CmdPacketRequestItemSortLock) {
t.Fatalf("responses = %+v", got.UpperResponses)
}
want := BuildSortLockStateBody(listTypeAccountCargo, 5)
if string(got.UpperResponses[0].Body) != string(want) {
t.Fatalf("body = % X, want % X", got.UpperResponses[0].Body, want)
}
}
@@ -0,0 +1,210 @@
// 本文件覆盖个人金库(list 2)的整理锁定。
// 86JP IsSupportedLockListType 含 PersonalCargo,现场客户端也确实会在个人金库里发
// op713 list=2(2026-09-27 11:37:34 的 6 连拒),而 list 2 的行存在角色记录的
// Warehouse 而不是 Slots —— 取值表、落库字段、重发表三处都必须跟着容器走。
package itemlock
import (
"context"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// inventoryFieldRecorder 记录 SaveInventoryFields 真正传下去的字段。
// 内存仓储不实现 FieldStore,SaveFields 会退化成整条保存,所以只有字段本身能证明
// "个人金库的锁存进 warehouse、背包的锁存进 slots" —— 线上 mysqlBagStore.SaveFields
// 正是按这个值决定重建哪个集合(case InventoryFieldWarehouse 只重建 warehouse)。
type inventoryFieldRecorder struct {
inner dnfrepo.InventoryRepository
fields []dnfrepo.InventoryField
}
func (r *inventoryFieldRecorder) Load(ctx context.Context, key string) (dnfrepo.InventoryRecord, bool, error) {
return r.inner.Load(ctx, key)
}
func (r *inventoryFieldRecorder) Save(ctx context.Context, record dnfrepo.InventoryRecord) error {
return r.inner.Save(ctx, record)
}
func (r *inventoryFieldRecorder) SaveFields(ctx context.Context, record dnfrepo.InventoryRecord, fields ...dnfrepo.InventoryField) error {
r.fields = append(r.fields, fields...)
return r.inner.Save(ctx, record)
}
// TestHandlerSortLockFlipsPersonalCargoRowInWarehouse 复刻现场的原始请求体
// (list=2 slot=118):旧实现在这里回 "itemlock list type is not supported: 2",
// 不放 ACK,客户端上锁没有任何反应。
func TestHandlerSortLockFlipsPersonalCargoRowInWarehouse(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "27",
Warehouse: map[string]dnfrepo.ItemStack{
slotKey(listTypePersonalCargo, 118): {ItemID: 490701740, Count: 2},
},
})
// 个人金库是角色容器,故意不带 AccountID:走账号 owner 的分支只服务于 list 12。
locked, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketRequestItemSortLock),
Body: []byte{listTypePersonalCargo, 118, 0},
SelectedCharacterID: 27,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle sort lock error = %v", err)
}
assertSortLockResponse(t, locked, dnfenum.CmdPacketRequestItemSortLock, listTypePersonalCargo, 118)
record := loadTestInventory(t, ctx, repos, "27")
if !IsSortLocked(record.Warehouse[slotKey(listTypePersonalCargo, 118)]) {
t.Fatalf("个人金库的行没上锁: warehouse=%+v", record.Warehouse)
}
if len(record.Slots) != 0 {
t.Fatalf("锁写进了角色 Slots: %+v", record.Slots)
}
unlocked, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketRequestItemSortUnlock),
Body: []byte{listTypePersonalCargo, 118, 0},
SelectedCharacterID: 27,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle sort unlock error = %v", err)
}
assertSortLockResponse(t, unlocked, dnfenum.CmdPacketRequestItemSortUnlock, listTypePersonalCargo, 118)
if IsSortLocked(loadTestInventory(t, ctx, repos, "27").Warehouse[slotKey(listTypePersonalCargo, 118)]) {
t.Fatal("个人金库的行解锁后仍然锁着")
}
}
// TestApplySortLockPersonalCargoPicksWarehouseMapAndField 用同名残留行证明取值确实走了
// Warehouse(读 Slots 会把锁写到残留行上),并钉住落库字段是 warehouse 而不是 slots。
func TestApplySortLockPersonalCargoPicksWarehouseMapAndField(t *testing.T) {
ctx := context.Background()
base := dnfrepomemory.NewMemoryGroup()
if err := base.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "27",
Slots: map[string]dnfrepo.ItemStack{
// 同 key 的 Slots 残留:只有取错表才会命中它。
slotKey(listTypePersonalCargo, 118): {ItemID: 1, Count: 1},
},
Warehouse: map[string]dnfrepo.ItemStack{
slotKey(listTypePersonalCargo, 118): {ItemID: 490701740, Count: 2},
},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
recorder := &inventoryFieldRecorder{inner: base.Inventory}
owner, err := NewOwner(dnfrepo.Group{Inventory: recorder})
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
result, err := owner.ApplySortLock(ctx, Command{
Operation: "request_item_sort_lock",
SelectedCharacterID: 27,
ListType: listTypePersonalCargo,
SlotIndex: 118,
}, true)
if err != nil {
t.Fatalf("ApplySortLock error = %v", err)
}
if result.ListType != listTypePersonalCargo || result.SlotIndex != 118 || !result.Changed {
t.Fatalf("result = %+v", result)
}
if len(recorder.fields) != 1 || recorder.fields[0] != dnfrepo.InventoryFieldWarehouse {
t.Fatalf("落库字段 = %+v, want [warehouse]", recorder.fields)
}
loaded, found, err := base.Inventory.Load(ctx, "27")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if !IsSortLocked(loaded.Warehouse[slotKey(listTypePersonalCargo, 118)]) {
t.Fatalf("金库行没上锁: %+v", loaded.Warehouse)
}
if IsSortLocked(loaded.Slots[slotKey(listTypePersonalCargo, 118)]) {
t.Fatal("锁落到了 Slots 的同名残留行上")
}
}
// TestApplySortLockBagAndAvatarKeepSavingSlotsField 是"改动没有波及背包/装扮栏"的守卫:
// 这两个容器必须继续走 Slots 表与 slots 字段,取值与落库路径与改动前逐位一致。
func TestApplySortLockBagAndAvatarKeepSavingSlotsField(t *testing.T) {
ctx := context.Background()
for _, testCase := range []struct {
name string
listType byte
slot int16
}{
{name: "main bag", listType: listTypeMain, slot: 64},
{name: "avatar", listType: listTypeAvatar, slot: 30},
} {
t.Run(testCase.name, func(t *testing.T) {
base := dnfrepomemory.NewMemoryGroup()
if err := base.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "27",
Slots: map[string]dnfrepo.ItemStack{
slotKey(testCase.listType, testCase.slot): {ItemID: 490701734, Count: 1},
},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
recorder := &inventoryFieldRecorder{inner: base.Inventory}
owner, err := NewOwner(dnfrepo.Group{Inventory: recorder})
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
if _, err := owner.ApplySortLock(ctx, Command{
Operation: "request_item_sort_lock",
SelectedCharacterID: 27,
ListType: testCase.listType,
SlotIndex: testCase.slot,
}, true); err != nil {
t.Fatalf("ApplySortLock error = %v", err)
}
if len(recorder.fields) != 1 || recorder.fields[0] != dnfrepo.InventoryFieldSlots {
t.Fatalf("落库字段 = %+v, want [slots]", recorder.fields)
}
loaded, _, err := base.Inventory.Load(ctx, "27")
if err != nil {
t.Fatalf("load inventory: %v", err)
}
if !IsSortLocked(loaded.Slots[slotKey(testCase.listType, testCase.slot)]) {
t.Fatalf("list=%d slot=%d 没上锁", testCase.listType, testCase.slot)
}
})
}
}
// TestApplySortLockPersonalCargoRejectsEmptySlot:空槽仍然 fail-close,不放 ACK。
func TestApplySortLockPersonalCargoRejectsEmptySlot(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "27",
Warehouse: map[string]dnfrepo.ItemStack{
slotKey(listTypePersonalCargo, 118): {ItemID: 490701740, Count: 2},
},
})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
if _, err := owner.ApplySortLock(ctx, Command{
Operation: "request_item_sort_lock",
SelectedCharacterID: 27,
ListType: listTypePersonalCargo,
SlotIndex: 119,
}, true); err == nil {
t.Fatal("空槽上锁没有报错")
}
}
@@ -0,0 +1,151 @@
package itemlock
import (
"context"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestHandlerSortLockAndUnlockPersistIndependentState(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "27",
Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 64): {
ItemID: 1001,
Count: 1,
Extra: map[string]string{"equipment_lock_state": lockStateActive},
},
},
})
request := alignedcmd.Request{
Body: []byte{listTypeMain, 0x40, 0x00},
AccountID: "acc-lock",
SelectedCharacterID: 27,
Repositories: repos,
}
request.Opcode = uint16(dnfenum.CmdPacketRequestItemSortLock)
locked, err := NewHandler().Handle(ctx, request)
if err != nil {
t.Fatalf("sort lock: %v", err)
}
assertSortLockResponse(t, locked, dnfenum.CmdPacketRequestItemSortLock, listTypeMain, 0x40)
stack := loadTestInventory(t, ctx, repos, "27").Slots[slotKey(listTypeMain, 64)]
if !IsSortLocked(stack) || stack.Extra["equipment_lock_state"] != lockStateActive {
t.Fatalf("independent lock state=%+v", stack.Extra)
}
lockedAgain, err := NewHandler().Handle(ctx, request)
if err != nil {
t.Fatalf("sort lock repeat: %v", err)
}
assertSortLockResponse(t, lockedAgain, dnfenum.CmdPacketRequestItemSortLock, listTypeMain, 0x40)
request.Opcode = uint16(dnfenum.CmdPacketRequestItemSortUnlock)
unlocked, err := NewHandler().Handle(ctx, request)
if err != nil {
t.Fatalf("sort unlock: %v", err)
}
assertSortLockResponse(t, unlocked, dnfenum.CmdPacketRequestItemSortUnlock, listTypeMain, 0x40)
stack = loadTestInventory(t, ctx, repos, "27").Slots[slotKey(listTypeMain, 64)]
if IsSortLocked(stack) || stack.Extra["equipment_lock_state"] != lockStateActive {
t.Fatalf("sort unlock changed the wrong state: %+v", stack.Extra)
}
}
// 重发位置表按表限定 list:角色 Slots 只认 0/1,Warehouse 只认 2(个人金库)。
// 个人金库漏了重发 → 锁虽然落了库,每次 op13 重建后客户端的蓝锁都会消失;
// 而 Warehouse 里的 12:* 是历史残留(账号金库唯一 durable owner 是 AccountInventory),
// 反过来不能被当成真的锁重发。
func TestSortLockPositionsFollowCurrentItemSlots(t *testing.T) {
record := dnfrepo.InventoryRecord{
Slots: map[string]dnfrepo.ItemStack{
"1:7": {ItemID: 2001, Count: 1, Extra: map[string]string{sortLockStateKey: "1"}},
"0:64": {ItemID: 1001, Count: 1, Extra: map[string]string{sortLockStateKey: "1"}},
"0:20": {ItemID: 1002, Count: 1},
// 角色 Slots 里的 list 2 残留不属于个人金库,不参与重发。
"2:4": {ItemID: 1003, Count: 1, Extra: map[string]string{sortLockStateKey: "1"}},
},
Warehouse: map[string]dnfrepo.ItemStack{
"2:118": {ItemID: 490701740, Count: 2, Extra: map[string]string{sortLockStateKey: "1"}},
"2:9": {ItemID: 1004, Count: 1},
"12:3": {ItemID: 1005, Count: 1, Extra: map[string]string{sortLockStateKey: "1"}},
},
}
got := SortLockPositions(record)
want := []SortLockPosition{
{ListType: 0, SlotIndex: 64},
{ListType: 1, SlotIndex: 7},
{ListType: 2, SlotIndex: 118},
}
if len(got) != len(want) {
t.Fatalf("positions=%+v want=%+v", got, want)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("positions=%+v want=%+v", got, want)
}
}
}
// 这个用例原来拿 list 2 当"不支持的容器",等于把个人金库上不了锁这件事固化成了期望值。
// 现在真正的 fail-close 对象是"行不在本 owner 仓储里"的容器:装备栏(3)与宠物栏(7)。
func TestHandlerSortLockRejectsUnsupportedContainerAndMalformedBody(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "27",
Slots: map[string]dnfrepo.ItemStack{slotKey(listTypePet, 4): {ItemID: 1001, Count: 1}},
Warehouse: map[string]dnfrepo.ItemStack{slotKey(listTypePersonalCargo, 4): {ItemID: 1002, Count: 1}},
})
for _, body := range [][]byte{
{listTypeEquipment, 4, 0},
{listTypePet, 4, 0},
{listTypeMain, 4},
{listTypeMain, 4, 0, 0},
} {
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketRequestItemSortLock),
Body: body,
SelectedCharacterID: 27,
Repositories: repos,
})
if err != nil {
t.Fatalf("Handle body=%x error=%v", body, err)
}
if !got.Handled || got.ResponseAllowed || len(got.UpperResponses) != 0 {
t.Fatalf("body=%x result=%+v", body, got)
}
}
loaded := loadTestInventory(t, ctx, repos, "27")
if IsSortLocked(loaded.Slots[slotKey(listTypePet, 4)]) {
t.Fatal("unsupported sort lock mutated a pet item")
}
if IsSortLocked(loaded.Warehouse[slotKey(listTypePersonalCargo, 4)]) {
t.Fatal("unsupported sort lock mutated a personal cargo item")
}
}
// assertSortLockResponse 校验 class1 成功 ACK 的线格式:success + list + i16 slot。
// 体布局在这里写死而不是复用 BuildSortLockStateBody,否则生产者改错格式时断言会跟着一起错。
func assertSortLockResponse(t *testing.T, result alignedcmd.Result, opcode dnfenum.CmdPacket, listType byte, slotIndex int16) {
t.Helper()
if !result.Handled || !result.ResponseAllowed || len(result.UpperResponses) != 1 {
t.Fatalf("result=%+v", result)
}
response := result.UpperResponses[0]
if response.MsgID != uint16(opcode) || response.Classification != dnfproto.DefaultChannelClassification || !response.AllowCodec {
t.Fatalf("response=%+v", response)
}
want := []byte{1, listType, byte(slotIndex), byte(uint16(slotIndex) >> 8)}
if string(response.Body) != string(want) {
t.Fatalf("body=%x want=%x", response.Body, want)
}
}