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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user