Files
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

137 lines
5.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}