服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
287 lines
7.9 KiB
Go
287 lines
7.9 KiB
Go
package dungeon
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const tutorialRewardMarkerValue = int64(1)
|
|
|
|
var (
|
|
ErrTutorialRewardInvalid = errors.New("dungeon tutorial reward is invalid")
|
|
ErrTutorialInventoryFull = errors.New("dungeon tutorial reward inventory is full")
|
|
ErrTutorialAssetsMissing = errors.New("dungeon tutorial reward assets are unavailable")
|
|
)
|
|
|
|
type TutorialItemReward struct {
|
|
Progress uint32
|
|
ItemID uint32
|
|
Count uint32
|
|
Consumable bool
|
|
StackLimit int64
|
|
SlotStart int16
|
|
SlotEnd int16
|
|
ExpireAt time.Time
|
|
PVFPath string
|
|
StackableType string
|
|
}
|
|
|
|
type TutorialRewardCommand struct {
|
|
CharacterID string
|
|
Progress uint32
|
|
Rewards []TutorialItemReward
|
|
UpdatedAt time.Time
|
|
Project StackProjector
|
|
}
|
|
|
|
type TutorialRewardRow struct {
|
|
Slot uint16
|
|
ItemID uint32
|
|
Count uint32
|
|
}
|
|
|
|
type TutorialRewardResult struct {
|
|
Granted bool
|
|
Rows []TutorialRewardRow
|
|
}
|
|
|
|
func (o *Owner) GrantTutorialReward(ctx context.Context, cmd TutorialRewardCommand) (TutorialRewardResult, error) {
|
|
if o == nil || o.assets == nil || strings.TrimSpace(cmd.CharacterID) == "" ||
|
|
cmd.Progress == 0 || len(cmd.Rewards) == 0 {
|
|
return TutorialRewardResult{}, ErrTutorialAssetsMissing
|
|
}
|
|
ctx = contextOrBackground(ctx)
|
|
now := updatedAtOrNow(cmd.UpdatedAt)
|
|
marker := TutorialRewardMarker(cmd.Progress)
|
|
|
|
var result TutorialRewardResult
|
|
err := o.assets.WithinCharacterAssets(ctx, cmd.CharacterID, func(
|
|
characters dnfrepo.CharacterRepository,
|
|
inventories dnfrepo.InventoryRepository,
|
|
_ dnfrepo.EquipmentRepository,
|
|
) error {
|
|
character, found, err := characters.Load(ctx, cmd.CharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return ErrTutorialAssetsMissing
|
|
}
|
|
character = dnfrepo.CloneCharacter(character)
|
|
if character.Stats == nil {
|
|
character.Stats = make(map[string]int64)
|
|
}
|
|
if character.Stats[marker] == tutorialRewardMarkerValue {
|
|
return nil
|
|
}
|
|
|
|
inventory, found, err := inventories.Load(ctx, cmd.CharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
inventory = dnfrepo.InventoryRecord{CharacterID: cmd.CharacterID}
|
|
}
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
if inventory.Slots == nil {
|
|
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
rows := make([]TutorialRewardRow, 0, len(cmd.Rewards))
|
|
for _, reward := range cmd.Rewards {
|
|
if reward.Progress != cmd.Progress {
|
|
return fmt.Errorf(
|
|
"%w: requested_progress=%d row_progress=%d",
|
|
ErrTutorialRewardInvalid,
|
|
cmd.Progress,
|
|
reward.Progress,
|
|
)
|
|
}
|
|
slot, err := PlaceTutorialReward(&inventory, cmd.Progress, reward, cmd.Project)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows = append(rows, TutorialRewardRow{
|
|
Slot: slot, ItemID: reward.ItemID, Count: reward.Count,
|
|
})
|
|
}
|
|
character.Stats[marker] = tutorialRewardMarkerValue
|
|
character.UpdatedAt = now
|
|
inventory.UpdatedAt = now
|
|
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
|
return err
|
|
}
|
|
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
|
return err
|
|
}
|
|
result.Granted = true
|
|
result.Rows = rows
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return TutorialRewardResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func TutorialRewardMarker(progress uint32) string {
|
|
return "tutorial_reward_progress_" + strconv.FormatUint(uint64(progress), 10)
|
|
}
|
|
|
|
func PlaceTutorialReward(
|
|
record *dnfrepo.InventoryRecord,
|
|
progress uint32,
|
|
reward TutorialItemReward,
|
|
project StackProjector,
|
|
) (uint16, error) {
|
|
if record == nil || reward.Progress != progress || reward.ItemID == 0 || reward.Count == 0 ||
|
|
!reward.Consumable || reward.SlotStart < 0 || reward.SlotEnd < reward.SlotStart {
|
|
return 0, ErrTutorialRewardInvalid
|
|
}
|
|
if reward.StackLimit > 0 && uint64(reward.Count) > uint64(reward.StackLimit) {
|
|
return 0, ErrTutorialRewardInvalid
|
|
}
|
|
if !reward.ExpireAt.IsZero() && project == nil {
|
|
return 0, ErrStackProjectorRequired
|
|
}
|
|
if record.Slots == nil {
|
|
record.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
// PVF [stack limit] 的"每容器总量"口径本轮只接在邮件/商城/NPC 商店三条链路上;
|
|
// 教学奖励这条落点先保持旧行为,等它单独验收时再接。
|
|
newRowStackLimit := int64(0)
|
|
space := int64(math.MaxInt32)
|
|
canOpenRow := func() bool {
|
|
return space > 0 && dnfrepo.CanOpenContainerItemRow(
|
|
record.Slots,
|
|
dnfrepo.MainInventoryListType,
|
|
int64(reward.ItemID),
|
|
newRowStackLimit,
|
|
)
|
|
}
|
|
if slot, ok, err := stackTutorialRewardInRange(record, reward, project, 3, 8, space); err != nil {
|
|
return 0, err
|
|
} else if ok {
|
|
return uint16(slot), nil
|
|
}
|
|
// 【合并说明 2026-09-18,来自服务端仓库 fefab3b】他们给 firstEmptyMainSlot 加了
|
|
// listType 参数(教程奖励固定进主背包,这里显式传 MainInventoryListType);
|
|
// 我们这一侧的 canOpenRow()(PVF 开行闸门)原样保留。
|
|
// 末位 nil:教程奖励不在副本拾取那条链路上,没有弃置物格位保留。
|
|
if slot, ok := firstEmptyMainSlot(record, dnfrepo.MainInventoryListType, 3, 8, nil); ok && canOpenRow() {
|
|
return insertTutorialReward(record, slot, progress, reward, project)
|
|
}
|
|
if slot, ok, err := stackTutorialRewardInRange(
|
|
record,
|
|
reward,
|
|
project,
|
|
reward.SlotStart,
|
|
reward.SlotEnd,
|
|
space,
|
|
); err != nil {
|
|
return 0, err
|
|
} else if ok {
|
|
return uint16(slot), nil
|
|
}
|
|
// 同上:回退区间也显式用主背包列表,并保留我们的开行闸门。
|
|
if slot, ok := firstEmptyMainSlot(record, dnfrepo.MainInventoryListType, reward.SlotStart, reward.SlotEnd, nil); ok && canOpenRow() {
|
|
return insertTutorialReward(record, slot, progress, reward, project)
|
|
}
|
|
return 0, fmt.Errorf(
|
|
"%w: progress=%d item=%d quick=3..8 fallback=%d..%d",
|
|
ErrTutorialInventoryFull,
|
|
progress,
|
|
reward.ItemID,
|
|
reward.SlotStart,
|
|
reward.SlotEnd,
|
|
)
|
|
}
|
|
|
|
func stackTutorialRewardInRange(
|
|
record *dnfrepo.InventoryRecord,
|
|
reward TutorialItemReward,
|
|
project StackProjector,
|
|
start int16,
|
|
end int16,
|
|
space int64,
|
|
) (int16, bool, error) {
|
|
for slot := start; slot <= end; slot++ {
|
|
key := mainSlotKey(uint16(slot))
|
|
stack, ok := record.Slots[key]
|
|
if !ok || !canStackTutorialReward(stack, reward) {
|
|
continue
|
|
}
|
|
if int64(reward.Count) > space {
|
|
continue
|
|
}
|
|
if !reward.ExpireAt.IsZero() {
|
|
var err error
|
|
stack, err = project(stack, reward.ExpireAt)
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
}
|
|
stack.Count += int64(reward.Count)
|
|
updateDungeonRewardRawAmount(&stack)
|
|
record.Slots[key] = stack
|
|
return slot, true, nil
|
|
}
|
|
return 0, false, nil
|
|
}
|
|
|
|
func canStackTutorialReward(stack dnfrepo.ItemStack, reward TutorialItemReward) bool {
|
|
if stack.ItemID != int64(reward.ItemID) || stack.Bind || stack.Count < 0 {
|
|
return false
|
|
}
|
|
if reward.ExpireAt.IsZero() && !stack.ExpireAt.IsZero() {
|
|
return false
|
|
}
|
|
amount := int64(reward.Count)
|
|
if stack.Count > math.MaxInt64-amount {
|
|
return false
|
|
}
|
|
return reward.StackLimit <= 0 || stack.Count <= reward.StackLimit-amount
|
|
}
|
|
|
|
func insertTutorialReward(
|
|
record *dnfrepo.InventoryRecord,
|
|
slot int16,
|
|
progress uint32,
|
|
reward TutorialItemReward,
|
|
project StackProjector,
|
|
) (uint16, error) {
|
|
if slot <= 0 {
|
|
return 0, ErrTutorialRewardInvalid
|
|
}
|
|
extra := map[string]string{
|
|
"source": "tutorial_pvf_reward",
|
|
"reward_progress": strconv.FormatUint(uint64(progress), 10),
|
|
"item_kind": "stackable",
|
|
"pvf_path": reward.PVFPath,
|
|
"stackable_type": reward.StackableType,
|
|
}
|
|
if reward.StackLimit > 0 {
|
|
extra["stack_limit"] = strconv.FormatInt(reward.StackLimit, 10)
|
|
}
|
|
stack := dnfrepo.ItemStack{
|
|
ItemID: int64(reward.ItemID),
|
|
Count: int64(reward.Count),
|
|
Extra: extra,
|
|
}
|
|
if !reward.ExpireAt.IsZero() {
|
|
var err error
|
|
stack, err = project(stack, reward.ExpireAt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
record.Slots[mainSlotKey(uint16(slot))] = stack
|
|
return uint16(slot), nil
|
|
}
|