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,136 @@
package dungeon
import (
"context"
"errors"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
"longheng.io/server/internal/modules/dnf/progression"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrExperienceAwardStateInvalid = errors.New("dungeon experience award persisted state is invalid")
// ExperienceAwardCommand 是"一次已证明的角色经验结算"的完整输入:计划本身由
// bridge 用运行时 PVF 算好(角色经验/等级/SP + 熟练等级),本命令只负责把它
// 和账号级冒险团状态放进同一个事务。
type ExperienceAwardCommand struct {
AccountID string
CharacterID string
// Plan 由调用方用运行时 PVF 规划(含幂等前置条件 expected → next)。
Plan progression.CommitPlan
// AdventureRuntime 是运行时 PVF 的冒险团配置;AdventureCredit 描述这笔经验
// 怎么进账号账本(任意等级进骁勇结晶,满级部分才进胶囊槽与名誉经验)。
// 两者都缺时只落角色经验。
AdventureRuntime *adventuregroup.RuntimeConfig
AdventureCredit adventuregroup.ExperienceCredit
UpdatedAt time.Time
}
// ExperienceAwardResult 汇报事务内实际提交的状态。
type ExperienceAwardResult struct {
Commit progression.CommitResult
AdventureGroupCredit uint64
AdventureGroupMaximum uint64
AdventureGroupCrystals uint32
}
// CommitExperienceAward 在同一个事务里同时落角色经验/等级/SP 与账号级冒险团
// 状态(骁勇结晶 / 成长胶囊槽 / 名誉经验)。
//
// 为什么必须是一个事务:同一笔经验同时进角色和账号账本。分成两次写,
// 就会出现"经验涨了、结晶没涨"这种既不报错也不自愈的状态 —— 线上长期表现为
// 打怪一辈子换不到一个结晶(怪物经验占一次通关总经验的 3~8 成)。
//
// 口径见 adventuregroup.ExperienceCredit:骁勇结晶收**任意等级**的经验,
// 成长胶囊槽与名誉经验只收满级溢出部分。
//
// 重放(同一次死亡已经落过账)只返回幂等结果,不再记第二笔结晶。
func (o *Owner) CommitExperienceAward(
ctx context.Context,
cmd ExperienceAwardCommand,
) (ExperienceAwardResult, error) {
if o == nil || o.settlements == nil ||
strings.TrimSpace(cmd.CharacterID) == "" || strings.TrimSpace(cmd.AccountID) == "" {
return ExperienceAwardResult{}, ErrOwnerUnavailable
}
ctx = contextOrBackground(ctx)
now := updatedAtOrNow(cmd.UpdatedAt)
var result ExperienceAwardResult
err := o.settlements.WithinCharacterSettlement(ctx, cmd.CharacterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Skill == nil {
return ErrExperienceAwardStateInvalid
}
applied, err := progression.ApplyCommitPlan(ctx, tx.Character, tx.Skill, cmd.Plan)
if err != nil {
return err
}
result.Commit = applied
// 打怪升级必须同步剧情摘要等级(story_digest_last_level)。
//
// 客户端按「由 last_level 选出的段」与「由角色等级选出的段」是否相同
// 决定播放。若升级后 last_level 仍停在旧段,客户端会自行播放该段摘要
// —— 表现为「一到某等级就播剧情回顾/章节过场」。
//
// 单任务交付路径已在 bridge 侧同步;副本内打怪升级不经那条路,
// 必须在同一事务里补上,否则升级瞬间仍有段差。
if applied.Applied && !applied.Idempotent &&
applied.Current.Level > applied.Previous.Level && applied.Current.Level > 0 {
record, found, loadErr := tx.Character.Load(ctx, cmd.CharacterID)
if loadErr != nil {
return loadErr
}
if found {
if record.Stats == nil {
record.Stats = map[string]int64{}
}
current := record.Stats[dnfrepo.CharacterStoryDigestLastLevelStatKey]
if current < int64(applied.Current.Level) {
record.Stats[dnfrepo.CharacterStoryDigestLastLevelStatKey] = int64(applied.Current.Level)
record.Stats[dnfrepo.CharacterStoryDigestMigrationVersionStatKey] =
int64(dnfrepo.CurrentCharacterStoryDigestMigrationVersion)
if saveErr := tx.Character.Save(ctx, record); saveErr != nil {
return saveErr
}
}
}
}
if applied.Idempotent || cmd.AdventureCredit.Normalize().Empty() || cmd.AdventureRuntime == nil {
return nil
}
if tx.Account == nil {
return ErrExperienceAwardStateInvalid
}
credit, err := adventuregroup.CreditExperience(
ctx,
tx.Account,
*cmd.AdventureRuntime,
strings.TrimSpace(cmd.AccountID),
cmd.AdventureCredit,
now,
)
if err != nil {
// 账号行缺失时的取舍:
// - 这笔经验里有**满级部分**(进胶囊槽/名誉经验,属于原版规则)→
// 整笔失败。账号行没了说明库不对,静默丢掉这两个账本比报错更糟。
// - 只有**未满级经验**(2026-09-24 新增的"镜像进骁勇结晶")→
// 跳过记账但照常发经验。这只是加分项,不该因为一行账本缺失
// 就让玩家连杀怪经验都拿不到("改了A把B弄坏"正是要避免的)。
if errors.Is(err, adventuregroup.ErrCreditAccountNotFound) &&
cmd.AdventureCredit.Normalize().MaximumLevelExperience == 0 {
return nil
}
return err
}
result.AdventureGroupCredit = credit.AwardedExperience
result.AdventureGroupMaximum = credit.MaximumLevelExperience
result.AdventureGroupCrystals = credit.BravePoints
return nil
})
if err != nil {
return ExperienceAwardResult{}, err
}
return result, nil
}