服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
482 lines
16 KiB
Go
482 lines
16 KiB
Go
package dungeontower
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
|
|
)
|
|
|
|
const settingsSuffix = ":dungeon_tower_progress_v1"
|
|
|
|
var (
|
|
ErrOwnerUnavailable = errors.New("dungeon tower progress owner is unavailable")
|
|
ErrCommandInvalid = errors.New("dungeon tower progress command is invalid")
|
|
ErrCharacterMissing = errors.New("dungeon tower progress character is missing")
|
|
ErrProgressMismatch = errors.New("dungeon tower progress does not own the cleared floor")
|
|
ErrRewardInvalid = errors.New("dungeon tower reward is invalid")
|
|
ErrInventoryFull = errors.New("dungeon tower reward inventory is full")
|
|
)
|
|
|
|
// Snapshot is the durable current floor projected to the current client.
|
|
type Snapshot struct {
|
|
Family dnfdungeonadmission.TowerFamily
|
|
Floor uint16
|
|
DungeonID uint32
|
|
Completed bool
|
|
Replayed bool
|
|
}
|
|
|
|
// RewardItem is one immutable item selected from the active PVF before the
|
|
// tower settlement transaction starts.
|
|
type RewardItem struct {
|
|
Stack dnfrepo.ItemStack
|
|
Stackable bool
|
|
StackLimit int64
|
|
SlotStart int16
|
|
SlotEnd int16
|
|
}
|
|
|
|
type SettlementCommand struct {
|
|
CharacterID string
|
|
Route dnfdungeonadmission.TowerRoute
|
|
ClearedDungeonID uint32
|
|
Receipt string
|
|
MainSlots uint16
|
|
Rewards []RewardItem
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type SettlementResult struct {
|
|
Snapshot Snapshot
|
|
Inventory dnfrepo.InventoryRecord
|
|
ItemSlots []int16
|
|
Replayed bool
|
|
}
|
|
|
|
// Owner keeps each tower's floor on the character rather than the account's
|
|
// admission permission row. A clear receipt makes the increment idempotent.
|
|
type Owner struct {
|
|
repositories dnfrepo.Group
|
|
}
|
|
|
|
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
|
if repositories.Character == nil || repositories.Settings == nil || repositories.CharacterSettlement == nil {
|
|
return nil, ErrOwnerUnavailable
|
|
}
|
|
return &Owner{repositories: repositories}, nil
|
|
}
|
|
|
|
func (o *Owner) Current(
|
|
ctx context.Context,
|
|
characterID string,
|
|
route dnfdungeonadmission.TowerRoute,
|
|
) (Snapshot, error) {
|
|
if o == nil || o.repositories.Character == nil || o.repositories.Settings == nil {
|
|
return Snapshot{}, ErrOwnerUnavailable
|
|
}
|
|
characterID = strings.TrimSpace(characterID)
|
|
if characterID == "" {
|
|
return Snapshot{}, fmt.Errorf("%w: character is required", ErrCommandInvalid)
|
|
}
|
|
if err := validateRoute(route); err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
ctx = contextOrBackground(ctx)
|
|
if _, found, err := o.repositories.Character.Load(ctx, characterID); err != nil {
|
|
return Snapshot{}, err
|
|
} else if !found {
|
|
return Snapshot{}, ErrCharacterMissing
|
|
}
|
|
settings, _, err := o.repositories.Settings.Load(ctx, SettingsScope(characterID))
|
|
if err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
return snapshotFromValues(route, settings.Values)
|
|
}
|
|
|
|
// Advance commits one successful authored floor. clearedDungeonID may be a
|
|
// later route member only when the bridge has already skipped intervening PVF
|
|
// floors that have no playable solo actor layout.
|
|
func (o *Owner) Advance(
|
|
ctx context.Context,
|
|
characterID string,
|
|
route dnfdungeonadmission.TowerRoute,
|
|
clearedDungeonID uint32,
|
|
receipt string,
|
|
now time.Time,
|
|
) (Snapshot, error) {
|
|
result, err := o.CommitSettlement(ctx, SettlementCommand{
|
|
CharacterID: characterID, Route: route, ClearedDungeonID: clearedDungeonID,
|
|
Receipt: receipt, UpdatedAt: now,
|
|
})
|
|
return result.Snapshot, err
|
|
}
|
|
|
|
// CommitSettlement atomically grants the already-planned tower rewards and
|
|
// advances the durable floor. The dedicated result packet is only a projection
|
|
// of this transaction and therefore cannot create rewards or progress.
|
|
func (o *Owner) CommitSettlement(ctx context.Context, command SettlementCommand) (SettlementResult, error) {
|
|
if o == nil || o.repositories.CharacterSettlement == nil {
|
|
return SettlementResult{}, ErrOwnerUnavailable
|
|
}
|
|
characterID := strings.TrimSpace(command.CharacterID)
|
|
receipt := strings.TrimSpace(command.Receipt)
|
|
if characterID == "" || receipt == "" || command.ClearedDungeonID == 0 {
|
|
return SettlementResult{}, fmt.Errorf("%w: character, dungeon and receipt are required", ErrCommandInvalid)
|
|
}
|
|
if err := validateRoute(command.Route); err != nil {
|
|
return SettlementResult{}, err
|
|
}
|
|
if err := validateRewards(command); err != nil {
|
|
return SettlementResult{}, err
|
|
}
|
|
clearedFloor := routeFloorForDungeon(command.Route, command.ClearedDungeonID)
|
|
if clearedFloor == 0 {
|
|
return SettlementResult{}, fmt.Errorf("%w: dungeon=%d family=%s", ErrProgressMismatch, command.ClearedDungeonID, command.Route.Family)
|
|
}
|
|
ctx = contextOrBackground(ctx)
|
|
if command.UpdatedAt.IsZero() {
|
|
command.UpdatedAt = time.Now()
|
|
}
|
|
now := command.UpdatedAt.UTC()
|
|
var result SettlementResult
|
|
err := o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
|
if tx.Character == nil || tx.Settings == nil || tx.Inventory == nil {
|
|
return ErrOwnerUnavailable
|
|
}
|
|
if _, found, loadErr := tx.Character.Load(ctx, characterID); loadErr != nil {
|
|
return loadErr
|
|
} else if !found {
|
|
return ErrCharacterMissing
|
|
}
|
|
scope := SettingsScope(characterID)
|
|
settings, found, loadErr := tx.Settings.Load(ctx, scope)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
settings = dnfrepo.CloneSettings(settings)
|
|
if !found {
|
|
settings.Scope = scope
|
|
}
|
|
if settings.Values == nil {
|
|
settings.Values = make(map[string]string)
|
|
}
|
|
receiptKey := receiptStorageKey(command.Route.Family, receipt)
|
|
if value := strings.TrimSpace(settings.Values[receiptKey]); value != "" {
|
|
replayed, slots, parseErr := parseSettlementReceipt(command.Route, command.ClearedDungeonID, value)
|
|
if parseErr != nil {
|
|
return parseErr
|
|
}
|
|
replayed.Replayed = true
|
|
inventory, _, loadErr := tx.Inventory.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
result = SettlementResult{Snapshot: replayed, Inventory: dnfrepo.CloneInventory(inventory), ItemSlots: slots, Replayed: true}
|
|
return nil
|
|
}
|
|
current, currentErr := snapshotFromValues(command.Route, settings.Values)
|
|
if currentErr != nil {
|
|
return currentErr
|
|
}
|
|
if current.Completed || clearedFloor < current.Floor {
|
|
return fmt.Errorf("%w: current_floor=%d cleared_floor=%d completed=%t", ErrProgressMismatch, current.Floor, clearedFloor, current.Completed)
|
|
}
|
|
inventory, found, loadErr := tx.Inventory.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found {
|
|
inventory = dnfrepo.InventoryRecord{CharacterID: characterID}
|
|
}
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
if inventory.Slots == nil {
|
|
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
for _, reward := range command.Rewards {
|
|
slot, addErr := addRewardItem(&inventory, command.MainSlots, reward)
|
|
if addErr != nil {
|
|
return addErr
|
|
}
|
|
result.ItemSlots = append(result.ItemSlots, slot)
|
|
}
|
|
nextFloor := clearedFloor
|
|
completed := int(clearedFloor) == len(command.Route.DungeonIDs)
|
|
if !completed {
|
|
nextFloor++
|
|
}
|
|
result.Snapshot = Snapshot{
|
|
Family: command.Route.Family, Floor: nextFloor,
|
|
DungeonID: command.Route.DungeonIDs[int(nextFloor)-1], Completed: completed,
|
|
}
|
|
settings.Values[floorStorageKey(command.Route.Family)] = strconv.FormatUint(uint64(nextFloor), 10)
|
|
if completed {
|
|
settings.Values[completedStorageKey(command.Route.Family)] = "1"
|
|
} else {
|
|
delete(settings.Values, completedStorageKey(command.Route.Family))
|
|
}
|
|
settings.Values[receiptKey] = formatSettlementReceipt(command.ClearedDungeonID, result.Snapshot, result.ItemSlots)
|
|
settings.UpdatedAt = now
|
|
inventory.UpdatedAt = now
|
|
if len(command.Rewards) > 0 {
|
|
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result.Inventory = inventory
|
|
return dnfrepo.SaveSettingsFields(ctx, tx.Settings, settings, dnfrepo.SettingsFieldValues)
|
|
})
|
|
if err != nil {
|
|
return SettlementResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateRewards(command SettlementCommand) error {
|
|
for _, reward := range command.Rewards {
|
|
if reward.Stack.ItemID <= 0 || reward.Stack.Count <= 0 || (!reward.Stackable && reward.Stack.Count != 1) ||
|
|
reward.StackLimit < 0 || (reward.Stackable && reward.StackLimit > 0 && reward.Stack.Count > reward.StackLimit) {
|
|
return ErrRewardInvalid
|
|
}
|
|
if reward.SlotStart < 3 || reward.SlotEnd < reward.SlotStart {
|
|
if command.MainSlots == 0 {
|
|
return ErrRewardInvalid
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func addRewardItem(record *dnfrepo.InventoryRecord, mainSlots uint16, reward RewardItem) (int16, error) {
|
|
if mainSlots > math.MaxInt16+1 && (reward.SlotStart < 3 || reward.SlotEnd < reward.SlotStart) {
|
|
return 0, ErrRewardInvalid
|
|
}
|
|
// Tower revive coins go to the fixed slot-1 wallet like every other
|
|
// channel (see dnfrevivecoin.Grant).
|
|
if dnfrevivecoin.IsReward(reward.Stack.ItemID) && reward.Stack.Count > 0 {
|
|
if record.Slots == nil {
|
|
record.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
if _, _, err := dnfrevivecoin.GrantSlots(
|
|
record.Slots, reward.Stack.Count, "revive_coin_tower_reward"); err != nil {
|
|
return 0, err
|
|
}
|
|
return dnfrevivecoin.WalletSlot, nil
|
|
}
|
|
start, end := int16(0), int16(mainSlots)-1
|
|
if reward.SlotStart >= 3 && reward.SlotEnd >= reward.SlotStart {
|
|
start, end = reward.SlotStart, reward.SlotEnd
|
|
}
|
|
// PVF [stack limit] 是这个容器里该道具的总量上限:额度按容器里同 id 总量算,
|
|
// 容器里已经有该道具的行时不能再开第二行。装备(每件一行)与没写 [stack limit]
|
|
// 的道具沿用旧行为。
|
|
newRowStackLimit := int64(0)
|
|
space := int64(math.MaxInt32)
|
|
if reward.Stackable && reward.StackLimit > 0 {
|
|
newRowStackLimit = reward.StackLimit
|
|
space = dnfrepo.ContainerItemSpace(
|
|
record.Slots,
|
|
dnfrepo.MainInventoryListType,
|
|
reward.Stack.ItemID,
|
|
reward.StackLimit,
|
|
)
|
|
}
|
|
if reward.Stackable {
|
|
for cursor := int(start); cursor <= int(end); cursor++ {
|
|
slot := int16(cursor)
|
|
key := "0:" + strconv.FormatInt(int64(slot), 10)
|
|
stack, found := record.Slots[key]
|
|
if !found || stack.ItemID != reward.Stack.ItemID || stack.Bind != reward.Stack.Bind ||
|
|
!stack.ExpireAt.Equal(reward.Stack.ExpireAt) || reward.StackLimit == 1 ||
|
|
(reward.StackLimit > 0 && stack.Count > reward.StackLimit-reward.Stack.Count) ||
|
|
reward.Stack.Count > space ||
|
|
stack.Count > math.MaxInt64-reward.Stack.Count {
|
|
continue
|
|
}
|
|
stack.Count += reward.Stack.Count
|
|
updateRewardRawAmount(&stack)
|
|
record.Slots[key] = stack
|
|
return slot, nil
|
|
}
|
|
}
|
|
for cursor := int(start); cursor <= int(end); cursor++ {
|
|
slot := int16(cursor)
|
|
key := "0:" + strconv.FormatInt(int64(slot), 10)
|
|
if _, occupied := record.Slots[key]; occupied {
|
|
continue
|
|
}
|
|
if !dnfrepo.CanOpenContainerItemRow(record.Slots, dnfrepo.MainInventoryListType, reward.Stack.ItemID, newRowStackLimit) {
|
|
break
|
|
}
|
|
record.Slots[key] = cloneRewardStack(reward.Stack)
|
|
return slot, nil
|
|
}
|
|
return 0, ErrInventoryFull
|
|
}
|
|
|
|
func cloneRewardStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
|
|
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
|
if len(stack.Extra) == 0 {
|
|
stack.Extra = nil
|
|
return stack
|
|
}
|
|
extra := make(map[string]string, len(stack.Extra))
|
|
for key, value := range stack.Extra {
|
|
extra[key] = value
|
|
}
|
|
stack.Extra = extra
|
|
return stack
|
|
}
|
|
|
|
func updateRewardRawAmount(stack *dnfrepo.ItemStack) {
|
|
if stack == nil || stack.Count <= 0 || stack.Count > math.MaxUint32 || len(stack.RawEntry) != 0x77 {
|
|
return
|
|
}
|
|
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
|
binary.LittleEndian.PutUint32(stack.RawEntry[0x06:0x0a], uint32(stack.Count))
|
|
}
|
|
|
|
func SettingsScope(characterID string) string {
|
|
return "character:" + strings.TrimSpace(characterID) + settingsSuffix
|
|
}
|
|
|
|
func validateRoute(route dnfdungeonadmission.TowerRoute) error {
|
|
if (route.Family != dnfdungeonadmission.TowerFamilyDespair && route.Family != dnfdungeonadmission.TowerFamilyGrief) ||
|
|
len(route.DungeonIDs) == 0 || len(route.DungeonIDs) > int(^uint16(0)) {
|
|
return fmt.Errorf("%w: family=%q floors=%d", ErrCommandInvalid, route.Family, len(route.DungeonIDs))
|
|
}
|
|
seen := make(map[uint32]struct{}, len(route.DungeonIDs))
|
|
for _, dungeonID := range route.DungeonIDs {
|
|
if dungeonID == 0 {
|
|
return fmt.Errorf("%w: family=%q contains dungeon 0", ErrCommandInvalid, route.Family)
|
|
}
|
|
if _, duplicate := seen[dungeonID]; duplicate {
|
|
return fmt.Errorf("%w: family=%q duplicate dungeon=%d", ErrCommandInvalid, route.Family, dungeonID)
|
|
}
|
|
seen[dungeonID] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func snapshotFromValues(route dnfdungeonadmission.TowerRoute, values map[string]string) (Snapshot, error) {
|
|
floor := uint64(1)
|
|
if raw := strings.TrimSpace(values[floorStorageKey(route.Family)]); raw != "" {
|
|
parsed, err := strconv.ParseUint(raw, 10, 16)
|
|
if err != nil || parsed == 0 || parsed > uint64(len(route.DungeonIDs)) {
|
|
return Snapshot{}, fmt.Errorf("%w: family=%s floor=%q", ErrProgressMismatch, route.Family, raw)
|
|
}
|
|
floor = parsed
|
|
}
|
|
completedRaw := strings.TrimSpace(values[completedStorageKey(route.Family)])
|
|
if completedRaw != "" && completedRaw != "1" {
|
|
return Snapshot{}, fmt.Errorf("%w: family=%s completed=%q", ErrProgressMismatch, route.Family, completedRaw)
|
|
}
|
|
return Snapshot{
|
|
Family: route.Family, Floor: uint16(floor),
|
|
DungeonID: route.DungeonIDs[int(floor)-1], Completed: completedRaw == "1",
|
|
}, nil
|
|
}
|
|
|
|
func routeFloorForDungeon(route dnfdungeonadmission.TowerRoute, dungeonID uint32) uint16 {
|
|
for index, candidate := range route.DungeonIDs {
|
|
if candidate == dungeonID {
|
|
return uint16(index + 1)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func floorStorageKey(family dnfdungeonadmission.TowerFamily) string {
|
|
return "tower:" + string(family) + ":floor"
|
|
}
|
|
|
|
func completedStorageKey(family dnfdungeonadmission.TowerFamily) string {
|
|
return "tower:" + string(family) + ":completed"
|
|
}
|
|
|
|
func receiptStorageKey(family dnfdungeonadmission.TowerFamily, receipt string) string {
|
|
digest := sha256.Sum256([]byte(strings.TrimSpace(receipt)))
|
|
return "tower:" + string(family) + ":receipt:" + hex.EncodeToString(digest[:16])
|
|
}
|
|
|
|
func formatReceiptSnapshot(clearedDungeonID uint32, snapshot Snapshot) string {
|
|
completed := 0
|
|
if snapshot.Completed {
|
|
completed = 1
|
|
}
|
|
return fmt.Sprintf("%d:%d:%d", clearedDungeonID, snapshot.Floor, completed)
|
|
}
|
|
|
|
func formatSettlementReceipt(clearedDungeonID uint32, snapshot Snapshot, slots []int16) string {
|
|
value := formatReceiptSnapshot(clearedDungeonID, snapshot)
|
|
if len(slots) == 0 {
|
|
return value
|
|
}
|
|
parts := make([]string, 0, len(slots))
|
|
for _, slot := range slots {
|
|
parts = append(parts, strconv.FormatInt(int64(slot), 10))
|
|
}
|
|
return value + "|" + strings.Join(parts, ",")
|
|
}
|
|
|
|
func parseSettlementReceipt(
|
|
route dnfdungeonadmission.TowerRoute,
|
|
clearedDungeonID uint32,
|
|
value string,
|
|
) (Snapshot, []int16, error) {
|
|
parts := strings.SplitN(value, "|", 2)
|
|
snapshot, err := parseReceiptSnapshot(route, clearedDungeonID, parts[0])
|
|
if err != nil || len(parts) == 1 || strings.TrimSpace(parts[1]) == "" {
|
|
return snapshot, nil, err
|
|
}
|
|
values := strings.Split(parts[1], ",")
|
|
slots := make([]int16, 0, len(values))
|
|
for _, raw := range values {
|
|
parsed, parseErr := strconv.ParseInt(strings.TrimSpace(raw), 10, 16)
|
|
if parseErr != nil || parsed < 0 {
|
|
return Snapshot{}, nil, fmt.Errorf("%w: malformed reward slots", ErrProgressMismatch)
|
|
}
|
|
slots = append(slots, int16(parsed))
|
|
}
|
|
return snapshot, slots, nil
|
|
}
|
|
|
|
func parseReceiptSnapshot(
|
|
route dnfdungeonadmission.TowerRoute,
|
|
clearedDungeonID uint32,
|
|
value string,
|
|
) (Snapshot, error) {
|
|
parts := strings.Split(value, ":")
|
|
if len(parts) != 3 {
|
|
return Snapshot{}, fmt.Errorf("%w: malformed receipt", ErrProgressMismatch)
|
|
}
|
|
storedDungeon, dungeonErr := strconv.ParseUint(parts[0], 10, 32)
|
|
floor, floorErr := strconv.ParseUint(parts[1], 10, 16)
|
|
completed, completedErr := strconv.ParseUint(parts[2], 10, 1)
|
|
if dungeonErr != nil || floorErr != nil || completedErr != nil || uint32(storedDungeon) != clearedDungeonID ||
|
|
floor == 0 || floor > uint64(len(route.DungeonIDs)) {
|
|
return Snapshot{}, fmt.Errorf("%w: conflicting receipt", ErrProgressMismatch)
|
|
}
|
|
return Snapshot{
|
|
Family: route.Family, Floor: uint16(floor), DungeonID: route.DungeonIDs[int(floor)-1], Completed: completed == 1,
|
|
}, nil
|
|
}
|
|
|
|
func contextOrBackground(ctx context.Context) context.Context {
|
|
if ctx == nil {
|
|
return context.Background()
|
|
}
|
|
return ctx
|
|
}
|