服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
275 lines
9.2 KiB
Go
275 lines
9.2 KiB
Go
// Package carrygold owns the gold carry-cap rule: the PVF base limit a
|
|
// character's level grants, the durable upgrade stage the player has bought on
|
|
// top of it, and the credit helper every gold faucet uses so the cap actually
|
|
// bites when gold is gained instead of only being displayed.
|
|
//
|
|
// The base limit is real PVF data, not a constant: `etc/(r)goldlimitbylevel.etc`
|
|
// carries `[gold limit from level]` as level/limit pairs, rising from 0 at
|
|
// level 0 to 400,000,000 from level 60 on. The upgrade adds one 亿 per stage on
|
|
// top of that, which is why the client's confirm dialog offers 4亿 -> 5亿 for a
|
|
// level-capped character.
|
|
//
|
|
// The stage itself is decoded from the current client, which is authoritative
|
|
// for this feature because the release-side server had no handler for it at
|
|
// all — the op978 request was received, dropped, and the confirm button
|
|
// therefore did nothing.
|
|
//
|
|
// Request, legacy cmd1/type978 built by sub_1AEBF10:
|
|
//
|
|
// mov al, [0x052B5C04] ; the client's own cached stage
|
|
// inc al
|
|
// push 978
|
|
// ... ; put_header
|
|
// mov [ebp-7], al ; the only field the builder writes
|
|
// push 0x0E; push [ebp-0x14]; put_binary
|
|
//
|
|
// The blob is 14 bytes copied straight off the stack and only its **last**
|
|
// byte is initialized, so bytes 0..12 are uninitialized stack residue and must
|
|
// be ignored.
|
|
//
|
|
// Response, class1/op978 read by sub_1D23D60: the ordinary success envelope
|
|
// byte must be non-zero, then u8 stage and u32 result. A non-zero result takes
|
|
// the client's failure branch; zero stores the stage into 0x052B5C04 and
|
|
// formats client string 0x1194D with stage+4.
|
|
//
|
|
// Bounds are the client's own: sub_1AEBFF0 and three other sites refuse to
|
|
// offer an upgrade once the stage reaches 4, and the stage is displayed as
|
|
// stage+4, so the five stages read 4..8 (亿) in game.
|
|
package carrygold
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"sync/atomic"
|
|
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
// StageStat is the durable character stat holding the purchased stage.
|
|
StageStat = "carry_gold_stage"
|
|
|
|
// MaxStage is the last stage the client will ever offer.
|
|
MaxStage = byte(4)
|
|
|
|
// StageCost is the flat price the client promises the player. Its confirm
|
|
// dialog reads "扩充时将消耗500万金币" while raising the cap by one 亿, and
|
|
// that number is baked into the client string: it is absent from the
|
|
// request, from the runtime Script.pvf, and from every code immediate in
|
|
// DNF.exe, so nothing per-stage can be derived and the charge is flat.
|
|
StageCost = int64(5_000_000)
|
|
|
|
// Unit is one 亿, the granularity one purchased stage adds.
|
|
Unit = int64(100_000_000)
|
|
|
|
// DisplayedBase is the cap the client's own string formats at stage 0. It
|
|
// is the level-capped case, which is the only one the dialog can show.
|
|
DisplayedBase = 4
|
|
|
|
// LevelLimitPath is the PVF table holding the per-level base cap.
|
|
LevelLimitPath = "etc/(r)goldlimitbylevel.etc"
|
|
|
|
// LevelLimitKey is the section inside that table.
|
|
LevelLimitKey = "gold limit from level"
|
|
)
|
|
|
|
var (
|
|
ErrSourceRequired = errors.New("carry gold pvf source is required")
|
|
ErrLevelLimitTable = errors.New("carry gold level limit table is malformed")
|
|
)
|
|
|
|
// Source is the PVF text reader the loader needs.
|
|
type Source interface {
|
|
ReadText(relativePath string) (string, error)
|
|
}
|
|
|
|
type levelLimitEntry struct {
|
|
level int
|
|
limit int64
|
|
}
|
|
|
|
// LevelLimits is the parsed `[gold limit from level]` table. It is immutable
|
|
// once loaded.
|
|
type LevelLimits struct {
|
|
entries []levelLimitEntry
|
|
}
|
|
|
|
// LoadLevelLimits parses the real PVF table. It never falls back to a guessed
|
|
// table: a missing or malformed file is an error the caller has to surface.
|
|
func LoadLevelLimits(ctx context.Context, source Source) (*LevelLimits, error) {
|
|
if source == nil {
|
|
return nil, ErrSourceRequired
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
text, err := source.ReadText(LevelLimitPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", LevelLimitPath, err)
|
|
}
|
|
document, err := dnfpvf.Parse(LevelLimitPath, text)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", LevelLimitPath, err)
|
|
}
|
|
return parseLevelLimits(document.Ints(LevelLimitKey))
|
|
}
|
|
|
|
func parseLevelLimits(values []int64) (*LevelLimits, error) {
|
|
if len(values) < 2 || len(values)%2 != 0 {
|
|
return nil, fmt.Errorf("%w: %s has %d values", ErrLevelLimitTable, LevelLimitKey, len(values))
|
|
}
|
|
entries := make([]levelLimitEntry, 0, len(values)/2)
|
|
for index := 0; index+1 < len(values); index += 2 {
|
|
level := values[index]
|
|
limit := values[index+1]
|
|
if level < 0 || limit < 0 {
|
|
return nil, fmt.Errorf("%w: level=%d limit=%d", ErrLevelLimitTable, level, limit)
|
|
}
|
|
entries = append(entries, levelLimitEntry{level: int(level), limit: limit})
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].level < entries[j].level })
|
|
return &LevelLimits{entries: entries}, nil
|
|
}
|
|
|
|
// Limit is the base cap a character of this level may hold, before any bought
|
|
// stage. Levels between two rows keep the lower row's value, and a level above
|
|
// the last row keeps the last row's value, which is how the client's own table
|
|
// lookup behaves.
|
|
func (l *LevelLimits) Limit(level int) int64 {
|
|
if l == nil || len(l.entries) == 0 {
|
|
return 0
|
|
}
|
|
if level < l.entries[0].level {
|
|
return l.entries[0].limit
|
|
}
|
|
index := sort.Search(len(l.entries), func(i int) bool { return l.entries[i].level > level })
|
|
return l.entries[index-1].limit
|
|
}
|
|
|
|
// Len reports how many level rows were parsed. It exists so the preload can
|
|
// log what it installed.
|
|
func (l *LevelLimits) Len() int {
|
|
if l == nil {
|
|
return 0
|
|
}
|
|
return len(l.entries)
|
|
}
|
|
|
|
// installed holds the process-wide table. Static game data is read by every
|
|
// gold faucet in every module, so it is installed once at startup instead of
|
|
// being threaded through a dozen commands. A nil table means "not loaded", and
|
|
// then no cap is applied at all — the rule refuses to invent a limit the PVF
|
|
// did not supply.
|
|
var installed atomic.Pointer[LevelLimits]
|
|
|
|
// Install publishes the table for every faucet. The dnfbridge PVF preload owns
|
|
// the single call; passing nil clears it, which only tests do.
|
|
func Install(limits *LevelLimits) {
|
|
installed.Store(limits)
|
|
}
|
|
|
|
// Installed returns the published table, or nil when none has been loaded.
|
|
func Installed() *LevelLimits {
|
|
return installed.Load()
|
|
}
|
|
|
|
// Stage reads the durable stage out of a character stat map, clamped to what
|
|
// the client accepts. A missing or non-positive stat is stage zero, which is
|
|
// the un-upgraded default.
|
|
func Stage(stats map[string]int64) byte {
|
|
if stats == nil {
|
|
return 0
|
|
}
|
|
return StageFromValue(stats[StageStat])
|
|
}
|
|
|
|
// StageFromValue clamps a raw persisted value into the client's stage range.
|
|
func StageFromValue(value int64) byte {
|
|
if value <= 0 {
|
|
return 0
|
|
}
|
|
if value >= int64(MaxStage) {
|
|
return MaxStage
|
|
}
|
|
return byte(value)
|
|
}
|
|
|
|
// DisplayedLimit reproduces the client's own string, which formats stage+4 (亿)
|
|
// regardless of level. It is a log/UI value, never the rule.
|
|
func DisplayedLimit(stage byte) int {
|
|
return DisplayedBase + int(StageFromValue(int64(stage)))
|
|
}
|
|
|
|
// StageBonus is what one purchased stage adds on top of the PVF base limit.
|
|
func StageBonus(stage byte) int64 {
|
|
return int64(StageFromValue(int64(stage))) * Unit
|
|
}
|
|
|
|
// LimitForCharacter is the character's effective carry cap: the PVF base limit
|
|
// for its level plus the bought stages. It is zero when no PVF table has been
|
|
// installed, which every helper here reads as "uncapped".
|
|
func LimitForCharacter(character dnfrepo.CharacterRecord) int64 {
|
|
table := Installed()
|
|
if table == nil {
|
|
return 0
|
|
}
|
|
base := table.Limit(character.Level)
|
|
if base <= 0 {
|
|
return 0
|
|
}
|
|
return base + StageBonus(Stage(character.Stats))
|
|
}
|
|
|
|
// CreditBalance applies amount to balance without ever exceeding limit, and
|
|
// reports how much had to be discarded. A limit of zero means uncapped.
|
|
//
|
|
// It deliberately never *reduces* an existing balance. Release-side gold was
|
|
// never capped, so a character can already hold more than its level and stage
|
|
// allow; clamping such a balance downwards would delete gold the player owns.
|
|
// A balance already at or above the limit simply gains nothing.
|
|
func CreditBalance(balance int64, amount int64, limit int64) (after int64, discarded int64) {
|
|
if amount <= 0 || limit <= 0 {
|
|
return balance + amount, 0
|
|
}
|
|
if balance >= limit {
|
|
return balance, amount
|
|
}
|
|
room := limit - balance
|
|
if amount <= room {
|
|
return balance + amount, 0
|
|
}
|
|
return limit, amount - room
|
|
}
|
|
|
|
// Credit is CreditBalance against the character's own level and stage.
|
|
func Credit(character dnfrepo.CharacterRecord, amount int64) (after int64, discarded int64) {
|
|
var balance int64
|
|
if character.Stats != nil {
|
|
balance = character.Stats["gold"]
|
|
}
|
|
return CreditBalance(balance, amount, LimitForCharacter(character))
|
|
}
|
|
|
|
// Room is how much more gold the character may still gain. It is zero once the
|
|
// balance has reached the cap, and math.MaxInt64 when no cap is installed.
|
|
func Room(character dnfrepo.CharacterRecord) int64 {
|
|
limit := LimitForCharacter(character)
|
|
if limit <= 0 {
|
|
return int64(^uint64(0) >> 1)
|
|
}
|
|
var balance int64
|
|
if character.Stats != nil {
|
|
balance = character.Stats["gold"]
|
|
}
|
|
if balance >= limit {
|
|
return 0
|
|
}
|
|
return limit - balance
|
|
}
|