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

358 lines
12 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 progression
import (
"context"
"errors"
"fmt"
"math"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const characterExperienceStatKey = "exp"
var (
ErrCommitPlanInvalid = errors.New("dnf progression commit plan is invalid")
ErrCommitOwnershipMismatch = errors.New("dnf progression commit ownership mismatch")
ErrCommitStateMissing = errors.New("dnf progression commit state is missing")
ErrCommitStateInvalid = errors.New("dnf progression persisted state is invalid")
ErrCommitVersionPrecondition = errors.New("dnf progression commit version precondition failed")
)
// CommitState is the complete optimistic version used by one progression
// commit. Expected is compared before any write; Next is the caller-computed
// state to persist. The repository layer does not calculate EXP, levels, SP,
// or TP.
type CommitState struct {
Level int
Experience uint32
SkillPoints dnfrepo.SkillPointState
}
// CommitPlan binds a caller-computed Expected -> Next transition to one
// account-owned character. Expected is the version precondition; it avoids a
// lost update without coupling progression to unrelated character fields.
type CommitPlan struct {
CharacterID string
AccountID string
Expected CommitState
Next CommitState
// Allows a saturated same-level legacy overflow to converge back to the
// current PVF max-level entry threshold. Ordinary EXP regressions remain
// invalid.
AllowExperienceCapNormalization bool
// ExpectedCharacterStats protects state that shares the character stats
// record with EXP but has its own domain progression, such as HonorExpert.
ExpectedCharacterStats map[string]int64
NextCharacterStats map[string]int64
}
// CommitPlanFromExperienceSkillPoints maps the existing pure planner result
// into the atomic repository plan without recalculating any rule.
func CommitPlanFromExperienceSkillPoints(
characterID string,
accountID string,
planned ExperienceSkillPointPlan,
) CommitPlan {
return CommitPlan{
CharacterID: characterID,
AccountID: accountID,
Expected: CommitState{
Level: planned.Experience.PreviousLevel,
Experience: planned.Experience.PreviousExperience,
SkillPoints: planned.SkillPoints.Previous,
},
Next: CommitState{
Level: planned.Experience.NewLevel,
Experience: planned.Experience.NewExperience,
SkillPoints: planned.SkillPoints.New,
},
AllowExperienceCapNormalization: planned.Experience.Saturated &&
planned.Experience.NewLevel == planned.Experience.PreviousLevel &&
planned.Experience.NewExperience < planned.Experience.PreviousExperience,
}
}
// CommitResult reports the exact state observed in the transaction. A retry
// that observes Next is successful and Idempotent without issuing a write. If
// a later progression state is present, the Expected precondition fails.
type CommitResult struct {
Previous CommitState
Current CommitState
Applied bool
Idempotent bool
}
// Commit 在角色-技能事务里应用一个 CommitPlan(规则见 ApplyCommitPlan)。
func Commit(
ctx context.Context,
uow dnfrepo.CharacterProgressionUnitOfWork,
plan CommitPlan,
) (CommitResult, error) {
plan.CharacterID = strings.TrimSpace(plan.CharacterID)
plan.AccountID = strings.TrimSpace(plan.AccountID)
if uow == nil {
return CommitResult{}, dnfrepo.ErrCharacterProgressionTransactionUnavailable
}
// 调用方的计划与上下文必须在**开事务之前**判定:无效计划不得打开事务。
// 这条契约由 atomic_commit_test.go 钉着;ApplyCommitPlan 内部会再校验一次。
if err := validateCommitPlan(plan); err != nil {
return CommitResult{}, err
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return CommitResult{}, err
}
var result CommitResult
err := uow.WithinCharacterProgression(
ctx,
plan.CharacterID,
func(characters dnfrepo.CharacterRepository, skills dnfrepo.SkillRepository) error {
applied, applyErr := ApplyCommitPlan(ctx, characters, skills, plan)
result = applied
return applyErr
},
)
if err != nil {
return CommitResult{}, err
}
return result, nil
}
// ApplyCommitPlan 在**调用方已经打开的事务**里应用同一个 CommitPlan:前置校验
// (expected → next)、幂等判定与字段写入全部照旧,只是不再由本函数自己开
// 角色-技能事务。
//
// 需要把「角色经验」和「账号级冒险团状态(骁勇结晶 / 成长胶囊槽 / 荣誉经验)」
// 放进同一个提交的调用方用它 —— 例如怪物击杀经验:同一笔经验同时喂熟练等级和
// 结晶,拆成两次写就会出现"经验涨了、结晶没涨"的静默状态。
func ApplyCommitPlan(
ctx context.Context,
characters dnfrepo.CharacterRepository,
skills dnfrepo.SkillRepository,
plan CommitPlan,
) (CommitResult, error) {
plan.CharacterID = strings.TrimSpace(plan.CharacterID)
plan.AccountID = strings.TrimSpace(plan.AccountID)
if characters == nil || skills == nil {
return CommitResult{}, dnfrepo.ErrCharacterProgressionTransactionUnavailable
}
if err := validateCommitPlan(plan); err != nil {
return CommitResult{}, err
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return CommitResult{}, err
}
character, characterExists, err := characters.Load(ctx, plan.CharacterID)
if err != nil {
return CommitResult{}, err
}
if !characterExists {
return CommitResult{}, fmt.Errorf("%w: character=%s", ErrCommitStateMissing, plan.CharacterID)
}
if strings.TrimSpace(character.CharacterID) != plan.CharacterID || strings.TrimSpace(character.AccountID) != plan.AccountID {
return CommitResult{}, fmt.Errorf(
"%w: character=%s expected_account=%s actual_character=%s actual_account=%s",
ErrCommitOwnershipMismatch,
plan.CharacterID,
plan.AccountID,
character.CharacterID,
character.AccountID,
)
}
skill, skillExists, err := skills.Load(ctx, plan.CharacterID)
if err != nil {
return CommitResult{}, err
}
if !skillExists {
return CommitResult{}, fmt.Errorf("%w: character=%s", ErrCommitStateMissing, plan.CharacterID)
}
if strings.TrimSpace(skill.CharacterID) != plan.CharacterID {
return CommitResult{}, fmt.Errorf(
"%w: character=%s skill_character=%s",
ErrCommitOwnershipMismatch,
plan.CharacterID,
skill.CharacterID,
)
}
current, err := persistedCommitState(character, skill)
if err != nil {
return CommitResult{}, err
}
result := CommitResult{Previous: current, Current: current}
if commitStatesEqual(current, plan.Next) && characterStatsMatch(character.Stats, plan.NextCharacterStats) {
result.Idempotent = true
return result, nil
}
if commitStatesEqual(current, plan.Next) && !commitStatesEqual(current, plan.Expected) {
return CommitResult{}, fmt.Errorf(
"%w: character=%s progression reached next state without matching protected stats",
ErrCommitStateInvalid,
plan.CharacterID,
)
}
if !commitStatesEqual(current, plan.Expected) {
return CommitResult{}, fmt.Errorf(
"%w: character=%s expected=%+v actual=%+v next=%+v",
ErrCommitVersionPrecondition,
plan.CharacterID,
plan.Expected,
current,
plan.Next,
)
}
if !characterStatsMatch(character.Stats, plan.ExpectedCharacterStats) {
return CommitResult{}, fmt.Errorf(
"%w: character=%s expected character stats do not match",
ErrCommitVersionPrecondition,
plan.CharacterID,
)
}
character.Level = plan.Next.Level
character.Stats[characterExperienceStatKey] = int64(plan.Next.Experience)
for key, value := range plan.NextCharacterStats {
character.Stats[key] = value
}
skill.Points = plan.Next.SkillPoints
if err := dnfrepo.SaveCharacterFields(
ctx,
characters,
character,
dnfrepo.CharacterFieldBase,
dnfrepo.CharacterFieldStats,
); err != nil {
return CommitResult{}, err
}
if err := dnfrepo.SaveSkillFields(ctx, skills, skill, dnfrepo.SkillFieldPoints); err != nil {
return CommitResult{}, err
}
result.Current = plan.Next
result.Applied = true
return result, nil
}
func validateCommitPlan(plan CommitPlan) error {
if plan.CharacterID == "" || plan.AccountID == "" {
return fmt.Errorf("%w: character/account ownership is required", ErrCommitPlanInvalid)
}
if err := validateCommitState(plan.Expected); err != nil {
return fmt.Errorf("%w: expected: %v", ErrCommitPlanInvalid, err)
}
if err := validateCommitState(plan.Next); err != nil {
return fmt.Errorf("%w: next: %v", ErrCommitPlanInvalid, err)
}
if plan.Next.Level < plan.Expected.Level {
return fmt.Errorf("%w: level regressed %d -> %d", ErrCommitPlanInvalid, plan.Expected.Level, plan.Next.Level)
}
if plan.Next.Experience < plan.Expected.Experience &&
(!plan.AllowExperienceCapNormalization || plan.Next.Level != plan.Expected.Level) {
return fmt.Errorf("%w: cumulative experience regressed %d -> %d", ErrCommitPlanInvalid, plan.Expected.Experience, plan.Next.Experience)
}
if plan.Next.SkillPoints.TotalSP < plan.Expected.SkillPoints.TotalSP ||
plan.Next.SkillPoints.TotalTP < plan.Expected.SkillPoints.TotalTP {
return fmt.Errorf("%w: total SP/TP regressed expected=%+v next=%+v", ErrCommitPlanInvalid, plan.Expected.SkillPoints, plan.Next.SkillPoints)
}
if err := validateCharacterStatMap(plan.ExpectedCharacterStats); err != nil {
return err
}
if err := validateCharacterStatMap(plan.NextCharacterStats); err != nil {
return err
}
for key := range plan.ExpectedCharacterStats {
if _, found := plan.NextCharacterStats[key]; !found {
return fmt.Errorf("%w: expected character stat %q has no next value", ErrCommitPlanInvalid, key)
}
}
expectedSpentSP := plan.Expected.SkillPoints.TotalSP - plan.Expected.SkillPoints.RemainingSP
nextSpentSP := plan.Next.SkillPoints.TotalSP - plan.Next.SkillPoints.RemainingSP
expectedSpentTP := plan.Expected.SkillPoints.TotalTP - plan.Expected.SkillPoints.RemainingTP
nextSpentTP := plan.Next.SkillPoints.TotalTP - plan.Next.SkillPoints.RemainingTP
if expectedSpentSP != nextSpentSP || expectedSpentTP != nextSpentTP {
return fmt.Errorf(
"%w: progression changed spent points SP=%d->%d TP=%d->%d",
ErrCommitPlanInvalid,
expectedSpentSP,
nextSpentSP,
expectedSpentTP,
nextSpentTP,
)
}
return nil
}
func validateCharacterStatMap(values map[string]int64) error {
for key := range values {
if strings.TrimSpace(key) == "" || key == characterExperienceStatKey {
return fmt.Errorf("%w: invalid character stat key %q", ErrCommitPlanInvalid, key)
}
}
return nil
}
func characterStatsMatch(current map[string]int64, expected map[string]int64) bool {
for key, value := range expected {
if current[key] != value {
return false
}
}
return true
}
func validateCommitState(state CommitState) error {
if state.Level <= 0 || state.Level > MaximumCharacterLevel {
return fmt.Errorf("level=%d outside current DNF90 range 1..%d", state.Level, MaximumCharacterLevel)
}
points := state.SkillPoints
if points.SyncedLevel != state.Level {
return fmt.Errorf("level=%d synced_level=%d", state.Level, points.SyncedLevel)
}
if points.TotalSP < 0 || points.TotalSP > math.MaxUint16 ||
points.RemainingSP < 0 || points.RemainingSP > points.TotalSP ||
points.TotalTP < 0 || points.TotalTP > math.MaxUint16 ||
points.RemainingTP < 0 || points.RemainingTP > points.TotalTP {
return fmt.Errorf("SP/TP ledger outside current EXE u16 range: %+v", points)
}
return nil
}
func persistedCommitState(character dnfrepo.CharacterRecord, skill dnfrepo.SkillRecord) (CommitState, error) {
experience, ok := character.Stats[characterExperienceStatKey]
if !ok {
return CommitState{}, fmt.Errorf("%w: character=%s stat=%s", ErrCommitStateMissing, character.CharacterID, characterExperienceStatKey)
}
if experience < 0 || uint64(experience) > math.MaxUint32 {
return CommitState{}, fmt.Errorf(
"%w: character=%s experience=%d outside current EXE u32 range",
ErrCommitStateInvalid,
character.CharacterID,
experience,
)
}
state := CommitState{
Level: character.Level,
Experience: uint32(experience),
SkillPoints: skill.Points,
}
if err := validateCommitState(state); err != nil {
return CommitState{}, fmt.Errorf("%w: character=%s: %v", ErrCommitStateInvalid, character.CharacterID, err)
}
return state, nil
}
func commitStatesEqual(left, right CommitState) bool {
return left.Level == right.Level &&
left.Experience == right.Experience &&
left.SkillPoints == right.SkillPoints
}