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

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

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

No files matched your search

@@ -0,0 +1,304 @@
package cerashop
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
accountCeraMetadataKey = "account_cera"
accountTokenCeraMetadataKey = "account_token_cera"
accountHappyTokenCeraMetadataKey = "account_happy_token_cera"
)
var (
ErrOwnerUnavailable = errors.New("cera shop owner is unavailable")
ErrAccountNotFound = errors.New("cera shop account is not found")
ErrCharacterNotFound = errors.New("cera shop character is not found")
ErrInventoryNotFound = errors.New("cera shop inventory is not found")
ErrAccountMismatch = errors.New("cera shop character account mismatch")
ErrCeraInsufficient = errors.New("cera shop cera balance is insufficient")
ErrProjectorRequired = errors.New("cera shop checkout projector is required")
)
type CheckoutChanges struct {
Character bool
Inventory bool
Equipment bool
Settings bool
}
type CheckoutAssets struct {
Account *dnfrepo.AccountRecord
Character *dnfrepo.CharacterRecord
Inventory *dnfrepo.InventoryRecord
Equipment *dnfrepo.EquipmentRecord
Settings *dnfrepo.SettingsRecord
EquipmentFound bool
SettingsFound bool
}
type CheckoutProjector func(*CheckoutAssets) (CheckoutChanges, error)
type CheckoutCommand struct {
AccountID string
CharacterID string
SettingsScope string
Cost int64
UpdatedAt time.Time
// PayPolicy 决定这笔钱从哪些钱包扣。
//
// **零值 = PayPolicyCeraOnly = 只扣点券 = 改动前的行为**,所以既有调用方一行都
// 不用改,也不会因为漏填这个字段而悄悄换一种扣费方式。
PayPolicy PayPolicy
Project CheckoutProjector
}
type CheckoutResult struct {
CeraBefore int64
CeraAfter int64
TokenCera int64
HappyTokenCera int64
// Paid 是本次结账各钱包实际扣掉的数量。
// 用于日志核对,也是"代币券真的能花掉"这条验收的凭据。
Paid WalletPayment
}
// Wallet is the complete account-owned Cera projection consumed by the
// current client's class0/op53 update packet.
type Wallet struct {
Cera int64
TokenCera int64
HappyTokenCera int64
}
type Owner struct {
assets dnfrepo.CeraShopAssetUnitOfWork
}
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
if repositories.CeraShopAssets == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{assets: repositories.CeraShopAssets}, nil
}
func (o *Owner) Checkout(ctx context.Context, command CheckoutCommand) (CheckoutResult, error) {
accountID := strings.TrimSpace(command.AccountID)
characterID := strings.TrimSpace(command.CharacterID)
settingsScope := strings.TrimSpace(command.SettingsScope)
if o == nil || o.assets == nil || accountID == "" || characterID == "" || settingsScope == "" || command.Cost < 0 {
return CheckoutResult{}, ErrOwnerUnavailable
}
if command.Project == nil {
return CheckoutResult{}, ErrProjectorRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return CheckoutResult{}, err
}
now := command.UpdatedAt.UTC()
if now.IsZero() {
now = time.Now().UTC()
}
var result CheckoutResult
err := o.assets.WithinCeraShopAssets(
ctx,
accountID,
characterID,
settingsScope,
func(
accounts dnfrepo.AccountRepository,
_ dnfrepo.AccountInventoryRepository,
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
equipment dnfrepo.EquipmentRepository,
settings dnfrepo.SettingsRepository,
) error {
if accounts == nil || characters == nil || inventories == nil || equipment == nil || settings == nil {
return ErrOwnerUnavailable
}
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 strings.TrimSpace(character.AccountID) != accountID {
return ErrAccountMismatch
}
inventory, found, err := inventories.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
return ErrInventoryNotFound
}
equipmentRecord, equipmentFound, err := equipment.Load(ctx, characterID)
if err != nil {
return err
}
if !equipmentFound {
equipmentRecord = dnfrepo.EquipmentRecord{CharacterID: characterID}
}
settingsRecord, settingsFound, err := settings.Load(ctx, settingsScope)
if err != nil {
return err
}
if !settingsFound {
settingsRecord = dnfrepo.SettingsRecord{Scope: settingsScope}
}
account = dnfrepo.CloneAccount(account)
character = dnfrepo.CloneCharacter(character)
inventory = dnfrepo.CloneInventory(inventory)
equipmentRecord = dnfrepo.CloneEquipment(equipmentRecord)
settingsRecord = dnfrepo.CloneSettings(settingsRecord)
ceraBefore := Balance(account)
// 按策略算扣费方案:默认(零值)就是"只扣点券",与改动前完全一致。
// 瀑布策略下代币券 / 欢乐代币券会先被花掉,点券只补差额。
payment, planErr := PlanWalletPayment(WalletBalance(account), command.Cost, command.PayPolicy)
if planErr != nil {
if errors.Is(planErr, ErrWalletInsufficient) && command.PayPolicy == PayPolicyCeraOnly {
// 保持既有错误口径:只用点券时仍然报 ErrCeraInsufficient,
// 免得改了错误类型却没人更新调用方的判定。
return fmt.Errorf("%w: balance=%d cost=%d", ErrCeraInsufficient, ceraBefore, command.Cost)
}
return planErr
}
ceraAfter := ceraBefore - payment.FromCera
ApplyWalletPayment(&account, payment)
assets := &CheckoutAssets{
Account: &account,
Character: &character,
Inventory: &inventory,
Equipment: &equipmentRecord,
Settings: &settingsRecord,
EquipmentFound: equipmentFound,
SettingsFound: settingsFound,
}
changes, err := command.Project(assets)
if err != nil {
return err
}
account.UpdatedAt = now
if err := accounts.Save(ctx, account); err != nil {
return err
}
if changes.Character {
character.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
}
if changes.Inventory {
inventory.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
if changes.Equipment {
equipmentRecord.UpdatedAt = now
if err := dnfrepo.SaveEquipmentFields(ctx, equipment, equipmentRecord, dnfrepo.EquipmentFieldEntries); err != nil {
return err
}
}
if changes.Settings {
settingsRecord.UpdatedAt = now
if err := dnfrepo.SaveSettingsFields(ctx, settings, settingsRecord, dnfrepo.SettingsFieldValues); err != nil {
return err
}
}
wallet := WalletBalance(account)
result = CheckoutResult{
CeraBefore: ceraBefore,
CeraAfter: ceraAfter,
TokenCera: wallet.TokenCera,
HappyTokenCera: wallet.HappyTokenCera,
Paid: payment,
}
return nil
},
)
if err != nil {
return CheckoutResult{}, err
}
return result, nil
}
func Balance(account dnfrepo.AccountRecord) int64 {
return metadataBalance(account, accountCeraMetadataKey)
}
func TokenCeraBalance(account dnfrepo.AccountRecord) int64 {
return metadataBalance(account, accountTokenCeraMetadataKey)
}
func HappyTokenCeraBalance(account dnfrepo.AccountRecord) int64 {
return metadataBalance(account, accountHappyTokenCeraMetadataKey)
}
func WalletBalance(account dnfrepo.AccountRecord) Wallet {
return Wallet{
Cera: Balance(account),
TokenCera: TokenCeraBalance(account),
HappyTokenCera: HappyTokenCeraBalance(account),
}
}
func metadataBalance(account dnfrepo.AccountRecord, key string) int64 {
raw := strings.TrimSpace(account.Metadata[key])
if raw == "" {
return 0
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 0 {
return 0
}
return value
}
func SetBalance(account *dnfrepo.AccountRecord, balance int64) {
setMetadataBalance(account, accountCeraMetadataKey, balance)
}
func SetTokenCeraBalance(account *dnfrepo.AccountRecord, balance int64) {
setMetadataBalance(account, accountTokenCeraMetadataKey, balance)
}
func SetHappyTokenCeraBalance(account *dnfrepo.AccountRecord, balance int64) {
setMetadataBalance(account, accountHappyTokenCeraMetadataKey, balance)
}
func setMetadataBalance(account *dnfrepo.AccountRecord, key string, balance int64) {
if account == nil {
return
}
if account.Metadata == nil {
account.Metadata = make(map[string]string, 4)
}
if balance < 0 {
balance = 0
}
account.Metadata[key] = strconv.FormatInt(balance, 10)
}
@@ -0,0 +1,129 @@
package cerashop
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestCheckoutCommitsWalletAndAllProjectedAssets(t *testing.T) {
ctx := context.Background()
repositories := seededCheckoutRepositories(t, 1000)
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.Checkout(ctx, CheckoutCommand{
AccountID: "account-1",
CharacterID: "22",
SettingsScope: dnfrepo.CharacterContainerStateScope("22"),
Cost: 100,
Project: func(assets *CheckoutAssets) (CheckoutChanges, error) {
assets.Character.Stats = map[string]int64{"name_tag_item_id": 123}
assets.Inventory.Slots = map[string]dnfrepo.ItemStack{"0:9": {ItemID: 456, Count: 1}}
assets.Equipment.Entries = map[string]dnfrepo.EquipmentEntry{"30": {SlotIndex: 30, ItemID: 123}}
assets.Settings.Values = map[string]string{"main_list_param16": "8"}
return CheckoutChanges{Character: true, Inventory: true, Equipment: true, Settings: true}, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.CeraBefore != 1000 || result.CeraAfter != 900 {
t.Fatalf("checkout result=%+v", result)
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
character, _, _ := repositories.Character.Load(ctx, "22")
inventory, _, _ := repositories.Inventory.Load(ctx, "22")
equipment, _, _ := repositories.Equipment.Load(ctx, "22")
settings, found, _ := repositories.Settings.Load(ctx, dnfrepo.CharacterContainerStateScope("22"))
if Balance(account) != 900 ||
character.Stats["name_tag_item_id"] != 123 ||
inventory.Slots["0:9"].ItemID != 456 ||
equipment.Entries["30"].ItemID != 123 ||
!found || settings.Values["main_list_param16"] != "8" {
t.Fatalf("account=%+v character=%+v inventory=%+v equipment=%+v settings=%+v", account, character, inventory, equipment, settings)
}
}
func TestCheckoutRollsBackProjectionFailure(t *testing.T) {
ctx := context.Background()
repositories := seededCheckoutRepositories(t, 1000)
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
projectErr := errors.New("projection rejected")
_, err = owner.Checkout(ctx, CheckoutCommand{
AccountID: "account-1",
CharacterID: "22",
SettingsScope: dnfrepo.CharacterContainerStateScope("22"),
Cost: 100,
Project: func(assets *CheckoutAssets) (CheckoutChanges, error) {
assets.Inventory.Slots = make(map[string]dnfrepo.ItemStack)
assets.Inventory.Slots["0:9"] = dnfrepo.ItemStack{ItemID: 456, Count: 1}
assets.Settings.Values = map[string]string{"main_list_param16": "8"}
return CheckoutChanges{Inventory: true, Settings: true}, projectErr
},
})
if !errors.Is(err, projectErr) {
t.Fatalf("error=%v want=%v", err, projectErr)
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
inventory, _, _ := repositories.Inventory.Load(ctx, "22")
if Balance(account) != 1000 || len(inventory.Slots) != 0 {
t.Fatalf("account=%+v inventory=%+v", account, inventory)
}
if _, found, _ := repositories.Settings.Load(ctx, dnfrepo.CharacterContainerStateScope("22")); found {
t.Fatal("failed checkout persisted settings")
}
}
func TestCheckoutRejectsInsufficientCeraBeforeProjection(t *testing.T) {
repositories := seededCheckoutRepositories(t, 50)
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
projected := false
_, err = owner.Checkout(context.Background(), CheckoutCommand{
AccountID: "account-1",
CharacterID: "22",
SettingsScope: dnfrepo.CharacterContainerStateScope("22"),
Cost: 100,
Project: func(*CheckoutAssets) (CheckoutChanges, error) {
projected = true
return CheckoutChanges{}, nil
},
})
if !errors.Is(err, ErrCeraInsufficient) || projected {
t.Fatalf("error=%v projected=%t", err, projected)
}
}
func seededCheckoutRepositories(t *testing.T, cera int64) dnfrepo.Group {
t.Helper()
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
account := dnfrepo.AccountRecord{AccountID: "account-1"}
SetBalance(&account, cera)
if err := repositories.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "22",
AccountID: "account-1",
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "22",
Slots: make(map[string]dnfrepo.ItemStack),
}); err != nil {
t.Fatal(err)
}
return repositories
}
@@ -0,0 +1,136 @@
package cerashop
import (
"errors"
"fmt"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 商城的钱包扣费策略。
//
// 现场背景:客户端**只上报"支付方式 0 = 点券"**,没有任何"用代币券买"的选项
// (见 dnfbridge 的 currentCeraShopPaymentMode*)。也就是说代币券 / 欢乐代币券
// 能不能花掉,完全由服务端决定 —— 服务端不主动从这两个钱包扣,它们就是一组
// 只能看不能用的数字(这正是现场 2026-09-19 抱怨的另一半)。
//
// 所以这里实现官方的"瀑布"语义:欢乐代币券 → 代币券 → 点券,依次扣到够为止。
type PayPolicy int
const (
// PayPolicyCeraOnly 只扣点券,**这是零值**。
//
// 零值必须等于改动前的行为:既有调用方一行都不用改,也不会因为漏传字段而
// 悄悄换一种扣费方式(Go 里"新增 bool 字段默认 false"就是这种陷阱)。
PayPolicyCeraOnly PayPolicy = iota
// PayPolicyWaterfall 欢乐代币券 → 代币券 → 点券,依次扣。
PayPolicyWaterfall
// PayPolicyTokenCeraOnly 只扣欢乐代币券 + 代币券,不动点券。
// 对应客户端支持、而本服 PVF 尚未配置的 `[buy only cera point]` 商品;
// 余额不够就整笔失败。
PayPolicyTokenCeraOnly
)
// ErrWalletInsufficient 表示按给定策略扣不动这笔钱。
//
// 与 ErrCeraInsufficient(只针对点券)分开:调用方需要区分"点券不够"和
// "代币券+点券都不够",日志里才看得清玩家到底缺哪种钱。
var ErrWalletInsufficient = errors.New("cera shop: wallet balance is insufficient for the payment policy")
// WalletPayment 是一次结账从各钱包实际扣掉的数量。
type WalletPayment struct {
FromHappyTokenCera int64
FromTokenCera int64
FromCera int64
}
// Total 是这次结账各钱包扣费之和。
func (p WalletPayment) Total() int64 {
return p.FromHappyTokenCera + p.FromTokenCera + p.FromCera
}
// IsZero 表示没有从任何钱包扣钱。
func (p WalletPayment) IsZero() bool {
return p.FromHappyTokenCera == 0 && p.FromTokenCera == 0 && p.FromCera == 0
}
// String 便于日志与断言(例如 "happy=100 token=50 cera=0")。
func (p WalletPayment) String() string {
return fmt.Sprintf("happy=%d token=%d cera=%d", p.FromHappyTokenCera, p.FromTokenCera, p.FromCera)
}
// PlanWalletPayment 按策略算出各钱包要扣多少,**不改动传入的钱包**。
//
// 返回的 WalletPayment 保证 Total() == cost(成功时);失败时返回零值与错误。
// 这是一个纯函数,方便把"扣费顺序"这类最容易出错的逻辑单独钉死。
func PlanWalletPayment(wallet Wallet, cost int64, policy PayPolicy) (WalletPayment, error) {
if cost < 0 {
return WalletPayment{}, fmt.Errorf("%w: negative cost %d", ErrWalletInsufficient, cost)
}
if cost == 0 {
return WalletPayment{}, nil
}
// 负数余额只可能来自脏数据;当成 0,绝不让它把扣费算成"多扣"。
happy := wallet.HappyTokenCera
if happy < 0 {
happy = 0
}
token := wallet.TokenCera
if token < 0 {
token = 0
}
cera := wallet.Cera
if cera < 0 {
cera = 0
}
var payment WalletPayment
remaining := cost
// 两种非"纯点券"的策略都先花欢乐代币券:它是活动货币,优先级最高。
if policy == PayPolicyWaterfall || policy == PayPolicyTokenCeraOnly {
payment.FromHappyTokenCera = minInt64(remaining, happy)
remaining -= payment.FromHappyTokenCera
if remaining > 0 {
payment.FromTokenCera = minInt64(remaining, token)
remaining -= payment.FromTokenCera
}
}
if policy == PayPolicyWaterfall && remaining > 0 {
payment.FromCera = minInt64(remaining, cera)
remaining -= payment.FromCera
}
// 未知策略一律按"只扣点券"处理,绝不猜一种更宽松的扣法。
if policy != PayPolicyWaterfall && policy != PayPolicyTokenCeraOnly {
payment = WalletPayment{}
remaining = cost
payment.FromCera = minInt64(remaining, cera)
remaining -= payment.FromCera
}
if remaining > 0 {
return WalletPayment{}, fmt.Errorf("%w: policy=%d cost=%d wallet=%+v", ErrWalletInsufficient, policy, cost, wallet)
}
return payment, nil
}
// ApplyWalletPayment 把扣费方案写回账号钱包,并返回扣费后的钱包。
//
// 调用方负责保存账号记录(本函数只改内存里的记录,与仓库无关,便于在事务回调里用)。
// 每个字段都夹到 >= 0:方案本身已经保证不会扣超,这里只是最后一道防线。
func ApplyWalletPayment(account *dnfrepo.AccountRecord, payment WalletPayment) Wallet {
if account == nil {
return Wallet{}
}
wallet := WalletBalance(*account)
SetHappyTokenCeraBalance(account, wallet.HappyTokenCera-payment.FromHappyTokenCera)
SetTokenCeraBalance(account, wallet.TokenCera-payment.FromTokenCera)
SetBalance(account, wallet.Cera-payment.FromCera)
return WalletBalance(*account)
}
func minInt64(left, right int64) int64 {
if left < right {
return left
}
return right
}
@@ -0,0 +1,170 @@
package cerashop
import (
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 支付瀑布:欢乐代币券 → 代币券 → 点券。
//
// 客户端只上报"支付方式 0 = 点券",没有"用代币券买"的选项,所以代币券能不能花掉
// 完全由服务端决定。这一组测试把"扣费顺序"钉死——它是最容易写错、也最容易
// 悄悄扣错钱包的地方。
func TestPlanWalletPaymentCeraOnlyIsTheZeroValue(t *testing.T) {
wallet := Wallet{Cera: 100, TokenCera: 500, HappyTokenCera: 500}
// 零值必须是"只扣点券":既有调用方一行不改也不该换扣费方式。
var policy PayPolicy
if policy != PayPolicyCeraOnly {
t.Fatalf("PayPolicy 零值=%v want PayPolicyCeraOnly", policy)
}
payment, err := PlanWalletPayment(wallet, 80, policy)
if err != nil {
t.Fatal(err)
}
if payment.FromCera != 80 || payment.FromTokenCera != 0 || payment.FromHappyTokenCera != 0 {
t.Fatalf("只扣点券模式动了别的钱包: %s", payment)
}
// 点券不够就该失败,而不是把代币券也花了。
if _, err := PlanWalletPayment(wallet, 101, PayPolicyCeraOnly); !errors.Is(err, ErrWalletInsufficient) {
t.Fatalf("点券不够应失败: %v", err)
}
}
func TestPlanWalletPaymentWaterfallSpendsInOrder(t *testing.T) {
cases := []struct {
name string
wallet Wallet
cost int64
want WalletPayment
wantErr bool
}{
{
name: "happy_covers_all",
wallet: Wallet{Cera: 999, TokenCera: 999, HappyTokenCera: 100},
cost: 40,
want: WalletPayment{FromHappyTokenCera: 40},
},
{
name: "happy_exhausted_then_token",
wallet: Wallet{Cera: 999, TokenCera: 100, HappyTokenCera: 30},
cost: 50,
want: WalletPayment{FromHappyTokenCera: 30, FromTokenCera: 20},
},
{
name: "happy_and_token_exhausted_then_cera",
wallet: Wallet{Cera: 999, TokenCera: 20, HappyTokenCera: 10},
cost: 100,
want: WalletPayment{FromHappyTokenCera: 10, FromTokenCera: 20, FromCera: 70},
},
{
name: "exactly_enough_across_all_three",
wallet: Wallet{Cera: 70, TokenCera: 20, HappyTokenCera: 10},
cost: 100,
want: WalletPayment{FromHappyTokenCera: 10, FromTokenCera: 20, FromCera: 70},
},
{
name: "one_short",
wallet: Wallet{Cera: 69, TokenCera: 20, HappyTokenCera: 10},
cost: 100,
wantErr: true,
},
{
name: "zero_cera_still_payable_with_token",
wallet: Wallet{Cera: 0, TokenCera: 300, HappyTokenCera: 0},
cost: 250,
want: WalletPayment{FromTokenCera: 250},
},
{
name: "cost_zero_is_a_no_op",
wallet: Wallet{Cera: 5, TokenCera: 5, HappyTokenCera: 5},
cost: 0,
want: WalletPayment{},
},
{
// 负数余额只可能来自脏数据,当成 0,绝不能让扣费算出"多扣"。
name: "negative_balances_treated_as_zero",
wallet: Wallet{Cera: 100, TokenCera: -50, HappyTokenCera: -50},
cost: 100,
want: WalletPayment{FromCera: 100},
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
payment, err := PlanWalletPayment(testCase.wallet, testCase.cost, PayPolicyWaterfall)
if testCase.wantErr {
if !errors.Is(err, ErrWalletInsufficient) {
t.Fatalf("期望余额不足,实际 err=%v payment=%s", err, payment)
}
if !payment.IsZero() {
t.Fatalf("失败时必须返回零方案,绝不部分扣费: %s", payment)
}
return
}
if err != nil {
t.Fatal(err)
}
if payment != testCase.want {
t.Fatalf("payment=%s want=%s", payment, testCase.want)
}
if payment.Total() != testCase.cost {
t.Fatalf("扣费总额=%d want=%d", payment.Total(), testCase.cost)
}
})
}
}
// `[buy only cera point]` 商品:不能动点券。
func TestPlanWalletPaymentTokenCeraOnlyNeverTouchesCera(t *testing.T) {
wallet := Wallet{Cera: 100000, TokenCera: 30, HappyTokenCera: 10}
payment, err := PlanWalletPayment(wallet, 40, PayPolicyTokenCeraOnly)
if err != nil {
t.Fatal(err)
}
if payment.FromCera != 0 || payment.FromHappyTokenCera != 10 || payment.FromTokenCera != 30 {
t.Fatalf("不该动点券: %s", payment)
}
// 券不够时失败,即使点券充足。
if _, err := PlanWalletPayment(wallet, 41, PayPolicyTokenCeraOnly); !errors.Is(err, ErrWalletInsufficient) {
t.Fatalf("券不够应失败(点券再多也不能用): %v", err)
}
}
// 未知策略一律按"只扣点券"处理:绝不猜一种更宽松的扣法。
func TestPlanWalletPaymentUnknownPolicyFallsBackToCeraOnly(t *testing.T) {
wallet := Wallet{Cera: 100, TokenCera: 500, HappyTokenCera: 500}
payment, err := PlanWalletPayment(wallet, 60, PayPolicy(99))
if err != nil {
t.Fatal(err)
}
if payment.FromCera != 60 || payment.FromTokenCera != 0 || payment.FromHappyTokenCera != 0 {
t.Fatalf("未知策略不该动代币券钱包: %s", payment)
}
}
func TestPlanWalletPaymentRejectsNegativeCost(t *testing.T) {
if _, err := PlanWalletPayment(Wallet{Cera: 100}, -1, PayPolicyWaterfall); !errors.Is(err, ErrWalletInsufficient) {
t.Fatalf("负成本应失败: %v", err)
}
}
func TestApplyWalletPaymentWritesOnlyThePlannedFields(t *testing.T) {
account := &dnfrepo.AccountRecord{AccountID: "account-1", Metadata: map[string]string{}}
SetBalance(account, 700)
SetTokenCeraBalance(account, 100)
SetHappyTokenCeraBalance(account, 50)
after := ApplyWalletPayment(account, WalletPayment{FromHappyTokenCera: 50, FromTokenCera: 20, FromCera: 30})
want := Wallet{Cera: 670, TokenCera: 80, HappyTokenCera: 0}
if after != want {
t.Fatalf("after=%+v want=%+v", after, want)
}
if wallet := WalletBalance(*account); wallet != want {
t.Fatalf("账号记录=%+v want=%+v", wallet, want)
}
// nil 账号不该 panic(调用方多在事务回调里,panic 会连累整条连接)。
if got := ApplyWalletPayment(nil, WalletPayment{FromCera: 1}); got != (Wallet{}) {
t.Fatalf("nil 账号应返回零钱包: %+v", got)
}
}
@@ -0,0 +1,143 @@
// Package cerashop:代币券 / 欢乐代币券的"入钱包"语义。
//
// 背景(现场 2026-09-19):代币券和欢乐代币券在道具 PVF 里是普通
// `[stackable type] [material]`,既没有 `[action type]` 也没有任何"使用后转成点券"
// 的定义 —— 也就是说**客户端自己根本无法把它们变成钱包余额**。任何把它们当普通道具
// 发进背包的路径,玩家拿到的都是一件永远用不掉的"材料",这正是现场的抱怨。
//
// 所以转换 100% 由服务端负责:发放时识别券的道具号,直接累加到账号钱包,不占背包格。
//
// 道具号来源(真机 runtime/data/dnf/Script.pvf):
//
// stackable/cash/chn_Trade_Gold/trade_point.stk id 2681762 `代币券`
// stackable/cash/chn_Trade_Gold/trade_point_event.stk id 2681917 `欢乐代币券`
package cerashop
import (
"errors"
"math"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
// TokenCeraVoucherItemID 是 `代币券`(trade_point.stk)。
TokenCeraVoucherItemID int64 = 2681762
// HappyTokenCeraVoucherItemID 是 `欢乐代币券`(trade_point_event.stk)。
HappyTokenCeraVoucherItemID int64 = 2681917
)
// MaxWalletBalance 是三个钱包字段的上限。
//
// 客户端把这三个值按 **有符号 32 位** 读进字段(反编译 `class0/0x0035` 的 handler
// `FUN_01d83c20` 读的是 i32),所以上限必须是 MaxInt32 而不是 MaxInt64:
// 超过就会在客户端显示成负数。所有入账都必须在这里挡住。
const MaxWalletBalance int64 = math.MaxInt32
// ErrWalletOverflow 表示入账会让钱包超过 MaxWalletBalance。
// 调用方应该把它映射成自己模块的"请求非法"错误,而不是静默截断。
var ErrWalletOverflow = errors.New("cera shop: wallet balance would exceed the client limit")
// VoucherKind 是券对应的钱包字段。
type VoucherKind int
const (
// VoucherNone 表示这件道具不是券。
VoucherNone VoucherKind = iota
// VoucherTokenCera 是代币券,入账到 Wallet.TokenCera。
VoucherTokenCera
// VoucherHappyTokenCera 是欢乐代币券,入账到 Wallet.HappyTokenCera。
VoucherHappyTokenCera
)
// VoucherKindOf 判断一件道具是不是券。不是券返回 VoucherNone。
func VoucherKindOf(itemID int64) VoucherKind {
switch itemID {
case TokenCeraVoucherItemID:
return VoucherTokenCera
case HappyTokenCeraVoucherItemID:
return VoucherHappyTokenCera
default:
return VoucherNone
}
}
// IsVoucher 是上面的布尔简写,给"这批奖励里有没有券"这类判断用。
func IsVoucher(itemID int64) bool {
return VoucherKindOf(itemID) != VoucherNone
}
// VoucherItemID 返回某类券的道具号(VoucherNone 返回 0)。
func VoucherItemID(kind VoucherKind) int64 {
switch kind {
case VoucherTokenCera:
return TokenCeraVoucherItemID
case VoucherHappyTokenCera:
return HappyTokenCeraVoucherItemID
default:
return 0
}
}
// VoucherItemIDs 返回全部券道具号,供"发放前先摘出来"的过滤使用。
func VoucherItemIDs() []int64 {
return []int64{TokenCeraVoucherItemID, HappyTokenCeraVoucherItemID}
}
// VoucherCounts 是一批待入账的券数量。
type VoucherCounts struct {
TokenCera int64
HappyTokenCera int64
}
// IsZero 表示没有任何券需要入账。
func (c VoucherCounts) IsZero() bool {
return c.TokenCera == 0 && c.HappyTokenCera == 0
}
// Add 累加另一种券的数量。
func (c VoucherCounts) Add(other VoucherCounts) VoucherCounts {
return VoucherCounts{
TokenCera: c.TokenCera + other.TokenCera,
HappyTokenCera: c.HappyTokenCera + other.HappyTokenCera,
}
}
// CreditVoucherCounts 把券数量累加到账号钱包。
//
// 语义与既有邮件领取路径完全一致(那里已经这么做了),本函数只是把它提取成共享实现,
// 让任务奖励、礼盒开启、GM 补发都走同一条路,避免各写一套溢出判断。
//
// - **全有或全无**:任何一项溢出都不写任何字段,账号记录保持原样,返回 ErrWalletOverflow。
// - 调用方负责保存 account 与它的 UpdatedAt。
// - 负数一律当 0:券数量为负只可能来自上游 bug,不该把余额扣下去。
func CreditVoucherCounts(account *dnfrepo.AccountRecord, counts VoucherCounts) (Wallet, error) {
if account == nil {
return Wallet{}, errors.New("cera shop: account is required")
}
tokenCera := counts.TokenCera
if tokenCera < 0 {
tokenCera = 0
}
happyTokenCera := counts.HappyTokenCera
if happyTokenCera < 0 {
happyTokenCera = 0
}
if tokenCera == 0 && happyTokenCera == 0 {
return WalletBalance(*account), nil
}
wallet := WalletBalance(*account)
if tokenCera > MaxWalletBalance-wallet.TokenCera {
return wallet, ErrWalletOverflow
}
if happyTokenCera > MaxWalletBalance-wallet.HappyTokenCera {
return wallet, ErrWalletOverflow
}
if tokenCera > 0 {
SetTokenCeraBalance(account, wallet.TokenCera+tokenCera)
}
if happyTokenCera > 0 {
SetHappyTokenCeraBalance(account, wallet.HappyTokenCera+happyTokenCera)
}
return WalletBalance(*account), nil
}
@@ -0,0 +1,126 @@
package cerashop
import (
"errors"
"math"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 代币券 / 欢乐代币券必须能落成钱包余额,而不是背包里的一件"材料"。
// 这里守住券的道具号识别与入账语义(溢出、全有或全无)。
func TestVoucherKindOfRecognizesBothVouchers(t *testing.T) {
cases := []struct {
itemID int64
want VoucherKind
}{
{2681762, VoucherTokenCera},
{2681917, VoucherHappyTokenCera},
{0, VoucherNone},
{2681762 - 1, VoucherNone},
{2681762 + 1, VoucherNone},
{2681917 + 1, VoucherNone},
{100050, VoucherNone},
}
for _, testCase := range cases {
if got := VoucherKindOf(testCase.itemID); got != testCase.want {
t.Fatalf("VoucherKindOf(%d)=%v want=%v", testCase.itemID, got, testCase.want)
}
if IsVoucher(testCase.itemID) != (testCase.want != VoucherNone) {
t.Fatalf("IsVoucher(%d) 与 VoucherKindOf 不一致", testCase.itemID)
}
}
if VoucherItemID(VoucherTokenCera) != 2681762 || VoucherItemID(VoucherHappyTokenCera) != 2681917 ||
VoucherItemID(VoucherNone) != 0 {
t.Fatal("VoucherItemID 与 VoucherKindOf 不是同一套映射")
}
if ids := VoucherItemIDs(); len(ids) != 2 || !IsVoucher(ids[0]) || !IsVoucher(ids[1]) {
t.Fatalf("VoucherItemIDs=%v", ids)
}
}
func TestCreditVoucherCountsAddsBothWallets(t *testing.T) {
account := &dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_cera": "700", "account_token_cera": "5"},
}
wallet, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: 100, HappyTokenCera: 250})
if err != nil {
t.Fatal(err)
}
if wallet.Cera != 700 || wallet.TokenCera != 105 || wallet.HappyTokenCera != 250 {
t.Fatalf("钱包=%+v want cera=700 token=105 happy=250", wallet)
}
// 点券字段绝不能被券的入账碰到。
if got := Balance(*account); got != 700 {
t.Fatalf("点券被改动了: %d", got)
}
}
// 数量为 0 时是空操作,不该写任何键(否则会给账号留下无意义的新元数据)。
func TestCreditVoucherCountsIgnoresZeroAndNegative(t *testing.T) {
account := &dnfrepo.AccountRecord{AccountID: "account-1"}
wallet, err := CreditVoucherCounts(account, VoucherCounts{})
if err != nil || wallet.TokenCera != 0 || wallet.HappyTokenCera != 0 {
t.Fatalf("空入账 err=%v wallet=%+v", err, wallet)
}
if len(account.Metadata) != 0 {
t.Fatalf("空入账不该写元数据: %v", account.Metadata)
}
// 负数只可能来自上游 bug,按 0 处理而不是把余额扣下去。
if _, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: -50, HappyTokenCera: -1}); err != nil {
t.Fatal(err)
}
if wallet := WalletBalance(*account); wallet.TokenCera != 0 || wallet.HappyTokenCera != 0 {
t.Fatalf("负数不该扣余额: %+v", wallet)
}
}
// 溢出必须整笔拒绝,且**一个字段都不写**(全有或全无)——否则会出现
// "代币券溢出失败、欢乐代币券却已经进账"的半截状态。
func TestCreditVoucherCountsRefusesOverflowAtomically(t *testing.T) {
account := &dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_token_cera": "2147483647"},
}
_, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: 1, HappyTokenCera: 100})
if !errors.Is(err, ErrWalletOverflow) {
t.Fatalf("代币券溢出应返回 ErrWalletOverflow,实际=%v", err)
}
if wallet := WalletBalance(*account); wallet.TokenCera != math.MaxInt32 || wallet.HappyTokenCera != 0 {
t.Fatalf("溢出时不该写任何字段: %+v", wallet)
}
// 边界:刚好填满到上限是允许的(不是溢出)。
account = &dnfrepo.AccountRecord{AccountID: "account-1"}
wallet, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: math.MaxInt32})
if err != nil || wallet.TokenCera != math.MaxInt32 {
t.Fatalf("刚好到上限应当允许: err=%v wallet=%+v", err, wallet)
}
// 再 +1 就溢出。
if _, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: 1}); !errors.Is(err, ErrWalletOverflow) {
t.Fatalf("超过上限应溢出,实际=%v", err)
}
}
// nil 账号必须报错而不是 panic:调用方多为事务回调,panic 会连累整条连接。
func TestCreditVoucherCountsRejectsNilAccount(t *testing.T) {
if _, err := CreditVoucherCounts(nil, VoucherCounts{TokenCera: 1}); err == nil {
t.Fatal("nil 账号应报错")
}
}
func TestVoucherCountsAddAndIsZero(t *testing.T) {
if !(VoucherCounts{}).IsZero() {
t.Fatal("空 counts 应当是 zero")
}
if (VoucherCounts{TokenCera: 1}).IsZero() {
t.Fatal("非空 counts 不该是 zero")
}
sum := VoucherCounts{TokenCera: 3}.Add(VoucherCounts{TokenCera: 4, HappyTokenCera: 5})
if sum.TokenCera != 7 || sum.HappyTokenCera != 5 {
t.Fatalf("Add=%+v", sum)
}
}
@@ -0,0 +1,80 @@
package cerashop
import (
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// WalletCurrency 按名字标识账号钱包里的一个字段,供 GM / 管理接口读写。
//
// 存在的理由:钱包的三个字段分别落在账号 Metadata 的三个键上,键名是 cerashop 的
// 内部细节。GM 接口需要按名字操作它们(尤其是造测试数据:把点券清零、只留代币券),
// 所以在这里开一个受控的出口,而不是让调用方去猜 "account_token_cera" 这种字符串。
type WalletCurrency string
const (
// CurrencyCera 是点券。
CurrencyCera WalletCurrency = "cera"
// CurrencyTokenCera 是代币券。
CurrencyTokenCera WalletCurrency = "token_cera"
// CurrencyHappyTokenCera 是欢乐代币券。
CurrencyHappyTokenCera WalletCurrency = "happy_token_cera"
)
// MetadataKey 返回该字段在账号 Metadata 里的键名。
func (c WalletCurrency) MetadataKey() (string, bool) {
switch c {
case CurrencyCera:
return accountCeraMetadataKey, true
case CurrencyTokenCera:
return accountTokenCeraMetadataKey, true
case CurrencyHappyTokenCera:
return accountHappyTokenCeraMetadataKey, true
default:
return "", false
}
}
// Known 表示这是一个合法的钱包字段名。
func (c WalletCurrency) Known() bool {
_, ok := c.MetadataKey()
return ok
}
// BalanceOf 读该字段的当前值(未知字段返回 0,false)。
func (c WalletCurrency) BalanceOf(account dnfrepo.AccountRecord) (int64, bool) {
switch c {
case CurrencyCera:
return Balance(account), true
case CurrencyTokenCera:
return TokenCeraBalance(account), true
case CurrencyHappyTokenCera:
return HappyTokenCeraBalance(account), true
default:
return 0, false
}
}
// SetOn 把值写进账号记录(调用方负责保存账号记录)。未知字段返回 false,不做任何改动。
//
// 值会被夹到 [0, MaxWalletBalance]:客户端按有符号 32 位读这三个字段,超过上限会
// 显示成负数,所以这里直接挡住,而不是写进去等客户端出问题。
func (c WalletCurrency) SetOn(account *dnfrepo.AccountRecord, value int64) bool {
if account == nil || !c.Known() {
return false
}
if value < 0 {
value = 0
}
if value > MaxWalletBalance {
value = MaxWalletBalance
}
switch c {
case CurrencyCera:
SetBalance(account, value)
case CurrencyTokenCera:
SetTokenCeraBalance(account, value)
case CurrencyHappyTokenCera:
SetHappyTokenCeraBalance(account, value)
}
return true
}