Files
90CNServer/server/internal/modules/dnf/dungeontower/owner.go
T
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

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
}