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,91 @@
|
||||
package cargo
|
||||
|
||||
import "encoding/binary"
|
||||
|
||||
const (
|
||||
listTypeMain byte = 0
|
||||
msgItemListUpdate uint16 = 0x000E
|
||||
msgCeraUpdate uint16 = 0x0035
|
||||
|
||||
commonUpdateEntrySize = 0x77
|
||||
)
|
||||
|
||||
type packetWriter struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (w *packetWriter) writeByte(value byte) {
|
||||
w.data = append(w.data, value)
|
||||
}
|
||||
|
||||
func (w *packetWriter) writeZero(count int) {
|
||||
if count <= 0 {
|
||||
return
|
||||
}
|
||||
w.data = append(w.data, make([]byte, count)...)
|
||||
}
|
||||
|
||||
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 buildSuccessAck() []byte {
|
||||
return []byte{1}
|
||||
}
|
||||
|
||||
func buildCargoGoldAck(cargoGold int64) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeByte(1)
|
||||
writer.writeInt32(clampInt32(cargoGold))
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
func buildGoldUpdateBody(gold int64) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeByte(listTypeMain)
|
||||
writer.writeUint16(1)
|
||||
writeCommonUpdateEntry(&writer, 0, 0, clampInt32(gold))
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
func buildCeraUpdateBody(cera int64) []byte {
|
||||
var writer packetWriter
|
||||
writer.writeByte(1)
|
||||
writer.writeInt32(clampInt32(cera))
|
||||
writer.writeInt32(0)
|
||||
writer.writeInt32(0)
|
||||
return writer.bytes()
|
||||
}
|
||||
|
||||
func writeCommonUpdateEntry(writer *packetWriter, slot int16, itemID int32, countOrValue int32) {
|
||||
writer.writeInt16(slot)
|
||||
writer.writeInt32(itemID)
|
||||
writer.writeInt32(countOrValue)
|
||||
writer.writeZero(commonUpdateEntrySize - 2 - 4 - 4)
|
||||
}
|
||||
|
||||
func clampInt32(value int64) int32 {
|
||||
if value > 2147483647 {
|
||||
return 2147483647
|
||||
}
|
||||
if value < -2147483648 {
|
||||
return -2147483648
|
||||
}
|
||||
return int32(value)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// 本文件定义账号仓库命令计划。
|
||||
// 计划只记录协议解析后的业务意图,不直接修改金币、仓库或角色状态。
|
||||
package cargo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
)
|
||||
|
||||
// MoneyDirection 表示金币在角色钱包和账号仓库之间的流向。
|
||||
type MoneyDirection string
|
||||
|
||||
const (
|
||||
MoneyDeposit MoneyDirection = "deposit"
|
||||
MoneyWithdraw MoneyDirection = "withdraw"
|
||||
)
|
||||
|
||||
// Command 是 cargo handler 交给后续 owner 的最小命令计划。
|
||||
// 金币属于关键资产;在 wallet/account-cargo owner 和幂等事务落地前,该计划不能直接变成成功回包。
|
||||
type Command struct {
|
||||
Operation string
|
||||
AccountID string
|
||||
SelectedCharacterID uint16
|
||||
MoneyDirection MoneyDirection
|
||||
Amount int64
|
||||
NeedsOwner string
|
||||
}
|
||||
|
||||
// NewAccountCommand 构造账号仓库创建或升级命令计划。
|
||||
// 当前只用于日志和后续 owner 接入点,不写仓库扩展状态。
|
||||
func NewAccountCommand(req alignedcmd.Request, operation string) Command {
|
||||
return Command{
|
||||
Operation: operation,
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
NeedsOwner: "account-cargo owner + durable idempotency",
|
||||
}
|
||||
}
|
||||
|
||||
// NewMoneyCommand 构造金币存取命令计划。
|
||||
// 它只消费已经解析出的金额,真正扣减/增加必须由可靠 owner 在一个事务语义中完成。
|
||||
func NewMoneyCommand(req alignedcmd.Request, direction MoneyDirection, money GoldRequest) Command {
|
||||
return Command{
|
||||
Operation: string(direction) + "_money",
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
MoneyDirection: direction,
|
||||
Amount: int64(money.Amount),
|
||||
NeedsOwner: "wallet owner + account-cargo owner + mutation id",
|
||||
}
|
||||
}
|
||||
|
||||
func (c Command) String() string {
|
||||
if c.Amount > 0 {
|
||||
return fmt.Sprintf("account=%q char=%d direction=%s amount=%d needs=%s", c.AccountID, c.SelectedCharacterID, c.MoneyDirection, c.Amount, c.NeedsOwner)
|
||||
}
|
||||
return fmt.Sprintf("account=%q char=%d needs=%s", c.AccountID, c.SelectedCharacterID, c.NeedsOwner)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package cargo 承接 DNF 账号仓库创建、升级和金币存取类 C2S 协议。
|
||||
package cargo
|
||||
@@ -0,0 +1,216 @@
|
||||
package cargo
|
||||
|
||||
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.AlignedDomainCargo
|
||||
}
|
||||
|
||||
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
switch dnfenum.CmdPacket(req.Opcode) {
|
||||
case dnfenum.CmdPacketCreateAccountCargo:
|
||||
cmd := NewAccountCommand(req, "create_account_cargo")
|
||||
return handleAccountPlan(ctx, req, cmd)
|
||||
case dnfenum.CmdPacketUpgradeAccountCargo:
|
||||
cmd := NewAccountCommand(req, "upgrade_account_cargo")
|
||||
return handleAccountPlan(ctx, req, cmd)
|
||||
case dnfenum.CmdPacketDepositMoney:
|
||||
parsed, err := DecodeGoldRequest(req.Body)
|
||||
cmd := NewMoneyCommand(req, MoneyDeposit, parsed)
|
||||
return handleMoneyPlan(ctx, req, cmd, err)
|
||||
case dnfenum.CmdPacketWithdrawMoney:
|
||||
parsed, err := DecodeGoldRequest(req.Body)
|
||||
cmd := NewMoneyCommand(req, MoneyWithdraw, parsed)
|
||||
return handleMoneyPlan(ctx, req, cmd, err)
|
||||
default:
|
||||
return alignedcmd.Result{
|
||||
Handled: false,
|
||||
ResponseAllowed: false,
|
||||
Operation: "unregistered",
|
||||
Reason: fmt.Sprintf("cargo module has no opcode %d handler", req.Opcode),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleAccountPlan(ctx context.Context, req alignedcmd.Request, cmd Command) (alignedcmd.Result, error) {
|
||||
owner, err := NewOwner(req.Repositories)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
|
||||
}
|
||||
result, err := owner.ApplyAccount(ctx, cmd.Operation, AccountCommand{
|
||||
AccountID: cmd.AccountID,
|
||||
SelectedCharacterID: cmd.SelectedCharacterID,
|
||||
})
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
|
||||
}
|
||||
return ownerAppliedResult(req.Opcode, cmd.Operation, cmd.String(), result), nil
|
||||
}
|
||||
|
||||
func handleMoneyPlan(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) (alignedcmd.Result, error) {
|
||||
if parseErr != nil {
|
||||
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr), nil
|
||||
}
|
||||
owner, err := NewOwner(req.Repositories)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
|
||||
}
|
||||
result, err := owner.ApplyMoney(ctx, MoneyCommand{
|
||||
AccountID: cmd.AccountID,
|
||||
SelectedCharacterID: cmd.SelectedCharacterID,
|
||||
Direction: cmd.MoneyDirection,
|
||||
Amount: cmd.Amount,
|
||||
})
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
|
||||
}
|
||||
return ownerAppliedResult(req.Opcode, cmd.Operation, cmd.String(), result), nil
|
||||
}
|
||||
|
||||
func blockedResult(operation string, summary string) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf("parsed %s: %s; success ACK is blocked until cargo asset writes are closed", operation, summary),
|
||||
}
|
||||
}
|
||||
|
||||
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 blockedResult(operation, summary)
|
||||
}
|
||||
|
||||
func ownerBlockedResult(operation string, summary string, err error) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf("parsed %s: %s; cargo owner preflight failed: %v; success ACK is blocked", operation, summary, err),
|
||||
}
|
||||
}
|
||||
|
||||
func ownerAppliedResult(opcode uint16, operation string, summary string, result PlanResult) alignedcmd.Result {
|
||||
responses := cargoUpperResponses(opcode, operation, result)
|
||||
postActions := make([]alignedcmd.PostAction, 0, 1)
|
||||
if operation == "create_account_cargo" || operation == "upgrade_account_cargo" {
|
||||
// The list-12 body belongs to the bridge because it is decoded by the
|
||||
// current EXE's item-list reader. Do not send the former empty/replayed
|
||||
// cargo body here: it lost account-owned rows and mixed owner state.
|
||||
//
|
||||
// 开通/升级都改变了金库容量,必须先重建仓库底板再发列表:当前 EXE 的面板格子
|
||||
// 是建面板时按容量铺的,只更新列表数据不会把锁去掉(现场 2026-09-18:扩容后
|
||||
// 格子还画着锁、实际能放东西,切一下仓库页才正常)。
|
||||
postActions = append(postActions, alignedcmd.PostActionResetCargoPadBeforeItemLists)
|
||||
if result.Cost.Kind == CostMaterial {
|
||||
// The material is a real list-0 stack. Refresh the character-owned
|
||||
// containers after its durable transaction as well as list 12.
|
||||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedItemContainers)
|
||||
}
|
||||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedAccountCargo)
|
||||
}
|
||||
out := alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: len(responses) > 0,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf(
|
||||
"parsed %s: %s; cargo owner applied account=%s char=%s direction=%s amount=%d charGold=%d charCera=%d cargoGold=%d cargoLevel=%d cargoCreated=%t cost=%s/%d changed=%t; C# order response opened",
|
||||
operation,
|
||||
summary,
|
||||
result.AccountID,
|
||||
result.CharacterID,
|
||||
result.Direction,
|
||||
result.Amount,
|
||||
result.CharacterGold,
|
||||
result.CharacterCera,
|
||||
result.CargoGold,
|
||||
result.CargoLevel,
|
||||
result.CargoCreated,
|
||||
result.Cost.Kind,
|
||||
result.Cost.Amount,
|
||||
result.Changed,
|
||||
),
|
||||
UpperResponses: responses,
|
||||
PostActions: postActions,
|
||||
}
|
||||
// 金库创建/升级用点券付款时,这就是一次"点券消费"。模块自己知道花了多少、
|
||||
// 花在什么上,所以在这里上报给 bridge 记账(点券消费记录)。
|
||||
// CharacterCera 是事务提交后的权威余额,扣费失败时 Changed=false 不会走到这里。
|
||||
if result.Changed && result.Cost.Kind == CostCera && result.Cost.Amount > 0 {
|
||||
out.CeraSpent = result.Cost.Amount
|
||||
out.CeraAfter = result.CharacterCera
|
||||
out.CeraSpentNote = cargoCeraSpentNote(operation, result)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cargoCeraSpentNote 给点券消费记录写一句人话,说明这笔钱花在哪个金库阶段。
|
||||
func cargoCeraSpentNote(operation string, result PlanResult) string {
|
||||
switch operation {
|
||||
case "create_account_cargo":
|
||||
return fmt.Sprintf("金库创建(开通账号金库,升级到 %d 阶段)", result.CargoLevel)
|
||||
case "upgrade_account_cargo":
|
||||
return fmt.Sprintf("金库升级(账号金库升到 %d 阶段)", result.CargoLevel)
|
||||
default:
|
||||
return operation
|
||||
}
|
||||
}
|
||||
|
||||
func cargoUpperResponses(opcode uint16, operation string, result PlanResult) []alignedcmd.UpperResponse {
|
||||
switch operation {
|
||||
case "deposit_money", "withdraw_money":
|
||||
return []alignedcmd.UpperResponse{
|
||||
class1Response(opcode, buildCargoGoldAck(result.CargoGold)),
|
||||
class0Response(msgItemListUpdate, buildGoldUpdateBody(result.CharacterGold)),
|
||||
}
|
||||
case "create_account_cargo", "upgrade_account_cargo":
|
||||
responses := []alignedcmd.UpperResponse{class1Response(opcode, buildSuccessAck())}
|
||||
switch result.Cost.Kind {
|
||||
case CostGold:
|
||||
responses = append(responses, class0Response(msgItemListUpdate, buildGoldUpdateBody(result.CharacterGold)))
|
||||
case CostCera:
|
||||
responses = append(responses, class0Response(msgCeraUpdate, buildCeraUpdateBody(result.CharacterCera)))
|
||||
}
|
||||
return responses
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package cargo
|
||||
|
||||
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 TestDecodeGoldRequest(t *testing.T) {
|
||||
got, err := DecodeGoldRequest([]byte{0x40, 0x42, 0x0F, 0x00})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeGoldRequest error = %v", err)
|
||||
}
|
||||
if got.Amount != 1000000 {
|
||||
t.Fatalf("amount = %d, want 1000000", got.Amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerBlocksCargoSuccessAck(t *testing.T) {
|
||||
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketDepositMoney),
|
||||
Body: []byte{0x01, 0x00, 0x00, 0x00},
|
||||
AccountID: " acc-1 ",
|
||||
SelectedCharacterID: 12,
|
||||
})
|
||||
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 != "deposit_money" {
|
||||
t.Fatalf("operation = %q", got.Operation)
|
||||
}
|
||||
if !strings.Contains(got.Reason, `account="acc-1"`) || !strings.Contains(got.Reason, "char=12") || !strings.Contains(got.Reason, "amount=1") {
|
||||
t.Fatalf("reason should include command plan, got %q", got.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerDepositUsesOwnerAndReturnsAckThenGoldRefresh(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "acc-5",
|
||||
Metadata: map[string]string{
|
||||
"account_cargo_gold": "400",
|
||||
"account_cargo_level": "1",
|
||||
"account_cargo_created": "true",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "90",
|
||||
AccountID: "acc-5",
|
||||
Stats: map[string]int64{"gold": 800},
|
||||
}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketDepositMoney),
|
||||
Body: []byte{0x2C, 0x01, 0x00, 0x00},
|
||||
AccountID: "acc-5",
|
||||
SelectedCharacterID: 90,
|
||||
Repositories: repos,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed {
|
||||
t.Fatalf("result = %+v, want handled with response", got)
|
||||
}
|
||||
if !strings.Contains(got.Reason, "cargo owner applied") || !strings.Contains(got.Reason, "charGold=500") || !strings.Contains(got.Reason, "cargoGold=700") {
|
||||
t.Fatalf("reason should include owner mutation, got %q", got.Reason)
|
||||
}
|
||||
if len(got.UpperResponses) != 2 {
|
||||
t.Fatalf("response count = %d, want 2", len(got.UpperResponses))
|
||||
}
|
||||
ack := got.UpperResponses[0]
|
||||
if ack.MsgID != uint16(dnfenum.CmdPacketDepositMoney) || ack.Classification != dnfproto.DefaultChannelClassification || !ack.AllowCodec {
|
||||
t.Fatalf("ack = %+v", ack)
|
||||
}
|
||||
if want := []byte{1, 0xBC, 0x02, 0x00, 0x00}; string(ack.Body) != string(want) {
|
||||
t.Fatalf("ack body = % X, want % X", ack.Body, want)
|
||||
}
|
||||
refresh := got.UpperResponses[1]
|
||||
if refresh.MsgID != msgItemListUpdate || refresh.Classification != 0 || !refresh.AllowCodec {
|
||||
t.Fatalf("refresh = %+v", refresh)
|
||||
}
|
||||
if len(refresh.Body) != 3+commonUpdateEntrySize || refresh.Body[0] != 0 || refresh.Body[1] != 1 || refresh.Body[2] != 0 {
|
||||
t.Fatalf("gold refresh header/len = len=%d body=% X", len(refresh.Body), refresh.Body[:min(len(refresh.Body), 12)])
|
||||
}
|
||||
if refresh.Body[9] != 0xF4 || refresh.Body[10] != 0x01 {
|
||||
t.Fatalf("gold refresh value bytes = % X, want 500", refresh.Body[9:13])
|
||||
}
|
||||
account, ok, err := repos.Account.Load(ctx, "acc-5")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load account ok=%t err=%v", ok, err)
|
||||
}
|
||||
if got := account.Metadata["account_cargo_gold"]; got != "700" {
|
||||
t.Fatalf("cargo gold = %q, want 700", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerCreateCargoUsesOwnerAndReturnsAckCostAndCurrentCargoPostAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc-6"}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "91",
|
||||
AccountID: "acc-6",
|
||||
Stats: map[string]int64{"gold": 500000},
|
||||
}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketCreateAccountCargo),
|
||||
AccountID: " acc-6 ",
|
||||
SelectedCharacterID: 91,
|
||||
Repositories: repos,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed {
|
||||
t.Fatalf("result = %+v, want handled with response", got)
|
||||
}
|
||||
if !strings.Contains(got.Reason, "cargo owner applied") || !strings.Contains(got.Reason, "cargoLevel=1") || !strings.Contains(got.Reason, "cargoCreated=true") {
|
||||
t.Fatalf("reason should include account mutation, got %q", got.Reason)
|
||||
}
|
||||
if len(got.UpperResponses) != 2 {
|
||||
t.Fatalf("response count = %d, want 2", len(got.UpperResponses))
|
||||
}
|
||||
if ack := got.UpperResponses[0]; ack.MsgID != uint16(dnfenum.CmdPacketCreateAccountCargo) || string(ack.Body) != string([]byte{1}) || ack.Classification != dnfproto.DefaultChannelClassification {
|
||||
t.Fatalf("ack = %+v", ack)
|
||||
}
|
||||
if gold := got.UpperResponses[1]; gold.MsgID != msgItemListUpdate || gold.Classification != 0 || len(gold.Body) != 3+commonUpdateEntrySize {
|
||||
t.Fatalf("gold refresh = %+v len=%d", gold, len(gold.Body))
|
||||
}
|
||||
if len(got.PostActions) != 2 ||
|
||||
got.PostActions[0] != alignedcmd.PostActionResetCargoPadBeforeItemLists ||
|
||||
got.PostActions[1] != alignedcmd.PostActionRefreshSelectedAccountCargo {
|
||||
t.Fatalf("post actions = %+v, want cargo-pad reset then current account cargo refresh", got.PostActions)
|
||||
}
|
||||
character, ok, err := repos.Character.Load(ctx, "91")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load character ok=%t err=%v", ok, err)
|
||||
}
|
||||
if got := character.Stats["gold"]; got != 400000 {
|
||||
t.Fatalf("character gold = %d, want 400000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func min(a int, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestMoneyCommandRecordsOwnerGap(t *testing.T) {
|
||||
cmd := NewMoneyCommand(alignedcmd.Request{
|
||||
AccountID: " acc-2 ",
|
||||
SelectedCharacterID: 99,
|
||||
}, MoneyWithdraw, GoldRequest{Amount: 500})
|
||||
if cmd.AccountID != "acc-2" || cmd.SelectedCharacterID != 99 || cmd.Amount != 500 || cmd.MoneyDirection != MoneyWithdraw {
|
||||
t.Fatalf("cmd = %+v", cmd)
|
||||
}
|
||||
if !strings.Contains(cmd.String(), "mutation id") {
|
||||
t.Fatalf("command plan must name reliable-write gap: %s", cmd.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package cargo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("cargo owner unavailable")
|
||||
ErrAccountRequired = errors.New("account id required")
|
||||
ErrCharacterRequired = errors.New("selected character id required")
|
||||
ErrAccountNotFound = errors.New("account record not found")
|
||||
ErrCharacterNotFound = errors.New("character record not found")
|
||||
ErrGoldInsufficient = errors.New("gold is insufficient")
|
||||
ErrCargoExists = errors.New("account cargo already exists")
|
||||
ErrCargoNotCreated = errors.New("account cargo is not created")
|
||||
ErrCargoMaxLevel = errors.New("account cargo is already max level")
|
||||
ErrCargoCostMissing = errors.New("account cargo upgrade cost owner is missing")
|
||||
ErrCargoMaterialInsufficient = errors.New("account cargo upgrade material is insufficient")
|
||||
// ErrCargoUpgradeToolRequired 表示该档没有金库窗口自己的费用,只能买商城的
|
||||
// 帐号金库升级工具。op306 必须失败关闭,否则新接上的高档位会变成免费升级。
|
||||
ErrCargoUpgradeToolRequired = errors.New("account cargo tier requires the cera shop upgrade tool")
|
||||
)
|
||||
|
||||
const accountCargoCreateGoldCost int64 = 100000
|
||||
const accountCargoVoidMagicStoneItemID int64 = 3299
|
||||
const accountCargoVoidMagicStoneCost int64 = 250
|
||||
|
||||
// accountCargoTiers 是账号金库的完整容量阶梯,一档 8 格。
|
||||
//
|
||||
// 2026-08-25 现网证据(旧树 runtime/logs/packet_log.txt):客户端按顺序消耗 PVF
|
||||
// 里的 12 个点券工具 stackable/cash/chn_account_cargo/account_cargo1..12.stk
|
||||
// (cerashop.etc 商品 100534 / 102045 / 102108 / 102316 / 102659 /
|
||||
// 103665-103671),每个工具一档 8 格;1->8、16->24、24->32 三档不用工具,走
|
||||
// op306 的金币和无色小晶块。观测到的 8->16(tool1)、32->40(tool2)、
|
||||
// 40->48(tool3)、48->56(tool4)、56->64(tool5) 全部成功落库,容量 64 时客户端
|
||||
// 请求 tool6 也就是 64->72。12 个工具因此把阶梯接到 120 格;此前表只写到 64,
|
||||
// tool6 被 errCurrentCeraShopProductUnavailable 拒绝,客户端把失败码 4 显示成
|
||||
// "物品栏空间不足"。
|
||||
var accountCargoTiers = []int64{1, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120}
|
||||
|
||||
type AccountCommand struct {
|
||||
AccountID string
|
||||
SelectedCharacterID uint16
|
||||
}
|
||||
|
||||
type MoneyCommand struct {
|
||||
AccountID string
|
||||
SelectedCharacterID uint16
|
||||
Direction MoneyDirection
|
||||
Amount int64
|
||||
}
|
||||
|
||||
type CostKind string
|
||||
|
||||
const (
|
||||
CostNone CostKind = ""
|
||||
CostGold CostKind = "gold"
|
||||
CostCera CostKind = "cera"
|
||||
CostMaterial CostKind = "material"
|
||||
)
|
||||
|
||||
type CostResult struct {
|
||||
Kind CostKind
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// PlanResult is a read-only validation result; it does not mean cargo state
|
||||
// or gold has been changed.
|
||||
type PlanResult struct {
|
||||
AccountID string
|
||||
CharacterID string
|
||||
Operation string
|
||||
Direction MoneyDirection
|
||||
Amount int64
|
||||
CharacterGold int64
|
||||
CharacterCera int64
|
||||
CargoGold int64
|
||||
CargoLevel int64
|
||||
CargoCreated bool
|
||||
Cost CostResult
|
||||
Changed bool
|
||||
}
|
||||
|
||||
type Owner struct {
|
||||
accounts dnfrepo.AccountRepository
|
||||
characters dnfrepo.CharacterRepository
|
||||
assets dnfrepo.RentalAssetUnitOfWork
|
||||
}
|
||||
|
||||
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
|
||||
if repos.Account == nil || repos.Character == nil || repos.RentalAssets == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{accounts: repos.Account, characters: repos.Character, assets: repos.RentalAssets}, nil
|
||||
}
|
||||
|
||||
func (o *Owner) PlanAccount(ctx context.Context, operation string, cmd AccountCommand) (PlanResult, error) {
|
||||
account, err := o.loadAccount(ctx, cmd.AccountID)
|
||||
if err != nil {
|
||||
return PlanResult{}, err
|
||||
}
|
||||
return PlanResult{
|
||||
AccountID: account.AccountID,
|
||||
Operation: operation,
|
||||
CargoGold: metadataInt(account.Metadata, "account_cargo_gold"),
|
||||
CargoLevel: metadataInt(account.Metadata, "account_cargo_level"),
|
||||
CargoCreated: metadataBool(account.Metadata, "account_cargo_created"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApplyAccount 写入账号金库创建/扩展状态,并在需要金币费用时同步扣角色金币。
|
||||
// 当前还没有统一 wallet/account-cargo 事务,保存账号失败时会补偿回滚角色金币,成功 ACK 仍由 handler 阻断。
|
||||
func (o *Owner) ApplyAccount(ctx context.Context, operation string, cmd AccountCommand) (PlanResult, error) {
|
||||
if o == nil || o.assets == nil {
|
||||
return PlanResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
accountID := strings.TrimSpace(cmd.AccountID)
|
||||
if accountID == "" {
|
||||
return PlanResult{}, ErrAccountRequired
|
||||
}
|
||||
if cmd.SelectedCharacterID == 0 {
|
||||
return PlanResult{}, ErrCharacterRequired
|
||||
}
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
var result PlanResult
|
||||
err := o.assets.WithinRentalAssets(ctx, accountID, characterID, func(accounts dnfrepo.AccountRepository, characters dnfrepo.CharacterRepository, inventoryRepo dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
|
||||
account, found, err := accounts.Load(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrAccountNotFound
|
||||
}
|
||||
character, found, err := characters.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrCharacterNotFound
|
||||
}
|
||||
if owner := strings.TrimSpace(character.AccountID); owner != "" && owner != accountID {
|
||||
return ErrAccountRequired
|
||||
}
|
||||
account = ensureAccountMetadata(dnfrepo.CloneAccount(account))
|
||||
character = ensureCharacterStats(dnfrepo.CloneCharacter(character), characterID)
|
||||
var cost CostResult
|
||||
inventoryDirty := false
|
||||
switch operation {
|
||||
case "create_account_cargo":
|
||||
if metadataBool(account.Metadata, "account_cargo_created") || metadataInt(account.Metadata, "account_cargo_level") > 0 {
|
||||
return ErrCargoExists
|
||||
}
|
||||
if character.Stats["gold"] < accountCargoCreateGoldCost {
|
||||
return fmt.Errorf("%w: character=%d amount=%d", ErrGoldInsufficient, character.Stats["gold"], accountCargoCreateGoldCost)
|
||||
}
|
||||
character.Stats["gold"] -= accountCargoCreateGoldCost
|
||||
cost = CostResult{Kind: CostGold, Amount: accountCargoCreateGoldCost}
|
||||
account.Metadata["account_cargo_created"] = "true"
|
||||
account.Metadata["account_cargo_level"] = strconv.FormatInt(accountCargoTiers[0], 10)
|
||||
if strings.TrimSpace(account.Metadata["account_cargo_gold"]) == "" {
|
||||
account.Metadata["account_cargo_gold"] = "0"
|
||||
}
|
||||
case "upgrade_account_cargo":
|
||||
previous := metadataInt(account.Metadata, "account_cargo_level")
|
||||
next, ok := nextCargoTier(previous)
|
||||
if previous <= 0 {
|
||||
return ErrCargoNotCreated
|
||||
}
|
||||
if !ok {
|
||||
return ErrCargoMaxLevel
|
||||
}
|
||||
var inventory dnfrepo.InventoryRecord
|
||||
if previous == 16 || previous == 24 {
|
||||
inventory, found, err = inventoryRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrCargoCostMissing
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
}
|
||||
cost, inventoryDirty, err = applyCargoUpgradeCost(&character, &account, &inventory, previous)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inventoryDirty {
|
||||
inventory.CharacterID = characterID
|
||||
inventory.UpdatedAt = time.Now().UTC()
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
account.Metadata["account_cargo_created"] = "true"
|
||||
account.Metadata["account_cargo_level"] = strconv.FormatInt(next, 10)
|
||||
default:
|
||||
return fmt.Errorf("operation %q invalid", operation)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
character.UpdatedAt = now
|
||||
account.UpdatedAt = now
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := accounts.Save(ctx, account); err != nil {
|
||||
return err
|
||||
}
|
||||
result = PlanResult{AccountID: account.AccountID, CharacterID: characterID, Operation: operation, CharacterGold: character.Stats["gold"], CharacterCera: metadataInt(account.Metadata, "account_cera"), CargoGold: metadataInt(account.Metadata, "account_cargo_gold"), CargoLevel: metadataInt(account.Metadata, "account_cargo_level"), CargoCreated: metadataBool(account.Metadata, "account_cargo_created"), Cost: cost, Changed: true}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return PlanResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (o *Owner) PlanMoney(ctx context.Context, cmd MoneyCommand) (PlanResult, error) {
|
||||
if cmd.Amount <= 0 {
|
||||
return PlanResult{}, fmt.Errorf("amount %d invalid", cmd.Amount)
|
||||
}
|
||||
account, err := o.loadAccount(ctx, cmd.AccountID)
|
||||
if err != nil {
|
||||
return PlanResult{}, err
|
||||
}
|
||||
characterID, character, err := o.loadCharacter(ctx, cmd.SelectedCharacterID)
|
||||
if err != nil {
|
||||
return PlanResult{}, err
|
||||
}
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
characterGold := character.Stats["gold"]
|
||||
cargoGold := metadataInt(account.Metadata, "account_cargo_gold")
|
||||
switch cmd.Direction {
|
||||
case MoneyDeposit:
|
||||
if characterGold < cmd.Amount {
|
||||
return PlanResult{}, fmt.Errorf("%w: character=%d amount=%d", ErrGoldInsufficient, characterGold, cmd.Amount)
|
||||
}
|
||||
case MoneyWithdraw:
|
||||
if cargoGold < cmd.Amount {
|
||||
return PlanResult{}, fmt.Errorf("%w: cargo=%d amount=%d", ErrGoldInsufficient, cargoGold, cmd.Amount)
|
||||
}
|
||||
default:
|
||||
return PlanResult{}, fmt.Errorf("direction %q invalid", cmd.Direction)
|
||||
}
|
||||
return PlanResult{
|
||||
AccountID: account.AccountID,
|
||||
CharacterID: characterID,
|
||||
Operation: string(cmd.Direction) + "_money",
|
||||
Direction: cmd.Direction,
|
||||
Amount: cmd.Amount,
|
||||
CharacterGold: characterGold,
|
||||
CharacterCera: metadataInt(account.Metadata, "account_cera"),
|
||||
CargoGold: cargoGold,
|
||||
CargoLevel: metadataInt(account.Metadata, "account_cargo_level"),
|
||||
CargoCreated: metadataBool(account.Metadata, "account_cargo_created"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApplyMoney 同步写入角色金币和账号金库金币。
|
||||
// 这是账号金库 305-308 链路的最小可靠 owner;客户端刷新包顺序未闭合前,handler 仍然阻断成功 ACK。
|
||||
func (o *Owner) ApplyMoney(ctx context.Context, cmd MoneyCommand) (PlanResult, error) {
|
||||
if cmd.Amount <= 0 {
|
||||
return PlanResult{}, fmt.Errorf("amount %d invalid", cmd.Amount)
|
||||
}
|
||||
if o == nil || o.assets == nil {
|
||||
return PlanResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
accountID := strings.TrimSpace(cmd.AccountID)
|
||||
if accountID == "" {
|
||||
return PlanResult{}, ErrAccountRequired
|
||||
}
|
||||
if cmd.SelectedCharacterID == 0 {
|
||||
return PlanResult{}, ErrCharacterRequired
|
||||
}
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
var result PlanResult
|
||||
err := o.assets.WithinRentalAssets(ctx, accountID, characterID, func(accounts dnfrepo.AccountRepository, characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
|
||||
account, found, err := accounts.Load(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrAccountNotFound
|
||||
}
|
||||
character, found, err := characters.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrCharacterNotFound
|
||||
}
|
||||
if owner := strings.TrimSpace(character.AccountID); owner != "" && owner != accountID {
|
||||
return ErrAccountRequired
|
||||
}
|
||||
account = ensureAccountMetadata(dnfrepo.CloneAccount(account))
|
||||
character = ensureCharacterStats(dnfrepo.CloneCharacter(character), characterID)
|
||||
characterGold := character.Stats["gold"]
|
||||
cargoGold := metadataInt(account.Metadata, "account_cargo_gold")
|
||||
if !metadataBool(account.Metadata, "account_cargo_created") && metadataInt(account.Metadata, "account_cargo_level") <= 0 {
|
||||
return ErrCargoNotCreated
|
||||
}
|
||||
switch cmd.Direction {
|
||||
case MoneyDeposit:
|
||||
if characterGold < cmd.Amount {
|
||||
return fmt.Errorf("%w: character=%d amount=%d", ErrGoldInsufficient, characterGold, cmd.Amount)
|
||||
}
|
||||
characterGold -= cmd.Amount
|
||||
cargoGold += cmd.Amount
|
||||
case MoneyWithdraw:
|
||||
if cargoGold < cmd.Amount {
|
||||
return fmt.Errorf("%w: cargo=%d amount=%d", ErrGoldInsufficient, cargoGold, cmd.Amount)
|
||||
}
|
||||
characterGold += cmd.Amount
|
||||
cargoGold -= cmd.Amount
|
||||
default:
|
||||
return fmt.Errorf("direction %q invalid", cmd.Direction)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
character.Stats["gold"] = characterGold
|
||||
character.UpdatedAt = now
|
||||
account.Metadata["account_cargo_gold"] = strconv.FormatInt(cargoGold, 10)
|
||||
account.Metadata["account_cargo_created"] = "true"
|
||||
account.UpdatedAt = now
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := accounts.Save(ctx, account); err != nil {
|
||||
return err
|
||||
}
|
||||
result = PlanResult{AccountID: account.AccountID, CharacterID: characterID, Operation: string(cmd.Direction) + "_money", Direction: cmd.Direction, Amount: cmd.Amount, CharacterGold: characterGold, CharacterCera: metadataInt(account.Metadata, "account_cera"), CargoGold: cargoGold, CargoLevel: metadataInt(account.Metadata, "account_cargo_level"), CargoCreated: metadataBool(account.Metadata, "account_cargo_created"), Changed: true}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return PlanResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (o *Owner) loadAccount(ctx context.Context, accountID string) (dnfrepo.AccountRecord, error) {
|
||||
if o == nil || o.accounts == nil {
|
||||
return dnfrepo.AccountRecord{}, ErrOwnerUnavailable
|
||||
}
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
return dnfrepo.AccountRecord{}, ErrAccountRequired
|
||||
}
|
||||
account, ok, err := o.accounts.Load(ctx, accountID)
|
||||
if err != nil {
|
||||
return dnfrepo.AccountRecord{}, err
|
||||
}
|
||||
if !ok {
|
||||
return dnfrepo.AccountRecord{}, ErrAccountNotFound
|
||||
}
|
||||
return dnfrepo.CloneAccount(account), nil
|
||||
}
|
||||
|
||||
func (o *Owner) loadCharacter(ctx context.Context, selectedCharacterID uint16) (string, dnfrepo.CharacterRecord, error) {
|
||||
if o == nil || o.characters == nil {
|
||||
return "", dnfrepo.CharacterRecord{}, ErrOwnerUnavailable
|
||||
}
|
||||
if selectedCharacterID == 0 {
|
||||
return "", dnfrepo.CharacterRecord{}, ErrCharacterRequired
|
||||
}
|
||||
characterID := strconv.FormatUint(uint64(selectedCharacterID), 10)
|
||||
character, ok, err := o.characters.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return "", dnfrepo.CharacterRecord{}, err
|
||||
}
|
||||
if !ok {
|
||||
return "", dnfrepo.CharacterRecord{}, ErrCharacterNotFound
|
||||
}
|
||||
return characterID, dnfrepo.CloneCharacter(character), nil
|
||||
}
|
||||
|
||||
func ensureAccountMetadata(account dnfrepo.AccountRecord) dnfrepo.AccountRecord {
|
||||
if account.Metadata == nil {
|
||||
account.Metadata = make(map[string]string, 4)
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
func ensureCharacterStats(character dnfrepo.CharacterRecord, characterID string) dnfrepo.CharacterRecord {
|
||||
character.CharacterID = characterID
|
||||
if character.Stats == nil {
|
||||
character.Stats = make(map[string]int64, 1)
|
||||
}
|
||||
return character
|
||||
}
|
||||
|
||||
func nextCargoTier(previous int64) (int64, bool) {
|
||||
for index, tier := range accountCargoTiers {
|
||||
if tier == previous && index+1 < len(accountCargoTiers) {
|
||||
return accountCargoTiers[index+1], true
|
||||
}
|
||||
}
|
||||
return previous, false
|
||||
}
|
||||
|
||||
// NextAccountCargoTier is shared by the normal account-cargo command and the
|
||||
// PVF-resolved Cera-shop upgrade tool. Keeping one progression prevents the
|
||||
// shop from silently skipping a normal client-visible tier.
|
||||
func NextAccountCargoTier(previous int64) (int64, bool) {
|
||||
return nextCargoTier(previous)
|
||||
}
|
||||
|
||||
func applyCargoUpgradeCost(character *dnfrepo.CharacterRecord, account *dnfrepo.AccountRecord, inventory *dnfrepo.InventoryRecord, previousTier int64) (CostResult, bool, error) {
|
||||
switch previousTier {
|
||||
case 1:
|
||||
const cost int64 = 2000000
|
||||
if character.Stats["gold"] < cost {
|
||||
return CostResult{}, false, fmt.Errorf("%w: character=%d amount=%d", ErrGoldInsufficient, character.Stats["gold"], cost)
|
||||
}
|
||||
character.Stats["gold"] -= cost
|
||||
return CostResult{Kind: CostGold, Amount: cost}, false, nil
|
||||
case 8, 32, 40, 48, 56:
|
||||
cost := map[int64]int64{8: 2000, 32: 2000, 40: 2500, 48: 3000, 56: 5000}[previousTier]
|
||||
balance := metadataInt(account.Metadata, "account_cera")
|
||||
if balance < cost {
|
||||
return CostResult{}, false, fmt.Errorf("%w: cera=%d amount=%d", ErrGoldInsufficient, balance, cost)
|
||||
}
|
||||
account.Metadata["account_cera"] = strconv.FormatInt(balance-cost, 10)
|
||||
return CostResult{Kind: CostCera, Amount: cost}, false, nil
|
||||
case 16, 24:
|
||||
if inventory == nil || inventory.Slots == nil {
|
||||
return CostResult{}, false, fmt.Errorf("%w: void magic stone inventory missing for tier=%d", ErrCargoCostMissing, previousTier)
|
||||
}
|
||||
remaining := accountCargoVoidMagicStoneCost
|
||||
keys := make([]string, 0)
|
||||
for key, stack := range inventory.Slots {
|
||||
if strings.HasPrefix(key, "0:") && stack.ItemID == accountCargoVoidMagicStoneItemID && stack.Count > 0 {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
stack := inventory.Slots[key]
|
||||
if stack.Count >= remaining {
|
||||
remaining = 0
|
||||
break
|
||||
}
|
||||
remaining -= stack.Count
|
||||
}
|
||||
if remaining > 0 {
|
||||
return CostResult{}, false, fmt.Errorf("%w: item=%d need=%d", ErrCargoMaterialInsufficient, accountCargoVoidMagicStoneItemID, accountCargoVoidMagicStoneCost)
|
||||
}
|
||||
remaining = accountCargoVoidMagicStoneCost
|
||||
for _, key := range keys {
|
||||
stack := inventory.Slots[key]
|
||||
consume := stack.Count
|
||||
if consume > remaining {
|
||||
consume = remaining
|
||||
}
|
||||
stack.Count -= consume
|
||||
remaining -= consume
|
||||
if stack.Count == 0 {
|
||||
delete(inventory.Slots, key)
|
||||
} else {
|
||||
inventory.Slots[key] = stack
|
||||
}
|
||||
if remaining == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return CostResult{Kind: CostMaterial, Amount: accountCargoVoidMagicStoneCost}, true, nil
|
||||
default:
|
||||
// 64 格以上只有商城工具,金库窗口按钮没有可扣的费用。这里必须失败关闭:
|
||||
// 返回零费用会让 op306 白送一档。
|
||||
return CostResult{}, false, fmt.Errorf("%w: tier=%d", ErrCargoUpgradeToolRequired, previousTier)
|
||||
}
|
||||
}
|
||||
|
||||
func metadataInt(metadata map[string]string, key string) int64 {
|
||||
raw := strings.TrimSpace(metadata[key])
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func metadataBool(metadata map[string]string, key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(metadata[key])) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package cargo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestOwnerPlanAccountReadsCargoMetadata(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "acc-1",
|
||||
Metadata: map[string]string{
|
||||
"account_cargo_gold": "1200",
|
||||
"account_cargo_level": "3",
|
||||
"account_cargo_created": "1",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner() error = %v", err)
|
||||
}
|
||||
got, err := owner.PlanAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: " acc-1 "})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanAccount() error = %v", err)
|
||||
}
|
||||
if got.AccountID != "acc-1" || got.CargoGold != 1200 || got.CargoLevel != 3 || !got.CargoCreated {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerPlanMoneyDepositChecksCharacterGold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "acc-2",
|
||||
Metadata: map[string]string{"account_cargo_gold": "50"},
|
||||
}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "77",
|
||||
AccountID: "acc-2",
|
||||
Stats: map[string]int64{"gold": 500},
|
||||
}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner() error = %v", err)
|
||||
}
|
||||
got, err := owner.PlanMoney(ctx, MoneyCommand{
|
||||
AccountID: "acc-2",
|
||||
SelectedCharacterID: 77,
|
||||
Direction: MoneyDeposit,
|
||||
Amount: 300,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanMoney() error = %v", err)
|
||||
}
|
||||
if got.CharacterGold != 500 || got.CargoGold != 50 || got.Amount != 300 || got.Direction != MoneyDeposit {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerPlanMoneyWithdrawChecksCargoGold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "acc-3",
|
||||
Metadata: map[string]string{"account_cargo_gold": "900"},
|
||||
}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "78",
|
||||
AccountID: "acc-3",
|
||||
Stats: map[string]int64{"gold": 20},
|
||||
}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner() error = %v", err)
|
||||
}
|
||||
got, err := owner.PlanMoney(ctx, MoneyCommand{
|
||||
AccountID: "acc-3",
|
||||
SelectedCharacterID: 78,
|
||||
Direction: MoneyWithdraw,
|
||||
Amount: 700,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanMoney() error = %v", err)
|
||||
}
|
||||
if got.CharacterGold != 20 || got.CargoGold != 900 || got.Amount != 700 || got.Direction != MoneyWithdraw {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerPlanMoneyRejectsInsufficientGold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "acc-4",
|
||||
Metadata: map[string]string{"account_cargo_gold": "5"},
|
||||
}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "79",
|
||||
AccountID: "acc-4",
|
||||
Stats: map[string]int64{"gold": 10},
|
||||
}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner() error = %v", err)
|
||||
}
|
||||
_, err = owner.PlanMoney(ctx, MoneyCommand{
|
||||
AccountID: "acc-4",
|
||||
SelectedCharacterID: 79,
|
||||
Direction: MoneyWithdraw,
|
||||
Amount: 6,
|
||||
})
|
||||
if !errors.Is(err, ErrGoldInsufficient) {
|
||||
t.Fatalf("PlanMoney() error = %v, want ErrGoldInsufficient", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerApplyCreateCargoChargesGold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc-5"}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "80",
|
||||
AccountID: "acc-5",
|
||||
Stats: map[string]int64{"gold": 500000},
|
||||
}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner() error = %v", err)
|
||||
}
|
||||
got, err := owner.ApplyAccount(ctx, "create_account_cargo", AccountCommand{
|
||||
AccountID: "acc-5",
|
||||
SelectedCharacterID: 80,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyAccount() error = %v", err)
|
||||
}
|
||||
if !got.Changed || got.CharacterGold != 400000 || got.CargoLevel != 1 || !got.CargoCreated {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
|
||||
account, ok, err := repos.Account.Load(ctx, "acc-5")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load account ok=%t err=%v", ok, err)
|
||||
}
|
||||
if account.Metadata["account_cargo_level"] != "1" || account.Metadata["account_cargo_created"] != "true" {
|
||||
t.Fatalf("account metadata = %+v", account.Metadata)
|
||||
}
|
||||
character, ok, err := repos.Character.Load(ctx, "80")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load character ok=%t err=%v", ok, err)
|
||||
}
|
||||
if got := character.Stats["gold"]; got != 400000 {
|
||||
t.Fatalf("character gold = %d, want 400000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerApplyAccountUpgradeDebitsAccountSharedCera(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "acc-cera",
|
||||
Metadata: map[string]string{
|
||||
"account_cargo_created": "true",
|
||||
"account_cargo_level": "8",
|
||||
"account_cargo_gold": "0",
|
||||
"account_cera": "3000",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "87", AccountID: "acc-cera", Stats: map[string]int64{"gold": 1}}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner() error = %v", err)
|
||||
}
|
||||
got, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-cera", SelectedCharacterID: 87})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyAccount() error = %v", err)
|
||||
}
|
||||
if !got.Changed || got.Cost.Kind != CostCera || got.Cost.Amount != 2000 || got.CharacterCera != 1000 || got.CargoLevel != 16 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
account, ok, err := repos.Account.Load(ctx, "acc-cera")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load account ok=%t err=%v", ok, err)
|
||||
}
|
||||
if account.Metadata["account_cera"] != "1000" || account.Metadata["account_cargo_level"] != "16" {
|
||||
t.Fatalf("account metadata = %+v", account.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerApplyAccountUpgradeRejectsInsufficientAccountCera(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "acc-cera-poor",
|
||||
Metadata: map[string]string{
|
||||
"account_cargo_created": "true",
|
||||
"account_cargo_level": "8",
|
||||
"account_cargo_gold": "0",
|
||||
"account_cera": "100",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "88", AccountID: "acc-cera-poor", Stats: map[string]int64{"gold": 1}}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner() error = %v", err)
|
||||
}
|
||||
if _, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-cera-poor", SelectedCharacterID: 88}); !errors.Is(err, ErrGoldInsufficient) {
|
||||
t.Fatalf("ApplyAccount() error = %v, want ErrGoldInsufficient", err)
|
||||
}
|
||||
account, _, _ := repos.Account.Load(ctx, "acc-cera-poor")
|
||||
if account.Metadata["account_cera"] != "100" || account.Metadata["account_cargo_level"] != "8" {
|
||||
t.Fatalf("insufficient-cera attempt mutated account: %+v", account.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerApplyMoneyDepositPersistsBothSides(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "acc-6",
|
||||
Metadata: map[string]string{
|
||||
"account_cargo_gold": "50",
|
||||
"account_cargo_level": "1",
|
||||
"account_cargo_created": "true",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "81",
|
||||
AccountID: "acc-6",
|
||||
Stats: map[string]int64{"gold": 500},
|
||||
}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner() error = %v", err)
|
||||
}
|
||||
got, err := owner.ApplyMoney(ctx, MoneyCommand{
|
||||
AccountID: "acc-6",
|
||||
SelectedCharacterID: 81,
|
||||
Direction: MoneyDeposit,
|
||||
Amount: 300,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyMoney() error = %v", err)
|
||||
}
|
||||
if !got.Changed || got.CharacterGold != 200 || got.CargoGold != 350 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
|
||||
account, ok, err := repos.Account.Load(ctx, "acc-6")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load account ok=%t err=%v", ok, err)
|
||||
}
|
||||
if got := account.Metadata["account_cargo_gold"]; got != "350" {
|
||||
t.Fatalf("cargo gold = %q, want 350", got)
|
||||
}
|
||||
character, ok, err := repos.Character.Load(ctx, "81")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load character ok=%t err=%v", ok, err)
|
||||
}
|
||||
if got := character.Stats["gold"]; got != 200 {
|
||||
t.Fatalf("character gold = %d, want 200", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerApplyAccountUpgradeConsumesVoidMagicStoneForMiddleTiers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "acc-void",
|
||||
Metadata: map[string]string{
|
||||
"account_cargo_created": "true",
|
||||
"account_cargo_level": "16",
|
||||
"account_cargo_gold": "0",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "86", AccountID: "acc-void", Stats: map[string]int64{"gold": 1, "cera": 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "86", Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:10": {ItemID: accountCargoVoidMagicStoneItemID, Count: 200},
|
||||
"0:11": {ItemID: accountCargoVoidMagicStoneItemID, Count: 100},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-void", SelectedCharacterID: 86})
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade account cargo: %v", err)
|
||||
}
|
||||
if !got.Changed || got.CargoLevel != 24 || got.Cost.Kind != CostMaterial || got.Cost.Amount != 250 {
|
||||
t.Fatalf("upgrade result=%+v", got)
|
||||
}
|
||||
items, found, err := repos.Inventory.Load(ctx, "86")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load inventory found=%t err=%v", found, err)
|
||||
}
|
||||
if _, exists := items.Slots["0:10"]; exists {
|
||||
t.Fatalf("first material stack not consumed: %+v", items.Slots)
|
||||
}
|
||||
if got := items.Slots["0:11"].Count; got != 50 {
|
||||
t.Fatalf("remaining void magic stones=%d want=50", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerApplyMoneyWithdrawPersistsBothSides(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "acc-7",
|
||||
Metadata: map[string]string{
|
||||
"account_cargo_gold": "900",
|
||||
"account_cargo_level": "1",
|
||||
"account_cargo_created": "true",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "82",
|
||||
AccountID: "acc-7",
|
||||
Stats: map[string]int64{"gold": 20},
|
||||
}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner() error = %v", err)
|
||||
}
|
||||
got, err := owner.ApplyMoney(ctx, MoneyCommand{
|
||||
AccountID: "acc-7",
|
||||
SelectedCharacterID: 82,
|
||||
Direction: MoneyWithdraw,
|
||||
Amount: 700,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyMoney() error = %v", err)
|
||||
}
|
||||
if !got.Changed || got.CharacterGold != 720 || got.CargoGold != 200 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextAccountCargoTierCoversEveryCeraShopTool 锁住完整阶梯。2026-08-25 现网
|
||||
// 抓包证明客户端按顺序消耗 12 个点券工具,容量 64 时请求 tool6(64->72);阶梯
|
||||
// 只写到 64 时那次购买被拒,客户端显示"物品栏空间不足"。
|
||||
func TestNextAccountCargoTierCoversEveryCeraShopTool(t *testing.T) {
|
||||
want := []int64{1, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120}
|
||||
tier := want[0]
|
||||
for _, expected := range want[1:] {
|
||||
next, ok := NextAccountCargoTier(tier)
|
||||
if !ok || next != expected {
|
||||
t.Fatalf("NextAccountCargoTier(%d) = %d, %t; want %d, true", tier, next, ok, expected)
|
||||
}
|
||||
tier = next
|
||||
}
|
||||
if next, ok := NextAccountCargoTier(tier); ok {
|
||||
t.Fatalf("NextAccountCargoTier(%d) = %d, true; want max level", tier, next)
|
||||
}
|
||||
if next, ok := NextAccountCargoTier(5); ok {
|
||||
t.Fatalf("NextAccountCargoTier(5) = %d, true; want an unknown tier to fail closed", next)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerApplyAccountUpgradeRequiresToolAboveMaterialTiers 保证新接上的高档位
|
||||
// 不会被金库窗口按钮(op306)白送——那些档只有商城工具能付费。
|
||||
func TestOwnerApplyAccountUpgradeRequiresToolAboveMaterialTiers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
|
||||
AccountID: "acc-tool",
|
||||
Metadata: map[string]string{
|
||||
"account_cargo_created": "true",
|
||||
"account_cargo_level": "64",
|
||||
"account_cargo_gold": "0",
|
||||
"account_cera": "9000000",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save account: %v", err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "89", AccountID: "acc-tool", Stats: map[string]int64{"gold": 900000000}}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner() error = %v", err)
|
||||
}
|
||||
if _, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-tool", SelectedCharacterID: 89}); !errors.Is(err, ErrCargoUpgradeToolRequired) {
|
||||
t.Fatalf("ApplyAccount() error = %v, want ErrCargoUpgradeToolRequired", err)
|
||||
}
|
||||
account, ok, err := repos.Account.Load(ctx, "acc-tool")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load account ok=%t err=%v", ok, err)
|
||||
}
|
||||
if account.Metadata["account_cargo_level"] != "64" || account.Metadata["account_cera"] != "9000000" {
|
||||
t.Fatalf("blocked op306 upgrade mutated account: %+v", account.Metadata)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// 本文件解析账号仓库类 C2S 请求体。
|
||||
// 解析结果只用于生成后续 owner 命令计划,不直接修改金币或仓库状态。
|
||||
package cargo
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GoldRequest 描述仓库存取金币请求。
|
||||
type GoldRequest struct {
|
||||
Amount int32
|
||||
}
|
||||
|
||||
// DecodeGoldRequest 解析存入/取出金币请求,C# 证据为 body[0:4] little-endian int32。
|
||||
func DecodeGoldRequest(body []byte) (GoldRequest, error) {
|
||||
if len(body) < 4 {
|
||||
return GoldRequest{}, fmt.Errorf("body too short: got %d want >= 4", len(body))
|
||||
}
|
||||
req := GoldRequest{Amount: int32(binary.LittleEndian.Uint32(body))}
|
||||
if req.Amount <= 0 {
|
||||
return GoldRequest{}, fmt.Errorf("amount %d invalid", req.Amount)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (r GoldRequest) String() string {
|
||||
return fmt.Sprintf("amount=%d", r.Amount)
|
||||
}
|
||||
Reference in new issue
Block a user