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