DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,286 @@
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
}