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,324 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
MinimumLevel = 50
|
||||
MaximumBetPerRound = 10_000
|
||||
OpeningKnightCount = 8
|
||||
PermanentCrystalID = 490005585
|
||||
EventCrystalID = 490005586
|
||||
ParticipationItemID = 490005593
|
||||
|
||||
RoundStat = "joust_round"
|
||||
KnightStat = "joust_knight"
|
||||
AmountStat = "joust_amount"
|
||||
SourceItemIDStat = "joust_source_item_id"
|
||||
PendingStat = "joust_pending"
|
||||
LastBetUnixStat = "joust_last_bet_unix"
|
||||
WinnerStat = "joust_winner"
|
||||
PayoutStat = "joust_payout"
|
||||
SettledUnixStat = "joust_settled_unix"
|
||||
|
||||
// KnightAmountStatPrefix stores the current round's per-knight support.
|
||||
// AmountStat remains the total sent to the current client in op1242, while
|
||||
// these entries preserve split support across all active riders.
|
||||
KnightAmountStatPrefix = "joust_knight_amount_"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("joust owner unavailable")
|
||||
ErrRequestInvalid = errors.New("joust betting request invalid")
|
||||
ErrCharacterNotFound = errors.New("joust character not found")
|
||||
ErrAccountMismatch = errors.New("joust character account mismatch")
|
||||
ErrLevelTooLow = errors.New("joust character level is too low")
|
||||
ErrInventoryNotFound = errors.New("joust inventory not found")
|
||||
ErrCrystalMissing = errors.New("joust crystal is missing")
|
||||
ErrCrystalInvalid = errors.New("joust source is not a supported crystal")
|
||||
ErrCrystalInsufficient = errors.New("joust crystal count is insufficient")
|
||||
ErrCrystalChanged = errors.New("joust source crystal cannot change within one round")
|
||||
ErrKnightUnavailable = errors.New("joust knight is not in the active round")
|
||||
ErrKnightChanged = errors.New("joust knight cannot change within one round")
|
||||
ErrRoundLimit = errors.New("joust round betting limit exceeded")
|
||||
ErrRewardPlacement = errors.New("joust participation reward placement failed")
|
||||
ErrSettlementRequired = errors.New("previous joust round requires settlement")
|
||||
ErrSettlementInvalid = errors.New("joust settlement is invalid")
|
||||
)
|
||||
|
||||
// RewardPlacer projects the current PVF participation item into the already
|
||||
// cloned inventory aggregate. The transport supplies the current-client raw
|
||||
// row construction, while Owner keeps the mutation inside one repository
|
||||
// transaction with the crystal debit and durable bet ledger.
|
||||
type RewardPlacer func(*dnfrepo.InventoryRecord) (int16, dnfrepo.ItemStack, error)
|
||||
|
||||
type Command struct {
|
||||
AccountID string
|
||||
SelectedCharacterID uint16
|
||||
Round uint16
|
||||
Knight byte
|
||||
SourceSlot int16
|
||||
Amount uint32
|
||||
UpdatedAt time.Time
|
||||
KnightAllowed func(byte) bool
|
||||
PlaceReward RewardPlacer
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
CharacterID string
|
||||
Round uint16
|
||||
Knight byte
|
||||
Amount uint32
|
||||
RoundTotal uint32
|
||||
SourceSlot int16
|
||||
SourceItemID int64
|
||||
SourceRemoved bool
|
||||
RemainingSource dnfrepo.ItemStack
|
||||
RewardSlot int16
|
||||
ParticipationItem dnfrepo.ItemStack
|
||||
}
|
||||
|
||||
// KnightAmountStat returns the durable stat key for one rider's support in
|
||||
// the current joust round. Rider identifiers are current-PVF bytes, so using
|
||||
// their decimal representation keeps the state inspectable in the local DB.
|
||||
func KnightAmountStat(knight byte) string {
|
||||
return KnightAmountStatPrefix + strconv.FormatUint(uint64(knight), 10)
|
||||
}
|
||||
|
||||
// CurrentRoundBets reconstructs every rider wager for one round. It accepts
|
||||
// the original one-rider ledger as a fallback, preserving wagers placed before
|
||||
// split support was enabled.
|
||||
func CurrentRoundBets(stats map[string]int64, round uint16) (map[byte]uint32, uint32, bool) {
|
||||
if stats == nil || stats[RoundStat] != int64(round) {
|
||||
return nil, 0, false
|
||||
}
|
||||
totalValue := stats[AmountStat]
|
||||
if totalValue <= 0 || totalValue > MaximumBetPerRound {
|
||||
return nil, 0, false
|
||||
}
|
||||
wantTotal := uint32(totalValue)
|
||||
bets := make(map[byte]uint32, OpeningKnightCount)
|
||||
var actualTotal uint32
|
||||
for key, value := range stats {
|
||||
if !strings.HasPrefix(key, KnightAmountStatPrefix) || value == 0 {
|
||||
continue
|
||||
}
|
||||
if value < 0 || value > MaximumBetPerRound {
|
||||
return nil, 0, false
|
||||
}
|
||||
id, err := strconv.ParseUint(strings.TrimPrefix(key, KnightAmountStatPrefix), 10, 8)
|
||||
if err != nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
amount := uint32(value)
|
||||
if amount > MaximumBetPerRound-actualTotal {
|
||||
return nil, 0, false
|
||||
}
|
||||
actualTotal += amount
|
||||
bets[byte(id)] += amount
|
||||
}
|
||||
if actualTotal == 0 {
|
||||
knight := stats[KnightStat]
|
||||
if knight < 0 || knight > math.MaxUint8 {
|
||||
return nil, 0, false
|
||||
}
|
||||
bets[byte(knight)] = wantTotal
|
||||
return bets, wantTotal, true
|
||||
}
|
||||
if actualTotal != wantTotal {
|
||||
return nil, 0, false
|
||||
}
|
||||
return bets, actualTotal, true
|
||||
}
|
||||
|
||||
// CurrentRoundSourceItem returns the exact material debited for a round. Old
|
||||
// one-rider ledgers predate this field and are permanently interpreted as the
|
||||
// original permanent crystal, preserving a claim that was already placed.
|
||||
func CurrentRoundSourceItem(stats map[string]int64, round uint16) (int64, bool) {
|
||||
if stats == nil || stats[RoundStat] != int64(round) {
|
||||
return 0, false
|
||||
}
|
||||
itemID := stats[SourceItemIDStat]
|
||||
if itemID == 0 {
|
||||
return PermanentCrystalID, true
|
||||
}
|
||||
return itemID, itemID == PermanentCrystalID || itemID == EventCrystalID
|
||||
}
|
||||
|
||||
func clearKnightAmountStats(stats map[string]int64) {
|
||||
for key := range stats {
|
||||
if strings.HasPrefix(key, KnightAmountStatPrefix) {
|
||||
delete(stats, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Owner struct {
|
||||
assets dnfrepo.CharacterAssetUnitOfWork
|
||||
mailboxAssets dnfrepo.MailboxAssetUnitOfWork
|
||||
}
|
||||
|
||||
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
||||
if repositories.CharacterAssets == nil || repositories.MailboxAssets == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{assets: repositories.CharacterAssets, mailboxAssets: repositories.MailboxAssets}, nil
|
||||
}
|
||||
|
||||
func (o *Owner) Bet(ctx context.Context, cmd Command) (Result, error) {
|
||||
if o == nil || o.assets == nil {
|
||||
return Result{}, ErrOwnerUnavailable
|
||||
}
|
||||
if cmd.SelectedCharacterID == 0 || cmd.KnightAllowed == nil ||
|
||||
cmd.SourceSlot < 0 || cmd.Amount == 0 || cmd.Amount > MaximumBetPerRound ||
|
||||
cmd.PlaceReward == nil {
|
||||
return Result{}, ErrRequestInvalid
|
||||
}
|
||||
if !cmd.KnightAllowed(cmd.Knight) {
|
||||
return Result{}, ErrKnightUnavailable
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
accountID := strings.TrimSpace(cmd.AccountID)
|
||||
result := Result{
|
||||
CharacterID: characterID,
|
||||
Round: cmd.Round,
|
||||
Knight: cmd.Knight,
|
||||
Amount: cmd.Amount,
|
||||
SourceSlot: cmd.SourceSlot,
|
||||
}
|
||||
err := o.assets.WithinCharacterAssets(ctx, characterID, func(
|
||||
characters dnfrepo.CharacterRepository,
|
||||
inventories dnfrepo.InventoryRepository,
|
||||
_ dnfrepo.EquipmentRepository,
|
||||
) error {
|
||||
character, found, err := characters.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(character.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: character=%s", ErrCharacterNotFound, characterID)
|
||||
}
|
||||
if accountID != "" && strings.TrimSpace(character.AccountID) != accountID {
|
||||
return fmt.Errorf("%w: character=%s", ErrAccountMismatch, characterID)
|
||||
}
|
||||
if character.Level < MinimumLevel {
|
||||
return fmt.Errorf("%w: level=%d", ErrLevelTooLow, character.Level)
|
||||
}
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
if character.Stats == nil {
|
||||
character.Stats = make(map[string]int64, 5)
|
||||
}
|
||||
|
||||
previousAmount := uint32(0)
|
||||
previousSourceItemID := int64(0)
|
||||
previousBets := make(map[byte]uint32, OpeningKnightCount)
|
||||
if character.Stats[PendingStat] != 0 && character.Stats[RoundStat] != int64(cmd.Round) {
|
||||
return ErrSettlementRequired
|
||||
}
|
||||
if character.Stats[RoundStat] == int64(cmd.Round) && character.Stats[AmountStat] != 0 {
|
||||
var valid bool
|
||||
previousBets, previousAmount, valid = CurrentRoundBets(character.Stats, cmd.Round)
|
||||
if !valid {
|
||||
return ErrRequestInvalid
|
||||
}
|
||||
if previousSourceItemID, valid = CurrentRoundSourceItem(character.Stats, cmd.Round); !valid {
|
||||
return ErrRequestInvalid
|
||||
}
|
||||
} else {
|
||||
// A settled prior round must not leak its split support into this
|
||||
// new opening. It remains available until the next wager begins.
|
||||
clearKnightAmountStats(character.Stats)
|
||||
}
|
||||
if cmd.Amount > MaximumBetPerRound-previousAmount {
|
||||
return ErrRoundLimit
|
||||
}
|
||||
|
||||
inventory, found, err := inventories.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
key := fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, cmd.SourceSlot)
|
||||
source, found := inventory.Slots[key]
|
||||
if !found || source.ItemID <= 0 || source.Count <= 0 {
|
||||
return fmt.Errorf("%w: slot=%d", ErrCrystalMissing, cmd.SourceSlot)
|
||||
}
|
||||
if source.ItemID != PermanentCrystalID && source.ItemID != EventCrystalID {
|
||||
return fmt.Errorf("%w: slot=%d item=%d", ErrCrystalInvalid, cmd.SourceSlot, source.ItemID)
|
||||
}
|
||||
if previousAmount != 0 && previousSourceItemID != source.ItemID {
|
||||
return fmt.Errorf("%w: previous=%d requested=%d", ErrCrystalChanged, previousSourceItemID, source.ItemID)
|
||||
}
|
||||
if source.Count < int64(cmd.Amount) {
|
||||
return fmt.Errorf("%w: have=%d need=%d", ErrCrystalInsufficient, source.Count, cmd.Amount)
|
||||
}
|
||||
result.SourceItemID = source.ItemID
|
||||
source.Count -= int64(cmd.Amount)
|
||||
if source.Count == 0 {
|
||||
delete(inventory.Slots, key)
|
||||
result.SourceRemoved = true
|
||||
} else {
|
||||
inventory.Slots[key] = source
|
||||
result.RemainingSource = source
|
||||
}
|
||||
|
||||
rewardSlot, reward, err := cmd.PlaceReward(&inventory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrRewardPlacement, err)
|
||||
}
|
||||
if rewardSlot < 0 || reward.ItemID != ParticipationItemID || reward.Count <= 0 ||
|
||||
reward.Count > math.MaxUint32 {
|
||||
return ErrRewardPlacement
|
||||
}
|
||||
rewardKey := fmt.Sprintf("%d:%d", dnfrepo.MainInventoryListType, rewardSlot)
|
||||
storedReward, found := inventory.Slots[rewardKey]
|
||||
if !found || storedReward.ItemID != reward.ItemID || storedReward.Count != reward.Count {
|
||||
return ErrRewardPlacement
|
||||
}
|
||||
result.RewardSlot = rewardSlot
|
||||
result.ParticipationItem = reward
|
||||
result.RoundTotal = previousAmount + cmd.Amount
|
||||
previousBets[cmd.Knight] += cmd.Amount
|
||||
|
||||
now := cmd.UpdatedAt
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
character.Stats[RoundStat] = int64(cmd.Round)
|
||||
character.Stats[KnightStat] = int64(cmd.Knight)
|
||||
character.Stats[AmountStat] = int64(result.RoundTotal)
|
||||
character.Stats[SourceItemIDStat] = source.ItemID
|
||||
for knight, amount := range previousBets {
|
||||
character.Stats[KnightAmountStat(knight)] = int64(amount)
|
||||
}
|
||||
character.Stats[PendingStat] = 1
|
||||
character.Stats[LastBetUnixStat] = now.Unix()
|
||||
character.UpdatedAt = now
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
inventory.UpdatedAt = now
|
||||
return dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots)
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
Reference in new issue
Block a user