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