服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
137 lines
5.4 KiB
Go
137 lines
5.4 KiB
Go
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
|
||
}
|