DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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
}
@@ -0,0 +1,243 @@
package carrygold
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// realLevelLimitRows is the shape of the runtime table
// `etc/(r)goldlimitbylevel.etc` carries, trimmed to the rows the rules care
// about: the low-level ramp, the level-60 plateau, and the level-90 cap.
var realLevelLimitRows = []int64{
0, 0,
1, 100_000_000,
30, 100_000_000,
60, 400_000_000,
99, 400_000_000,
}
func installTestLevelLimits(t *testing.T, values []int64) {
t.Helper()
limits, err := parseLevelLimits(values)
if err != nil {
t.Fatal(err)
}
previous := Installed()
Install(limits)
t.Cleanup(func() { Install(previous) })
}
func TestStageClampsToTheClientBound(t *testing.T) {
if got := Stage(nil); got != 0 {
t.Fatalf("nil stats stage=%d", got)
}
if got := Stage(map[string]int64{}); got != 0 {
t.Fatalf("missing stat stage=%d", got)
}
if got := Stage(map[string]int64{StageStat: -3}); got != 0 {
t.Fatalf("negative stat stage=%d", got)
}
if got := Stage(map[string]int64{StageStat: 99}); got != MaxStage {
t.Fatalf("clamped stage=%d want %d", got, MaxStage)
}
for stage := byte(0); stage <= MaxStage; stage++ {
if got := DisplayedLimit(stage); got != DisplayedBase+int(stage) {
t.Fatalf("stage=%d displayed=%d", stage, got)
}
if got := StageBonus(stage); got != int64(stage)*Unit {
t.Fatalf("stage=%d bonus=%d", stage, got)
}
}
}
func TestLevelLimitsFollowThePVFTable(t *testing.T) {
limits, err := parseLevelLimits(realLevelLimitRows)
if err != nil {
t.Fatal(err)
}
// A level between two rows keeps the lower row's value, and a level above
// the last row keeps the last row's value.
for level, want := range map[int]int64{
0: 0, 1: 100_000_000, 29: 100_000_000, 30: 100_000_000,
59: 100_000_000, 60: 400_000_000, 90: 400_000_000, 200: 400_000_000,
} {
if got := limits.Limit(level); got != want {
t.Fatalf("level=%d limit=%d want %d", level, got, want)
}
}
if limits.Len() != len(realLevelLimitRows)/2 {
t.Fatalf("rows=%d", limits.Len())
}
if _, err := parseLevelLimits([]int64{1}); !errors.Is(err, ErrLevelLimitTable) {
t.Fatalf("odd table err=%v", err)
}
if _, err := LoadLevelLimits(context.Background(), nil); !errors.Is(err, ErrSourceRequired) {
t.Fatalf("nil source err=%v", err)
}
}
func TestLimitForCharacterAddsTheBoughtStagesToThePVFBase(t *testing.T) {
installTestLevelLimits(t, realLevelLimitRows)
capped := dnfrepo.CharacterRecord{Level: 90, Stats: map[string]int64{}}
if got := LimitForCharacter(capped); got != 400_000_000 {
t.Fatalf("level 90 stage 0 limit=%d", got)
}
capped.Stats[StageStat] = int64(MaxStage)
// Four bought stages read as 8亿 in game, which is what the client's own
// stage+4 string promises at the level cap.
if got := LimitForCharacter(capped); got != 800_000_000 {
t.Fatalf("level 90 stage 4 limit=%d", got)
}
// A low-level character gets the PVF base for its level, not the 4亿 the
// dialog shows, plus the same per-stage 亿.
low := dnfrepo.CharacterRecord{Level: 30, Stats: map[string]int64{StageStat: 1}}
if got := LimitForCharacter(low); got != 200_000_000 {
t.Fatalf("level 30 stage 1 limit=%d", got)
}
// Level 0 has no PVF allowance at all, which must read as uncapped rather
// than as a zero cap that would swallow every gold gain.
if got := LimitForCharacter(dnfrepo.CharacterRecord{Level: 0}); got != 0 {
t.Fatalf("level 0 limit=%d", got)
}
}
func TestNoInstalledTableLeavesEveryFaucetUncapped(t *testing.T) {
previous := Installed()
Install(nil)
t.Cleanup(func() { Install(previous) })
character := dnfrepo.CharacterRecord{Level: 90, Stats: map[string]int64{"gold": 900_000_000}}
if got := LimitForCharacter(character); got != 0 {
t.Fatalf("uninstalled limit=%d", got)
}
after, discarded := Credit(character, 1_000)
if after != 900_001_000 || discarded != 0 {
t.Fatalf("uninstalled credit after=%d discarded=%d", after, discarded)
}
}
func TestCreditStopsAtTheCapAndNeverLowersAnExistingBalance(t *testing.T) {
installTestLevelLimits(t, realLevelLimitRows)
character := dnfrepo.CharacterRecord{Level: 90, Stats: map[string]int64{"gold": 399_000_000}}
after, discarded := Credit(character, 5_000_000)
if after != 400_000_000 || discarded != 4_000_000 {
t.Fatalf("credit at cap after=%d discarded=%d", after, discarded)
}
// One purchased stage buys exactly one 亿 more room.
character.Stats[StageStat] = 1
after, discarded = Credit(character, 5_000_000)
if after != 404_000_000 || discarded != 0 {
t.Fatalf("credit after upgrade after=%d discarded=%d", after, discarded)
}
// Release-side gold was never capped, so an existing balance above the cap
// must be left alone rather than clamped down and destroyed.
over := dnfrepo.CharacterRecord{Level: 90, Stats: map[string]int64{"gold": 900_000_000}}
after, discarded = Credit(over, 1_000)
if after != 900_000_000 || discarded != 1_000 {
t.Fatalf("credit above cap after=%d discarded=%d", after, discarded)
}
if got := Room(over); got != 0 {
t.Fatalf("room above cap=%d", got)
}
if got := Room(dnfrepo.CharacterRecord{Level: 90, Stats: map[string]int64{"gold": 100_000_000}}); got != 300_000_000 {
t.Fatalf("room=%d", got)
}
}
func newUpgradeOwner(t *testing.T, gold int64, stage int64) (*Owner, dnfrepo.Group) {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
stats := map[string]int64{"gold": gold}
if stage > 0 {
stats[StageStat] = stage
}
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "17", AccountID: "account-1", Level: 90, Stats: stats,
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
return owner, repositories
}
func TestOwnerUpgradeChargesAndPersistsTheNextStage(t *testing.T) {
installTestLevelLimits(t, realLevelLimitRows)
owner, repositories := newUpgradeOwner(t, 250_000_000, 0)
result, err := owner.Upgrade(context.Background(), Command{
AccountID: "account-1", SelectedCharacterID: 17, RequestedStage: 1,
})
if err != nil {
t.Fatal(err)
}
if !result.Upgraded || result.Stage != 1 || result.PreviousStage != 0 ||
result.GoldAfter != 250_000_000-StageCost || result.Limit != 500_000_000 {
t.Fatalf("upgrade result=%+v", result)
}
stored, _, _ := repositories.Character.Load(context.Background(), "17")
if stored.Stats[StageStat] != 1 || stored.Stats["gold"] != 250_000_000-StageCost {
t.Fatalf("stored stats=%+v", stored.Stats)
}
// The purchased stage has to move the limit gold gains are measured
// against, otherwise the upgrade is a number nobody reads.
if got := LimitForCharacter(stored); got != 500_000_000 {
t.Fatalf("stored limit=%d", got)
}
}
func TestOwnerUpgradeRefusalsLeaveTheCharacterUntouched(t *testing.T) {
installTestLevelLimits(t, realLevelLimitRows)
for name, testCase := range map[string]struct {
gold int64
stage int64
requested byte
wantErr error
}{
"out of sequence": {gold: 900_000_000, stage: 1, requested: 3, wantErr: ErrStageOutOfSequence},
"exhausted": {gold: 900_000_000, stage: int64(MaxStage), requested: MaxStage + 1, wantErr: ErrStageExhausted},
"short on gold": {gold: StageCost - 1, stage: 0, requested: 1, wantErr: ErrGoldInsufficient},
} {
t.Run(name, func(t *testing.T) {
owner, repositories := newUpgradeOwner(t, testCase.gold, testCase.stage)
result, err := owner.Upgrade(context.Background(), Command{
AccountID: "account-1", SelectedCharacterID: 17, RequestedStage: testCase.requested,
})
if !errors.Is(err, testCase.wantErr) {
t.Fatalf("err=%v want %v", err, testCase.wantErr)
}
// The refusal still has to carry the durable stage so the client can
// be told what it really owns.
if result.Upgraded || result.Stage != StageFromValue(testCase.stage) {
t.Fatalf("refused result=%+v", result)
}
stored, _, _ := repositories.Character.Load(context.Background(), "17")
if stored.Stats["gold"] != testCase.gold || stored.Stats[StageStat] != testCase.stage {
t.Fatalf("refused upgrade mutated stats=%+v", stored.Stats)
}
})
}
}
func TestOwnerRejectsAForeignAccountAndAMissingCharacter(t *testing.T) {
owner, _ := newUpgradeOwner(t, 250_000_000, 0)
if _, err := owner.Upgrade(context.Background(), Command{
AccountID: "account-2", SelectedCharacterID: 17, RequestedStage: 1,
}); !errors.Is(err, ErrAccountMismatch) {
t.Fatalf("account mismatch err=%v", err)
}
if _, err := owner.Upgrade(context.Background(), Command{
AccountID: "account-1", SelectedCharacterID: 99, RequestedStage: 1,
}); !errors.Is(err, ErrCharacterNotFound) {
t.Fatalf("missing character err=%v", err)
}
if _, err := owner.Load(context.Background(), 0); !errors.Is(err, ErrCharacterRequired) {
t.Fatalf("missing selection err=%v", err)
}
}
@@ -0,0 +1,164 @@
package carrygold
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrOwnerUnavailable = errors.New("carry gold owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrCharacterNotFound = errors.New("character record not found")
ErrAccountMismatch = errors.New("character account does not match session account")
ErrStageOutOfSequence = errors.New("requested carry gold stage is not the next stage")
ErrStageExhausted = errors.New("carry gold stage is already at the client bound")
ErrGoldInsufficient = errors.New("carry gold upgrade cannot be paid for")
)
type Command struct {
AccountID string
SelectedCharacterID uint16
RequestedStage byte
UpdatedAt time.Time
}
// Result reports the character's durable state after the call. Stage and
// GoldAfter are always the persisted values, so a refusal still carries the
// stage the client has to be told about.
type Result struct {
CharacterID string
PreviousStage byte
Stage byte
Cost int64
GoldBefore int64
GoldAfter int64
Limit int64
Upgraded bool
}
// Owner owns the carry-gold stage rule and its character mutation. The bridge
// owns only the current EXE wire format and response ordering.
type Owner struct {
characters dnfrepo.CharacterRepository
}
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
if repos.Character == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{characters: repos.Character}, nil
}
// Load reports the durable stage and limit without mutating anything. It is
// what the login projection uses.
func (o *Owner) Load(ctx context.Context, selectedCharacterID uint16) (Result, error) {
character, characterID, err := o.load(ctx, selectedCharacterID, "")
if err != nil {
return Result{}, err
}
stage := Stage(character.Stats)
return Result{
CharacterID: characterID,
PreviousStage: stage,
Stage: stage,
GoldBefore: character.Stats["gold"],
GoldAfter: character.Stats["gold"],
Limit: LimitForCharacter(character),
}, nil
}
// Upgrade advances the durable stage by exactly one and charges StageCost.
// Gold is checked before any mutation, so a refusal leaves the character
// untouched.
func (o *Owner) Upgrade(ctx context.Context, cmd Command) (Result, error) {
character, characterID, err := o.load(ctx, cmd.SelectedCharacterID, cmd.AccountID)
if err != nil {
return Result{}, err
}
current := Stage(character.Stats)
gold := character.Stats["gold"]
result := Result{
CharacterID: characterID,
PreviousStage: current,
Stage: current,
Cost: StageCost,
GoldBefore: gold,
GoldAfter: gold,
Limit: LimitForCharacter(character),
}
if current >= MaxStage {
return result, fmt.Errorf("%w: character=%s stage=%d", ErrStageExhausted, characterID, current)
}
if cmd.RequestedStage != current+1 {
return result, fmt.Errorf(
"%w: character=%s stage=%d requested=%d",
ErrStageOutOfSequence, characterID, current, cmd.RequestedStage,
)
}
if gold < StageCost {
return result, fmt.Errorf(
"%w: character=%s gold=%d cost=%d",
ErrGoldInsufficient, characterID, gold, StageCost,
)
}
updated := dnfrepo.CloneCharacter(character)
if updated.Stats == nil {
updated.Stats = make(map[string]int64, 2)
}
goldAfter := gold - StageCost
updated.Stats["gold"] = goldAfter
updated.Stats[StageStat] = int64(cmd.RequestedStage)
if cmd.UpdatedAt.IsZero() {
updated.UpdatedAt = time.Now().UTC()
} else {
updated.UpdatedAt = cmd.UpdatedAt.UTC()
}
if err := dnfrepo.SaveCharacterFields(
ctx, o.characters, updated, dnfrepo.CharacterFieldStats,
); err != nil {
return result, err
}
result.Stage = cmd.RequestedStage
result.GoldAfter = goldAfter
result.Limit = LimitForCharacter(updated)
result.Upgraded = true
return result, nil
}
func (o *Owner) load(
ctx context.Context,
selectedCharacterID uint16,
accountID string,
) (dnfrepo.CharacterRecord, string, error) {
if o == nil || o.characters == nil {
return dnfrepo.CharacterRecord{}, "", ErrOwnerUnavailable
}
if selectedCharacterID == 0 {
return dnfrepo.CharacterRecord{}, "", ErrCharacterRequired
}
characterID := strconv.FormatUint(uint64(selectedCharacterID), 10)
character, found, err := o.characters.Load(ctx, characterID)
if err != nil {
return dnfrepo.CharacterRecord{}, characterID, err
}
if !found || strings.TrimSpace(character.CharacterID) != characterID {
return dnfrepo.CharacterRecord{}, characterID, fmt.Errorf(
"%w: character=%s", ErrCharacterNotFound, characterID)
}
if trimmed := strings.TrimSpace(accountID); trimmed != "" &&
strings.TrimSpace(character.AccountID) != trimmed {
return dnfrepo.CharacterRecord{}, characterID, fmt.Errorf(
"%w: character=%s", ErrAccountMismatch, characterID)
}
if character.Stats == nil {
character.Stats = make(map[string]int64, 2)
}
return character, characterID, nil
}
@@ -0,0 +1,44 @@
package carrygold
import (
"context"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// TestRealScriptPVFGoldLimitByLevel is the evidence behind the base carry cap.
// The runtime PVF really does carry `[gold limit from level]`, so the base
// limit is level-keyed data and must never be re-hardcoded as 4亿; only the
// per-stage 亿 and the flat StageCost come from the client.
func TestRealScriptPVFGoldLimitByLevel(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the carry-gold level table")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
limits, err := LoadLevelLimits(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
if limits.Len() < 90 {
t.Fatalf("runtime %s has only %d level rows", LevelLimitPath, limits.Len())
}
// The level-60 plateau is what makes the client's confirm dialog read
// 4亿 -> 5亿 for a level-capped character.
for level, want := range map[int]int64{
0: 0, 1: 100_000, 20: 30_000_000, 30: 100_000_000,
60: 400_000_000, 90: 400_000_000, 99: 400_000_000,
} {
if got := limits.Limit(level); got != want {
t.Fatalf("runtime level=%d limit=%d want %d", level, got, want)
}
}
if base := limits.Limit(90); base+StageBonus(MaxStage) != int64(DisplayedLimit(MaxStage))*Unit {
t.Fatalf("level cap base=%d + max stage bonus does not match the client display", base)
}
}