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

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

368 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package adventuregroup
import (
"context"
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func creditTestConfig() RuntimeConfig {
return RuntimeConfig{
ShopCategories: []ShopCategory{
{
Index: ShopPointBrave,
MaxPoint: 250,
ExperiencePerPoint: 15_000_000,
PointPerExperience: 1,
},
{Index: ShopPointGlory, MaxPoint: 9999, ResetPointMonthly: true},
{Index: ShopPointPure},
},
Capsule: CapsuleConfig{
MinimumExperience: 11_691_495,
MaximumExperience: 116_914_950,
MaximumCount: 10,
MinimumLevel: 50,
MaximumLevel: 86,
GrantedExperience: 1_000_000,
},
}
}
func creditTestRepositories(t *testing.T, account dnfrepo.AccountRecord) dnfrepo.Group {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(context.Background(), account); err != nil {
t.Fatal(err)
}
return repositories
}
// 一笔**满级**经验必须同时推进三个账号级累计量,余数还要逐笔累积 ——
// 这正是"怪物击杀也要给结晶"那条改动的核心口径。
func TestCreditExperienceAccruesCrystalsCapsuleAndHonorExpForMaxLevelAward(t *testing.T) {
config := creditTestConfig()
now := time.Date(2026, time.September, 23, 12, 0, 0, 0, time.UTC)
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
// 满级角色的整笔经验都属于"越过上限"的部分。
atCap := func(value uint64) ExperienceCredit {
return ExperienceCredit{AwardedExperience: value, MaximumLevelExperience: value}
}
credit, err := CreditExperience(context.Background(), repositories.Account, config, "dnf:1", atCap(10_000_000), now)
if err != nil {
t.Fatal(err)
}
if credit.AwardedExperience != 10_000_000 || credit.BravePoints != 0 || credit.BraveExperience != 10_000_000 ||
credit.CapsuleExperience != 10_000_000 || credit.HonorExperience != 10_000_000 {
t.Fatalf("first credit=%+v", credit)
}
// 第二笔把余数推过 1500 万:结晶 +1,余数留 5,000,000。
credit, err = CreditExperience(context.Background(), repositories.Account, config, "dnf:1", atCap(10_000_000), now)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 1 || credit.BraveExperience != 5_000_000 || credit.HonorExperience != 20_000_000 {
t.Fatalf("second credit=%+v", credit)
}
// 持久化结果必须与返回值一致(账号元数据 + honor_exp 同事务落库)。
account, found, err := repositories.Account.Load(context.Background(), "dnf:1")
if err != nil || !found {
t.Fatalf("account found=%t err=%v", found, err)
}
state, err := ParseRuntimeState(account, config, now)
if err != nil {
t.Fatal(err)
}
if state.ShopPoints[ShopPointBrave] != 1 || state.BraveExperience != 5_000_000 ||
state.GrowthExperience != 20_000_000 || account.HonorExp != 20_000_000 {
t.Fatalf("persisted state=%+v honor=%d", state, account.HonorExp)
}
}
// 2026-09-24 服主口径:**没满级的角色也要能攒骁勇结晶**。
// 未满级经验 1:1 进结晶(不影响角色自己的升级经验),但**不进**成长胶囊槽、
// **不进**名誉经验 —— 胶囊槽与名誉等级都是满级专属。
func TestCreditExperienceSubCapFeedsOnlyCrystals(t *testing.T) {
config := creditTestConfig()
now := time.Date(2026, time.September, 24, 1, 0, 0, 0, time.UTC)
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
// 一次 40 级的打怪:全额经验,没有满级部分。
credit, err := CreditExperience(
context.Background(),
repositories.Account,
config,
"dnf:1",
ExperienceCredit{AwardedExperience: 20_000_000},
now,
)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 1 || credit.BraveExperience != 5_000_000 {
t.Fatalf("sub-cap crystals=%+v", credit)
}
if credit.CapsuleExperience != 0 {
t.Fatalf("sub-cap experience leaked into the capsule slot: %+v", credit)
}
if credit.HonorExperience != 0 {
t.Fatalf("sub-cap experience leaked into honor experience: %+v", credit)
}
// 再攒一笔把余数推过 1500 万:结晶 +1。
credit, err = CreditExperience(
context.Background(),
repositories.Account,
config,
"dnf:1",
ExperienceCredit{AwardedExperience: 10_000_000},
now,
)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 2 || credit.BraveExperience != 0 {
t.Fatalf("second sub-cap credit=%+v", credit)
}
account, _, err := repositories.Account.Load(context.Background(), "dnf:1")
if err != nil {
t.Fatal(err)
}
state, err := ParseRuntimeState(account, config, now)
if err != nil {
t.Fatal(err)
}
if state.GrowthExperience != 0 || account.HonorExp != 0 {
t.Fatalf("sub-cap credits touched capsule/honor: state=%+v honor=%d", state, account.HonorExp)
}
}
// 一笔经验里"满级部分"不可能多于总额:Normalize 必须兜住调用方填错的顺序。
func TestExperienceCreditNormalizeClampsMaximumToAwarded(t *testing.T) {
credit := ExperienceCredit{AwardedExperience: 100, MaximumLevelExperience: 500}.Normalize()
if credit.MaximumLevelExperience != 100 {
t.Fatalf("normalized=%+v", credit)
}
if (ExperienceCredit{}).Empty() != true || (ExperienceCredit{AwardedExperience: 1}).Empty() != false {
t.Fatalf("Empty() semantics changed")
}
}
// 骁勇结晶封顶 [max shop point]:超过上限的经验只留在余数里,不再加结晶。
func TestCreditExperienceStopsAtShopPointCap(t *testing.T) {
config := creditTestConfig()
now := time.Date(2026, time.September, 23, 12, 0, 0, 0, time.UTC)
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
if _, err := CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 250 * 15_000_000, MaximumLevelExperience: 250 * 15_000_000}, now,
); err != nil {
t.Fatal(err)
}
credit, err := CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 15_000_000, MaximumLevelExperience: 15_000_000}, now,
)
if err != nil {
t.Fatal(err)
}
// 已到上限:结晶不再增加,超出的整笔经验按现有口径丢弃(余数仍是整除后的 0)。
if credit.BravePoints != 250 || credit.BraveExperience != 0 {
t.Fatalf("capped credit=%+v", credit)
}
}
// 成长胶囊槽按**总容量**封顶:一格 = [capsule max exp](100%),可以攒
// [capsule max count] 格(界面写"累积数上限 : 10个")。所以一笔远超一格的
// 经验必须继续累加、绝不在一格处停住 —— 那正是"胶囊不累计"的根因。
func TestCreditExperienceKeepsCapsuleGrowingPastOneGauge(t *testing.T) {
config := creditTestConfig()
now := time.Date(2026, time.September, 23, 12, 0, 0, 0, time.UTC)
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
credit, err := CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 200_000_000, MaximumLevelExperience: 200_000_000}, now,
)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 13 || credit.BraveExperience != 5_000_000 ||
credit.CapsuleExperience != 200_000_000 {
t.Fatalf("credit=%+v", credit)
}
// 再攒到超过总容量:胶囊停在 总容量 = 单格 × 格数,结晶继续按同一笔走。
credit, err = CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 2_000_000_000, MaximumLevelExperience: 2_000_000_000}, now,
)
if err != nil {
t.Fatal(err)
}
if want := CapsuleTotalCapacity(config.Capsule); credit.CapsuleExperience != want {
t.Fatalf("capsule=%d want total capacity %d", credit.CapsuleExperience, want)
}
if want := config.Capsule.MaximumExperience * uint64(config.Capsule.MaximumCount); want == config.Capsule.MaximumExperience {
t.Fatalf("test config cannot tell one gauge from the total: %d", want)
}
}
// 成长胶囊"可获得的经验值"是**可领取次数**,不是经验值总量:
// 客户端拿到这个值会建同样多条列表项(0x13F0FA0 的 `for (i=0; i<count; i++)`),
// 所以上限必须是"格数 × 每格档数"(10 × 10 = 100),而不是 1,000 万级的总量。
func TestCapsuleClaimCountIsBoundedByTotalCapacityInChunks(t *testing.T) {
config := creditTestConfig().Capsule
step := config.MinimumExperience
perGauge := config.MaximumExperience / step
if perGauge != 10 {
t.Fatalf("PVF ratio max/step = %d, want 10 (10%% per claim)", perGauge)
}
cases := []struct {
name string
accumulated uint64
want uint32
}{
{name: "没到一档", accumulated: step - 1, want: 0},
{name: "正好一档", accumulated: step, want: 1},
{name: "一格", accumulated: config.MaximumExperience, want: uint32(perGauge)},
{name: "一格多一档", accumulated: config.MaximumExperience + step, want: uint32(perGauge) + 1},
{name: "满总容量", accumulated: CapsuleTotalCapacity(config), want: uint32(perGauge) * config.MaximumCount},
{name: "超过总容量也不放大", accumulated: ^uint64(0), want: uint32(perGauge) * config.MaximumCount},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
if got := CapsuleClaimCount(config, testCase.accumulated); got != testCase.want {
t.Fatalf("CapsuleClaimCount(%d)=%d want=%d", testCase.accumulated, got, testCase.want)
}
})
}
if got := CapsuleClaimCount(CapsuleConfig{}, 1_000_000); got != 0 {
t.Fatalf("missing PVF claim count = %d, want 0", got)
}
}
// 没有可记的经验时不许写账号(否则每次零经验击杀都会刷一次账号行)。
func TestCreditExperienceIgnoresZeroValue(t *testing.T) {
config := creditTestConfig()
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
credit, err := CreditExperience(context.Background(), repositories.Account, config, "dnf:1", ExperienceCredit{}, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if credit.AwardedExperience != 0 || credit.HonorExperience != 0 {
t.Fatalf("zero credit=%+v", credit)
}
account, _, err := repositories.Account.Load(context.Background(), "dnf:1")
if err != nil {
t.Fatal(err)
}
if account.Metadata[RuntimeStateMetadataKey] != "" || account.HonorExp != 0 {
t.Fatalf("zero credit wrote account=%+v", account)
}
}
// 账号不存在必须整笔失败:静默跳过会让"经验涨了、结晶没涨"留下来。
func TestCreditExperienceRequiresTheAccount(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
_, err := CreditExperience(
context.Background(), repositories.Account, creditTestConfig(), "dnf:missing",
ExperienceCredit{AwardedExperience: 10}, time.Now().UTC(),
)
if !errors.Is(err, ErrCreditAccountNotFound) {
t.Fatalf("err=%v", err)
}
}
// ApplyExperienceCredit 是给"账号已经在调用方手里"的路径用的
// (地下城通关结算):它只改内存记录,落库由调用方自己的事务负责。
func TestApplyExperienceCreditMutatesOnlyTheGivenAccountRecord(t *testing.T) {
config := creditTestConfig()
now := time.Date(2026, time.September, 23, 12, 0, 0, 0, time.UTC)
account := dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{"keep": "1"}}
credit, err := ApplyExperienceCredit(&account, config, ExperienceCredit{
AwardedExperience: 15_000_000,
MaximumLevelExperience: 15_000_000,
}, now)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 1 || credit.HonorExperience != 15_000_000 || account.Metadata["keep"] != "1" {
t.Fatalf("credit=%+v account=%+v", credit, account)
}
}
// 2026-09-29 服主口径:**满级角色获得的经验不再累加进冒险团(等级)经验**,
// 满级之前照旧累加。同一笔账里的骁勇结晶 / 成长胶囊槽 / 名誉经验不受影响——
// 那三条各自有自己的口径(结晶任意等级、胶囊与名誉满级专属)。
//
// 回归背景:等级账本过去和结晶共用同一个 AwardedExperience,满级角色打怪
// 一直在推进冒险团等级(线上日志 adventure_group_credit=454 new_level=90)。
func TestCreditExperienceKeepsLevelLedgerForSubCapExperienceOnly(t *testing.T) {
config := creditTestConfig()
config.Growth = GrowthConfig{
MaximumLevel: 3,
RateParts: growthRateScale, // 1.0,增益与冒险团经验 1:1
RequiredExperience: []uint64{0, 0, 100, 200},
LevelBenefits: make([]Benefits, 4),
}
now := time.Date(2026, time.September, 29, 4, 0, 0, 0, time.UTC)
repositories := creditTestRepositories(t, dnfrepo.AccountRecord{AccountID: "dnf:1"})
// 1) 满级角色:整笔都是"越过上限"的部分 → 结晶/名誉照涨,等级账本一点不动。
credit, err := CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 15_000_000, MaximumLevelExperience: 15_000_000},
now,
)
if err != nil {
t.Fatal(err)
}
if credit.BravePoints != 1 || credit.HonorExperience != 15_000_000 {
t.Fatalf("满级笔照样要进结晶与名誉: %+v", credit)
}
// 等级账本必须一个字都没写(ApplyGrowthExperience 在 gain==0 时直接返回,
// 既不生成元数据也不报告等级)。
afterMaxLevel, found, err := repositories.Account.Load(context.Background(), "dnf:1")
if err != nil || !found {
t.Fatalf("account found=%t err=%v", found, err)
}
if value := afterMaxLevel.Metadata[GrowthMetadataKey]; value != "" {
t.Fatalf("满级经验不该推进冒险团等级,却写了账本: %s", value)
}
// 2) 未满级角色:全额进等级账本(150 → 2 级 + 50 余量),等级口径照旧。
credit, err = CreditExperience(
context.Background(), repositories.Account, config, "dnf:1",
ExperienceCredit{AwardedExperience: 150},
now,
)
if err != nil {
t.Fatal(err)
}
if credit.GrowthLevel != 2 || credit.GrowthExperience != 50 {
t.Fatalf("满级前的经验必须照旧累加: level=%d exp=%d", credit.GrowthLevel, credit.GrowthExperience)
}
// 持久化的账本必须与返回值一致。
account, found, err := repositories.Account.Load(context.Background(), "dnf:1")
if err != nil || !found {
t.Fatalf("account found=%t err=%v", found, err)
}
state, err := readGrowthState(account, config.Growth, Summary{})
if err != nil {
t.Fatal(err)
}
if state.Level != 2 || state.Experience != 50 {
t.Fatalf("persisted growth=%+v", state)
}
}