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,481 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package dungeontower
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestOwnerAdvancesCharacterTowerFloorExactlyOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "7", AccountID: "account-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route := dnfdungeonadmission.TowerRoute{
|
||||
Family: dnfdungeonadmission.TowerFamilyGrief,
|
||||
DungeonIDs: []uint32{5115, 5116, 5117},
|
||||
}
|
||||
before, err := owner.Current(ctx, "7", route)
|
||||
if err != nil || before.Floor != 1 || before.DungeonID != 5115 || before.Completed {
|
||||
t.Fatalf("before=%+v err=%v", before, err)
|
||||
}
|
||||
after, err := owner.Advance(ctx, "7", route, 5115, "clear-1", time.Now())
|
||||
if err != nil || after.Floor != 2 || after.DungeonID != 5116 || after.Replayed {
|
||||
t.Fatalf("after=%+v err=%v", after, err)
|
||||
}
|
||||
replay, err := owner.Advance(ctx, "7", route, 5115, "clear-1", time.Now())
|
||||
if err != nil || replay.Floor != 2 || !replay.Replayed {
|
||||
t.Fatalf("replay=%+v err=%v", replay, err)
|
||||
}
|
||||
current, err := owner.Current(ctx, "7", route)
|
||||
if err != nil || current.Floor != 2 || current.DungeonID != 5116 {
|
||||
t.Fatalf("current=%+v err=%v", current, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerKeepsTowerFamiliesIndependentAndRejectsStaleClear(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "8", AccountID: "account-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
despair := dnfdungeonadmission.TowerRoute{Family: dnfdungeonadmission.TowerFamilyDespair, DungeonIDs: []uint32{11008, 11009}}
|
||||
grief := dnfdungeonadmission.TowerRoute{Family: dnfdungeonadmission.TowerFamilyGrief, DungeonIDs: []uint32{5115, 5116}}
|
||||
if _, err := owner.Advance(ctx, "8", despair, 11008, "despair-clear", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := owner.Advance(ctx, "8", despair, 11008, "different-stale-clear", time.Now()); !errors.Is(err, ErrProgressMismatch) {
|
||||
t.Fatalf("stale clear error=%v", err)
|
||||
}
|
||||
griefCurrent, err := owner.Current(ctx, "8", grief)
|
||||
if err != nil || griefCurrent.Floor != 1 {
|
||||
t.Fatalf("grief=%+v err=%v", griefCurrent, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerAcceptsPVFProvedSkippedFloorAndCapsTopLayer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "9", AccountID: "account-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route := dnfdungeonadmission.TowerRoute{Family: dnfdungeonadmission.TowerFamilyDespair, DungeonIDs: []uint32{11008, 11009, 11010}}
|
||||
afterSkip, err := owner.Advance(ctx, "9", route, 11009, "skip-floor-one", time.Now())
|
||||
if err != nil || afterSkip.Floor != 3 || afterSkip.Completed {
|
||||
t.Fatalf("after skip=%+v err=%v", afterSkip, err)
|
||||
}
|
||||
top, err := owner.Advance(ctx, "9", route, 11010, "top-clear", time.Now())
|
||||
if err != nil || top.Floor != 3 || !top.Completed {
|
||||
t.Fatalf("top=%+v err=%v", top, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerCommitsTowerRewardsAndFloorInOneReceipt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "10", AccountID: "account-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route := dnfdungeonadmission.TowerRoute{Family: dnfdungeonadmission.TowerFamilyDespair, DungeonIDs: []uint32{11008, 11009}}
|
||||
command := SettlementCommand{
|
||||
CharacterID: "10", Route: route, ClearedDungeonID: 11008, Receipt: "tower-settlement-1", MainSlots: 24,
|
||||
Rewards: []RewardItem{{Stack: dnfrepo.ItemStack{ItemID: 1001, Count: 1, Extra: map[string]string{"pvf_path": "equipment/reward.equ"}}, SlotStart: 3, SlotEnd: 23}},
|
||||
UpdatedAt: time.Unix(100, 0).UTC(),
|
||||
}
|
||||
result, err := owner.CommitSettlement(ctx, command)
|
||||
if err != nil || result.Snapshot.Floor != 2 || result.Replayed || len(result.ItemSlots) != 1 {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
stack, found := result.Inventory.Slots["0:3"]
|
||||
if !found || stack.ItemID != 1001 || stack.Count != 1 {
|
||||
t.Fatalf("committed reward=%+v found=%t", stack, found)
|
||||
}
|
||||
replay, err := owner.CommitSettlement(ctx, command)
|
||||
if err != nil || !replay.Replayed || replay.Snapshot.Floor != 2 || len(replay.ItemSlots) != 1 || replay.ItemSlots[0] != 3 {
|
||||
t.Fatalf("replay=%+v err=%v", replay, err)
|
||||
}
|
||||
inventory, _, err := repositories.Inventory.Load(ctx, "10")
|
||||
if err != nil || inventory.Slots["0:3"].Count != 1 {
|
||||
t.Fatalf("inventory after replay=%+v err=%v", inventory, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package dungeontower
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
|
||||
)
|
||||
|
||||
// Tower settlement rewards are their own grant path; revive coins from them
|
||||
// follow the same rule as every other channel.
|
||||
func TestAddRewardItemRoutesReviveCoinsToTheWallet(t *testing.T) {
|
||||
for _, itemID := range []int64{1, 42} {
|
||||
record := &dnfrepo.InventoryRecord{CharacterID: "7", Slots: map[string]dnfrepo.ItemStack{
|
||||
dnfrevivecoin.WalletKey(): {ItemID: 1, Count: 1},
|
||||
}}
|
||||
slot, err := addRewardItem(record, 256, RewardItem{
|
||||
Stack: dnfrepo.ItemStack{ItemID: itemID, Count: 2},
|
||||
Stackable: true,
|
||||
SlotStart: 121,
|
||||
SlotEnd: 176,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("item %d: %v", itemID, err)
|
||||
}
|
||||
if slot != dnfrevivecoin.WalletSlot {
|
||||
t.Fatalf("item %d: slot=%d want %d", itemID, slot, dnfrevivecoin.WalletSlot)
|
||||
}
|
||||
wallet := record.Slots[dnfrevivecoin.WalletKey()]
|
||||
if wallet.ItemID != dnfrevivecoin.WalletItemID || wallet.Count != 3 {
|
||||
t.Fatalf("item %d: wallet=%+v want item 1 x3", itemID, wallet)
|
||||
}
|
||||
if len(record.Slots) != 1 {
|
||||
t.Fatalf("item %d: a loose bag row was left behind: %+v", itemID, record.Slots)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user