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

221 lines
8.2 KiB
Go

package adventuregroup
import (
"encoding/json"
"fmt"
"math"
"math/bits"
"strconv"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// GrowthMetadataKey is the account-owned adventure-group growth ledger. It is
// separate from the runtime state (shop points / capsule) because its level is
// what the client renders as the adventure-group level.
const GrowthMetadataKey = "adventure_group_growth_v1"
const growthRateScale = uint64(1_000_000)
// GrowthConfig is the PVF `[max adventure level]` / `[adventure exp rate]` /
// `[adventure exp table]` / per-level `[stat]` benefits.
type GrowthConfig struct {
MaximumLevel int
RateParts uint64
// Entry N is the experience needed to enter level N, not a cumulative total.
RequiredExperience []uint64
LevelBenefits []Benefits
}
type growthState struct {
Level int `json:"level"`
Experience uint64 `json:"experience"`
Remainder uint64 `json:"remainder"`
}
func parseGrowthConfig(doc *dnfpvf.Document) (GrowthConfig, error) {
c := GrowthConfig{MaximumLevel: int(firstSectionInt(doc, "max adventure level"))}
if c.MaximumLevel < 1 || c.MaximumLevel > 255 {
return c, fmt.Errorf("%w: adventure maximum level", ErrTableMalformed)
}
tokens, _ := doc.Section("adventure exp rate")
if len(tokens) != 1 || tokenNumber(tokens[0]) <= 0 || tokenNumber(tokens[0]) > 1 {
return c, fmt.Errorf("%w: adventure experience rate", ErrTableMalformed)
}
c.RateParts = uint64(math.Round(tokenNumber(tokens[0]) * float64(growthRateScale)))
c.RequiredExperience = make([]uint64, c.MaximumLevel+1)
c.LevelBenefits = make([]Benefits, c.MaximumLevel+1)
tokens, _ = doc.Section("adventure exp table")
seen := make(map[int]bool)
if len(tokens)%2 != 0 {
return c, fmt.Errorf("%w: adventure experience rows", ErrTableMalformed)
}
for i := 0; i < len(tokens); i += 2 {
level := int(tokens[i].Int)
value, err := strconv.ParseUint(tokens[i+1].Value, 10, 64)
if tokens[i].Kind != dnfpvf.TokenInt || tokens[i+1].Kind != dnfpvf.TokenString || err != nil || level < 1 || seen[level] {
return c, fmt.Errorf("%w: adventure experience row %d", ErrTableMalformed, i/2)
}
seen[level] = true
if level <= c.MaximumLevel {
c.RequiredExperience[level] = value
}
}
for level := 1; level <= c.MaximumLevel; level++ {
if !seen[level] || (level > 1 && c.RequiredExperience[level] == 0) {
return c, fmt.Errorf("%w: adventure experience level %d", ErrTableMalformed, level)
}
}
level := 0
for _, section := range doc.Sections {
values := sectionTokens(doc, section)
switch normalizedPVFName(section.Name) {
case "attribute":
level = int(firstInt(values))
case "/attribute":
level = 0
case "stat", "grow":
if level < 1 || level > c.MaximumLevel || len(values) < 3 {
continue
}
value := tokenNumber(values[2])
if value < 0 || value > math.MaxUint32 {
return c, fmt.Errorf("%w: adventure benefit level %d", ErrTableMalformed, level)
}
switch values[1].Value {
case "str":
c.LevelBenefits[level].ManageOption = uint64(value)
case "clear result exp up":
c.LevelBenefits[level].ExpBonusPercent = uint64(value)
}
}
}
return c, nil
}
// CurrentSummary returns the account's adventure-group level. Once the first
// earned experience is committed the growth ledger owns it; before that the
// legacy roster-derived level is migrated into the ledger so a roster change
// cannot lower an established account.
func (t *Tables) CurrentSummary(account dnfrepo.AccountRecord, characters []Character) (Summary, error) {
legacy, err := t.Calculate(characters)
if err != nil || t.runtime.Growth.MaximumLevel == 0 {
return legacy, err
}
c := t.runtime.Growth
initial := growthState{Level: max(1, min(legacy.ManageLevel, c.MaximumLevel))}
if legacy.ManageLevel > 0 && legacy.ManageLevel < len(t.manageThresholds) && initial.Level < c.MaximumLevel {
lower, upper := t.manageThresholds[legacy.ManageLevel-1], t.manageThresholds[legacy.ManageLevel]
if upper > lower {
hi, lo := bits.Mul64(legacy.TotalPoint-lower, c.RequiredExperience[initial.Level+1])
initial.Experience, _ = bits.Div64(hi, lo, upper-lower)
}
}
state, err := readGrowthState(account, c, Summary{ManageLevel: initial.Level, TotalPoint: initial.Experience})
if err != nil {
return Summary{}, err
}
return Summary{ManageLevel: state.Level, TotalPoint: state.Experience, Benefits: c.LevelBenefits[state.Level]}, nil
}
func readGrowthState(account dnfrepo.AccountRecord, c GrowthConfig, initial Summary) (growthState, error) {
state := growthState{Level: max(1, initial.ManageLevel), Experience: initial.TotalPoint}
if value := strings.TrimSpace(account.Metadata[GrowthMetadataKey]); value != "" {
if err := json.Unmarshal([]byte(value), &state); err != nil {
return state, fmt.Errorf("%w: growth: %v", ErrRuntimeStateInvalid, err)
}
}
if state.Level < 1 || state.Level > c.MaximumLevel || state.Remainder >= growthRateScale ||
(state.Level < c.MaximumLevel && state.Experience >= c.RequiredExperience[state.Level+1]) {
return state, fmt.Errorf("%w: growth level/progress", ErrRuntimeStateInvalid)
}
return state, nil
}
// SeedGrowthState migrates the roster-derived legacy level into the growth
// ledger the first time an account is read with both the account record and
// its roster in hand. Until the ledger exists, CurrentSummary can only show the
// migrated value without persisting it; seeding at login means a later
// experience grant can never start the ledger below an established level.
func SeedGrowthState(account *dnfrepo.AccountRecord, c GrowthConfig, legacy Summary, now time.Time) (bool, error) {
if account == nil || c.MaximumLevel == 0 {
return false, nil
}
if strings.TrimSpace(account.Metadata[GrowthMetadataKey]) != "" {
return false, nil
}
if account.Metadata == nil {
return false, fmt.Errorf("%w: account metadata not loaded", ErrRuntimeStateInvalid)
}
state := growthState{Level: max(1, min(legacy.ManageLevel, c.MaximumLevel)), Experience: legacy.TotalPoint}
if state.Level < c.MaximumLevel && state.Experience >= c.RequiredExperience[state.Level+1] {
state.Experience = 0
}
if state.Level > 1 && state.Level < len(c.RequiredExperience) && c.RequiredExperience[state.Level] > 0 {
// A level carried over from the roster starts its bar from zero; the
// roster's total point is a different scale and must not be reused.
state.Experience = 0
}
encoded, err := json.Marshal(state)
if err != nil {
return false, err
}
account.Metadata[GrowthMetadataKey] = string(encoded)
account.UpdatedAt = now
return true, nil
}
// ApplyGrowthExperience records one earned-experience grant on an account the
// caller has already loaded and is about to save itself. It mirrors the
// ApplyExperienceCredit contract (no repository access), which is what keeps a
// single settlement from double-counting the same experience.
//
// gain is the character experience that was actually awarded. RateParts turns
// it into adventure-group experience; the remainder is carried so many small
// grants still add up to the same total as one large one.
func ApplyGrowthExperience(
account *dnfrepo.AccountRecord,
config GrowthConfig,
initial Summary,
gain uint32,
now time.Time,
) (growthState, error) {
c := config
if account == nil {
return growthState{}, ErrOwnerUnavailable
}
if c.MaximumLevel == 0 || gain == 0 {
return growthState{}, nil
}
if account.Metadata == nil {
return growthState{}, fmt.Errorf("%w: account metadata not loaded", ErrRuntimeStateInvalid)
}
state, err := readGrowthState(*account, c, initial)
if err != nil {
return growthState{}, err
}
scaled := uint64(gain)*c.RateParts + state.Remainder
remaining := scaled / growthRateScale
state.Remainder = scaled % growthRateScale
for state.Level < c.MaximumLevel && remaining >= c.RequiredExperience[state.Level+1]-state.Experience {
remaining -= c.RequiredExperience[state.Level+1] - state.Experience
state.Level++
state.Experience = 0
}
if state.Level == c.MaximumLevel {
state.Experience, state.Remainder = 0, 0
} else {
state.Experience += remaining
}
encoded, err := json.Marshal(state)
if err != nil {
return state, err
}
account.Metadata[GrowthMetadataKey] = string(encoded)
account.UpdatedAt = now
return state, nil
}