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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user