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