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

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

499 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}