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

577 lines
21 KiB
Go

package dungeon
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
"longheng.io/server/internal/modules/dnf/honor"
"longheng.io/server/internal/modules/dnf/onlineevent"
"longheng.io/server/internal/modules/dnf/progression"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/wedding"
)
const (
settlementReceiptPrefix = "dungeon_settlement_receipt_"
settlementReceiptVersion = int64(1)
)
var ErrSettlementStateInvalid = errors.New("dungeon settlement persisted state is invalid")
type SettlementCommand struct {
AccountID string
CharacterID string
CompletionKey string
Tables *progression.Tables
Experience uint32
RecommendedDungeonClear bool
// WeddingCapsule 是婚姻成长胶囊的 PVF 规则;仅当"已婚角色通关推荐副本"时
// 由 bridge 解析并传入,其它情况为 nil,本 owner 会整段跳过。
WeddingCapsule *wedding.CapsuleCatalog
// 活动(onlineevent)进度闸门。这三项都必须在本事务内推进,否则"通关即签到"
// 这条链路就断了——签到类活动的进度只在副本结算里累加,没有第二个写入方。
//
// MonthlyAttendance 月度签到(活动 2370):推荐副本通关计数,每日 2 次
// WeeklyAttendance 周周签到(活动 2208):需要目录来判定当周与补签
// AttendanceMarble 签到弹珠:通关清次数决定额外骰子
//
// 三者都只在 RecommendedDungeonClear 为真时生效。
MonthlyAttendance bool
WeeklyAttendance *onlineevent.WeeklyCatalog
AttendanceMarble *onlineevent.MarbleCatalog
AdventureRuntime *adventuregroup.RuntimeConfig
HonorExpertTables *honor.Tables
MaximumCharacterLevel int
// RentalPointAward and RentalPointLimit are resolved from the current PVF
// rental catalog and committed with the dungeon completion receipt.
RentalPointAward uint32
RentalPointLimit uint32
// AllowZeroExperience opts a completion that legitimately pays no clear
// experience into the ledger. The zero-experience guard below exists to catch
// a caller whose clear-exp computation silently produced nothing; a raid gate
// is the one real case (its reward is the raid phase settlement, not character
// growth), so it has to ask explicitly instead of the guard being relaxed for
// everyone.
AllowZeroExperience bool
// Tower carries a Tower of Despair/Grief clear: its ten frozen reward slots
// and the entry pool it consumes. Nil for every ordinary dungeon.
Tower *TowerClear
// GuildContribution carries the 公会贡献度 events of this clear. Nil for
// every non-member and for clears that never took a roster snapshot.
GuildContribution *GuildContributionClear
UpdatedAt time.Time
}
type SettlementResult struct {
Character dnfrepo.CharacterRecord
Skill dnfrepo.SkillRecord
Inventory dnfrepo.InventoryRecord
ExperienceGain uint32
HonorExpertGain uint32
SPGain int
TPGain int
LuckyStarBefore uint32
LuckyStarApplied uint32
LuckyStarAfter uint32
LuckyStarAttempted bool
Replayed bool
}
// CommitSettlement atomically advances experience and the SP/TP ledger and
// writes an idempotency receipt tied to the accepted dungeon completion.
func (o *Owner) CommitSettlement(ctx context.Context, cmd SettlementCommand) (SettlementResult, error) {
if o == nil || o.settlements == nil || strings.TrimSpace(cmd.CharacterID) == "" ||
strings.TrimSpace(cmd.AccountID) == "" || strings.TrimSpace(cmd.CompletionKey) == "" ||
cmd.Tables == nil {
return SettlementResult{}, ErrOwnerUnavailable
}
// A tower clear pays its reward through the tower window rather than clear
// experience, so a zero-experience tower settlement is legitimate.
// 同理:签到类活动即使这一笔不带经验(例如免经验的活动副本),只要它会推进
// 活动进度,也必须放行——否则玩家通关了却什么都没记上。
if cmd.Experience == 0 && !cmd.AllowZeroExperience && cmd.Tower == nil &&
!(cmd.RecommendedDungeonClear && (cmd.RentalPointAward > 0 || cmd.WeddingCapsule != nil ||
cmd.MonthlyAttendance || cmd.WeeklyAttendance != nil || cmd.AttendanceMarble != nil)) {
return SettlementResult{}, ErrSettlementStateInvalid
}
ctx = contextOrBackground(ctx)
wantHash := sha256.Sum256([]byte(cmd.CompletionKey))
now := updatedAtOrNow(cmd.UpdatedAt)
var result SettlementResult
err := o.settlements.WithinCharacterSettlement(ctx, cmd.CharacterID, func(tx dnfrepo.Group) error {
character, found, err := tx.Character.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || character.CharacterID != cmd.CharacterID ||
strings.TrimSpace(character.AccountID) != strings.TrimSpace(cmd.AccountID) {
return ErrSettlementStateInvalid
}
skill, found, err := tx.Skill.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || skill.CharacterID != cmd.CharacterID {
return ErrSettlementStateInvalid
}
inventory, found, err := tx.Inventory.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || inventory.CharacterID != cmd.CharacterID {
return ErrSettlementStateInvalid
}
character = dnfrepo.CloneCharacter(character)
skill = dnfrepo.CloneSkill(skill)
inventory = dnfrepo.CloneInventory(inventory)
if settlementReceiptMatches(character.Stats, wantHash) {
receipt, err := settlementReceiptValues(character, skill)
if err != nil {
return err
}
result = SettlementResult{
Character: character, Skill: skill, Inventory: inventory,
ExperienceGain: receipt.ExperienceGain,
SPGain: receipt.SPGain,
LuckyStarBefore: receipt.LuckyStarBefore,
LuckyStarApplied: receipt.LuckyStarApplied,
LuckyStarAfter: receipt.LuckyStarAfter,
LuckyStarAttempted: receipt.LuckyStarAttempted,
Replayed: true,
}
return nil
}
experience, present := character.Stats["exp"]
if !present || experience < 0 || uint64(experience) > math.MaxUint32 ||
character.Level <= 0 || skill.Points.SyncedLevel != character.Level {
return ErrSettlementStateInvalid
}
maximumLevel := cmd.MaximumCharacterLevel
if maximumLevel <= 0 {
maximumLevel = progression.MaximumCharacterLevel
}
planned, err := PlanSettlementProgressionAtCap(
cmd.Tables,
character.Level,
uint32(experience),
cmd.Experience,
skill.Points,
maximumLevel,
)
if err != nil {
return err
}
maximumLevelExperience := maximumLevelExperienceGain(
character.Level,
uint32(experience),
cmd.Experience,
cmd.MaximumCharacterLevel,
cmd.Tables,
)
character.Level = planned.Experience.NewLevel
character.Stats["exp"] = int64(planned.Experience.NewExperience)
appliedHonorExpertGain := uint32(0)
if maximumLevelExperience > 0 && cmd.HonorExpertTables != nil {
currentExpert, err := honor.ExpertProgressFromStats(character.Stats)
if err != nil {
return err
}
nextExpert, err := cmd.HonorExpertTables.AdvanceExpert(currentExpert, uint64(maximumLevelExperience))
if err != nil {
return err
}
expertStats, err := honor.ExpertStats(nextExpert)
if err != nil {
return err
}
for key, value := range expertStats {
character.Stats[key] = value
}
appliedHonorExpertGain = maximumLevelExperience
}
if cmd.RecommendedDungeonClear {
// 签到弹珠:通关清次数决定额外骰子(4/8/12 次档)。必须在推荐次数
// 自增之前跑,它读的是"这一局之前"的累计清次数。
if err := onlineevent.RecordMarbleClear(&character, cmd.AttendanceMarble, now); err != nil {
return err
}
count := character.Stats[adventuregroup.RecommendedDungeonClearStatKey]
if count < 0 {
return ErrSettlementStateInvalid
}
if count < math.MaxUint16 {
character.Stats[adventuregroup.RecommendedDungeonClearStatKey] = count + 1
}
// 已婚角色的推荐副本通关同时累加婚姻成长胶囊进度。与推荐次数同事务,
// 所以重放同一 CompletionKey 不会重复计入(回执幂等门在上层)。
if cmd.WeddingCapsule != nil {
if err := wedding.AccumulateCapsuleClear(&character, cmd.WeddingCapsule); err != nil {
return err
}
}
}
// 月度/周签到的进度落在**账号**上,所以有它们就必须把账号行读进同一事务。
monthlyClear := cmd.MonthlyAttendance && cmd.RecommendedDungeonClear
weeklyClear := cmd.WeeklyAttendance != nil && cmd.RecommendedDungeonClear
// 骁勇结晶从 2026-09-24 起对**任意等级**的经验记账(不再只收满级溢出),
// 所以只要这笔结算带了经验、又给了冒险团配置,就必须把账号记录读进来
// 同事务提交;否则未满级角色通关会被 ErrSettlementStateInvalid 卡住。
needsAccount := monthlyClear || weeklyClear || cmd.RentalPointAward > 0 || cmd.Tower != nil ||
(cmd.AdventureRuntime != nil && (cmd.Experience > 0 || maximumLevelExperience > 0))
var account dnfrepo.AccountRecord
accountLoaded := false
if needsAccount {
if tx.Account == nil {
return ErrSettlementStateInvalid
}
loaded, accountFound, err := tx.Account.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
if !accountFound || strings.TrimSpace(loaded.AccountID) != strings.TrimSpace(cmd.AccountID) {
// 账号行缺失时的取舍与怪物击杀一致:这笔结算里只要有**满级部分**
// (进胶囊槽/名誉经验)或者**租赁点券**要落,就整笔失败 —— 那两项
// 是原版规则,静默丢掉比报错更糟;只有"未满级经验镜像进骁勇结晶"
// 时跳过记账、继续照常发角色经验与奖励,不让一行账本缺失把玩家的
// 通关结算整个卡死("改了A把B弄坏"正是要避免的)。
//
// 签到同理:账号行不在就没法记签到进度,但也不能因此把整笔通关
// 卡死——角色经验/技能/背包的写入还在下面。
if cmd.RentalPointAward > 0 || maximumLevelExperience > 0 || cmd.Tower != nil {
return ErrSettlementStateInvalid
}
// 注意:这里不能 return —— 角色经验/技能/背包的写入还在下面。
needsAccount = false
} else {
account = dnfrepo.CloneAccount(loaded)
accountLoaded = true
}
}
if accountLoaded {
if monthlyClear {
if err := onlineevent.RecordMonthlyClear(&account, now); err != nil {
return err
}
}
if weeklyClear {
if err := onlineevent.RecordWeeklyClear(&account, cmd.WeeklyAttendance, now); err != nil {
return err
}
}
}
if cmd.RentalPointAward > 0 {
if cmd.RentalPointLimit == 0 || cmd.RentalPointAward > cmd.RentalPointLimit {
return ErrSettlementStateInvalid
}
current, err := parseLuckyStars(account.Metadata[LuckyStarMetadataKey])
if err != nil {
return fmt.Errorf("%w: invalid %s metadata: %v", ErrSettlementStateInvalid, LuckyStarMetadataKey, err)
}
result.LuckyStarBefore = current
result.LuckyStarAfter = current
result.LuckyStarAttempted = true
if current < cmd.RentalPointLimit {
applied := cmd.RentalPointAward
available := cmd.RentalPointLimit - current
if applied > available {
applied = available
}
result.LuckyStarApplied = applied
result.LuckyStarAfter = current + applied
if applied > 0 {
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
account.Metadata[LuckyStarMetadataKey] = strconv.FormatUint(uint64(result.LuckyStarAfter), 10)
}
}
}
writeSettlementReceipt(
character.Stats,
wantHash,
cmd.Experience,
planned.SkillPoints.SPGain,
planned.Experience.NewLevel,
planned.Experience.NewExperience,
result.LuckyStarAttempted,
result.LuckyStarBefore,
result.LuckyStarApplied,
result.LuckyStarAfter,
)
character.UpdatedAt = now
skill.Points = planned.SkillPoints.New
skill.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldBase, dnfrepo.CharacterFieldStats); err != nil {
return err
}
if err := dnfrepo.SaveSkillFields(ctx, tx.Skill, skill, dnfrepo.SkillFieldPoints); err != nil {
return err
}
if accountLoaded && cmd.AdventureRuntime != nil && (cmd.Experience > 0 || maximumLevelExperience > 0) {
// 骁勇结晶 / 成长胶囊槽 / 账号名誉经验的口径只在 adventuregroup 里
// 定义一次:怪物击杀与任务完成两条路也用同一个函数记入,否则同一笔
// 经验会在不同路径上算出不同结果。
// AwardedExperience = 这次通关经验全额 → 骁勇结晶(任意等级)
// MaximumLevelExperience = 其中的满级溢出 → 胶囊槽 + 名誉经验
if _, err := adventuregroup.ApplyExperienceCredit(
&account,
*cmd.AdventureRuntime,
adventuregroup.ExperienceCredit{
AwardedExperience: uint64(cmd.Experience),
MaximumLevelExperience: uint64(maximumLevelExperience),
},
now,
); err != nil {
return err
}
}
if accountLoaded {
account.UpdatedAt = now
if err := tx.Account.Save(ctx, account); err != nil {
return err
}
}
if cmd.Tower != nil {
// Consume the entry pool (character entry first, then the account
// pool), then apply the frozen ten reward slots. Both live in this
// transaction so a failed grant leaves the floor and the pools
// untouched.
if !accountLoaded {
return ErrSettlementStateInvalid
}
if err := recordTowerEntryClear(&account, &character, cmd.Tower.Kind, now); err != nil {
return err
}
if err := applyTowerClear(&character, &inventory, cmd.Tower, now); err != nil {
return err
}
if err := RecordTower100FloorClear(&account, cmd.Tower.Kind, cmd.Tower.Floor); err != nil {
return err
}
character.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
account.UpdatedAt = now
if err := tx.Account.Save(ctx, account); err != nil {
return err
}
inventory.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
result = SettlementResult{
Character: character, Skill: skill, Inventory: inventory,
ExperienceGain: cmd.Experience,
HonorExpertGain: appliedHonorExpertGain,
SPGain: planned.SkillPoints.SPGain,
TPGain: planned.SkillPoints.TPGain,
LuckyStarBefore: result.LuckyStarBefore,
LuckyStarApplied: result.LuckyStarApplied,
LuckyStarAfter: result.LuckyStarAfter,
LuckyStarAttempted: result.LuckyStarAttempted,
}
return nil
})
if err != nil {
return SettlementResult{}, err
}
return result, nil
}
func maximumLevelExperienceGain(
previousLevel int,
previousExperience uint32,
gain uint32,
maximumLevel int,
tables *progression.Tables,
) uint32 {
if maximumLevel <= 0 || previousLevel <= 0 || previousLevel > maximumLevel || gain == 0 || tables == nil {
return 0
}
if previousLevel >= maximumLevel {
return gain
}
entryExperience, err := tables.ThresholdToNext(maximumLevel - 1)
if err != nil {
return 0
}
total := uint64(previousExperience) + uint64(gain)
if total <= uint64(entryExperience) {
return 0
}
excess := total - uint64(entryExperience)
if excess > math.MaxUint32 {
return math.MaxUint32
}
return uint32(excess)
}
func PlanSettlementProgression(
tables *progression.Tables,
level int,
total uint32,
gain uint32,
points dnfrepo.SkillPointState,
) (progression.ExperienceSkillPointPlan, error) {
return PlanSettlementProgressionAtCap(tables, level, total, gain, points, progression.MaximumCharacterLevel)
}
func PlanSettlementProgressionAtCap(
tables *progression.Tables,
level int,
total uint32,
gain uint32,
points dnfrepo.SkillPointState,
maximumLevel int,
) (progression.ExperienceSkillPointPlan, error) {
if tables == nil || level <= 0 || points.SyncedLevel != level || maximumLevel <= 0 ||
maximumLevel > progression.MaximumCharacterLevel || level > maximumLevel {
return progression.ExperienceSkillPointPlan{}, ErrSettlementStateInvalid
}
newTotal := uint64(total) + uint64(gain)
if newTotal > math.MaxUint32 {
newTotal = math.MaxUint32
}
result := progression.ExperienceResult{
PreviousLevel: level,
PreviousExperience: total,
Gain: gain,
NewLevel: level,
NewExperience: uint32(newTotal),
}
for result.NewLevel < maximumLevel {
threshold, err := tables.ThresholdToNext(result.NewLevel)
if err != nil {
return progression.ExperienceSkillPointPlan{}, err
}
if result.NewExperience < threshold {
break
}
if _, ok := tables.SkillPointsAtLevel(result.NewLevel + 1); !ok {
return progression.ExperienceSkillPointPlan{}, ErrSettlementStateInvalid
}
result.NewLevel++
}
// Max-level ordinary EXP stays at the entry threshold. The caller still
// assigns the full post-cap award to HonorExpert.
if result.NewLevel == maximumLevel && maximumLevel > 1 {
entryThreshold, err := tables.ThresholdToNext(maximumLevel - 1)
if err != nil {
return progression.ExperienceSkillPointPlan{}, err
}
if result.NewExperience > entryThreshold {
result.NewExperience = entryThreshold
result.Saturated = true
}
}
result.LevelsGained = result.NewLevel - result.PreviousLevel
advance, err := tables.AdvanceSkillPoints(points, result.NewLevel)
if err != nil {
return progression.ExperienceSkillPointPlan{}, err
}
return progression.ExperienceSkillPointPlan{Experience: result, SkillPoints: advance}, nil
}
func settlementReceiptMatches(stats map[string]int64, want [sha256.Size]byte) bool {
if stats == nil || stats[settlementReceiptPrefix+"version"] != settlementReceiptVersion {
return false
}
for index := 0; index < 4; index++ {
if uint64(stats[fmt.Sprintf("%shash_%d", settlementReceiptPrefix, index)]) !=
binary.LittleEndian.Uint64(want[index*8:(index+1)*8]) {
return false
}
}
return true
}
func writeSettlementReceipt(
stats map[string]int64,
hash [sha256.Size]byte,
gain uint32,
spGain int,
newLevel int,
newExperience uint32,
luckyStarAttempted bool,
luckyStarBefore uint32,
luckyStarApplied uint32,
luckyStarAfter uint32,
) {
stats[settlementReceiptPrefix+"version"] = settlementReceiptVersion
for index := 0; index < 4; index++ {
stats[fmt.Sprintf("%shash_%d", settlementReceiptPrefix, index)] =
int64(binary.LittleEndian.Uint64(hash[index*8 : (index+1)*8]))
}
stats[settlementReceiptPrefix+"experience_gain"] = int64(gain)
stats[settlementReceiptPrefix+"sp_gain"] = int64(spGain)
stats[settlementReceiptPrefix+"new_level"] = int64(newLevel)
stats[settlementReceiptPrefix+"new_experience"] = int64(newExperience)
stats[settlementReceiptPrefix+"lucky_star_attempted"] = 0
if luckyStarAttempted {
stats[settlementReceiptPrefix+"lucky_star_attempted"] = 1
}
stats[settlementReceiptPrefix+"lucky_star_before"] = int64(luckyStarBefore)
stats[settlementReceiptPrefix+"lucky_star_applied"] = int64(luckyStarApplied)
stats[settlementReceiptPrefix+"lucky_star_after"] = int64(luckyStarAfter)
}
type settlementReceipt struct {
ExperienceGain uint32
SPGain int
LuckyStarBefore uint32
LuckyStarApplied uint32
LuckyStarAfter uint32
LuckyStarAttempted bool
}
func settlementReceiptValues(
character dnfrepo.CharacterRecord,
skill dnfrepo.SkillRecord,
) (settlementReceipt, error) {
stats := character.Stats
gain, gainPresent := stats[settlementReceiptPrefix+"experience_gain"]
spGain := stats[settlementReceiptPrefix+"sp_gain"]
if !gainPresent || gain < 0 || gain > math.MaxUint32 || spGain < 0 || spGain > math.MaxInt ||
stats[settlementReceiptPrefix+"new_level"] != int64(character.Level) ||
stats[settlementReceiptPrefix+"new_experience"] != stats["exp"] ||
skill.Points.SyncedLevel != character.Level {
return settlementReceipt{}, ErrSettlementStateInvalid
}
receipt := settlementReceipt{ExperienceGain: uint32(gain), SPGain: int(spGain)}
attempted := stats[settlementReceiptPrefix+"lucky_star_attempted"]
if attempted == 0 {
if stats[settlementReceiptPrefix+"lucky_star_before"] != 0 ||
stats[settlementReceiptPrefix+"lucky_star_applied"] != 0 ||
stats[settlementReceiptPrefix+"lucky_star_after"] != 0 {
return settlementReceipt{}, ErrSettlementStateInvalid
}
return receipt, nil
}
if attempted != 1 {
return settlementReceipt{}, ErrSettlementStateInvalid
}
before := stats[settlementReceiptPrefix+"lucky_star_before"]
applied := stats[settlementReceiptPrefix+"lucky_star_applied"]
after := stats[settlementReceiptPrefix+"lucky_star_after"]
if before < 0 || before > math.MaxUint32 || applied < 0 || applied > math.MaxUint32 ||
after < 0 || after > math.MaxUint32 || before+applied != after {
return settlementReceipt{}, ErrSettlementStateInvalid
}
receipt.LuckyStarBefore = uint32(before)
receipt.LuckyStarApplied = uint32(applied)
receipt.LuckyStarAfter = uint32(after)
receipt.LuckyStarAttempted = true
return receipt, nil
}