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,75 @@
package adventuregroup
import (
"math"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
DailyLoginStateMetadataKey = "adventure_group_daily_login"
RecommendedDungeonClearStatKey = "adventure_recommended_dungeon_clears"
ContentTypeRecommendedDungeon = 0
// ContentTypeCount is the number of u32 content counters in the native
// payload (raw offsets 16/32/24/28/36/40). It is NOT the profession row
// count: the 20 profession rows are ProfessionTypeCount below.
ContentTypeCount = 6
ProfessionTypeCount = 20
adventureGroupContentCountMax = math.MaxUint32
)
// Projection contains only account activity that has an authoritative local
// owner. Unsupported raid and PVP counters deliberately stay zero; the tower
// counters are filled by the tower owner (indexed by TowerType).
type Projection struct {
ConsecutiveLoginDays uint32
ContentCounts [ContentTypeCount]uint32
TowerRemaining [TowerTypeCount]uint32
TowerLimits [TowerTypeCount]uint32
// ProfessionCounts is what the native 20-row descriptor block carries: one
// per-character count for each profession classification, not a content
// counter. Row N is the classification returned by
// RuntimeConfig.ProfessionClassification.
ProfessionCounts [ProfessionTypeCount]uint16
Runtime RuntimeState
Representatives RepresentativeState
RepresentativesEnabled bool
// CapsuleGauge 是成长胶囊**一格**的容量(PVF `[capsule max exp]`),也就是
// 客户端那条进度条的分母。发出去的进度必须是"累计值 mod 这一格",否则
// 攒过 100% 之后客户端会算出 >100% 的数(或直接顶在 100% 不动)。
CapsuleGauge uint64
// CapsuleClaimCount 是"现在可以领取几次经验值",单位是客户端那条说明里的
// 10% 档(一格 = 10 次)。**它必须是一个不大的次数**:客户端会按它建列表
// 逐个拼字符串,见 adventuregroup.CapsuleClaimCount 的说明。
CapsuleClaimCount uint32
}
// Tower counters are indexed by these values in the adventure-group payload.
const (
TowerDespairType = iota
TowerGriefType
TowerTypeCount
)
// ProjectActivity folds durable character-scoped activity into the
// account-wide values consumed by the current adventure-group packet.
func ProjectActivity(
consecutiveLoginDays uint32,
characters []dnfrepo.CharacterRecord,
) Projection {
result := Projection{ConsecutiveLoginDays: consecutiveLoginDays}
var recommended uint64
for _, character := range characters {
value := character.Stats[RecommendedDungeonClearStatKey]
if value <= 0 {
continue
}
if uint64(value) >= adventureGroupContentCountMax-recommended {
recommended = adventureGroupContentCountMax
break
}
recommended += uint64(value)
}
result.ContentCounts[ContentTypeRecommendedDungeon] = uint32(recommended)
return result
}
@@ -0,0 +1,27 @@
package adventuregroup
import (
"math"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestProjectActivitySumsRecommendedDungeonClearsAndSaturates(t *testing.T) {
got := ProjectActivity(24, []dnfrepo.CharacterRecord{
{Stats: map[string]int64{RecommendedDungeonClearStatKey: 7}},
{Stats: map[string]int64{RecommendedDungeonClearStatKey: -1}},
{Stats: map[string]int64{RecommendedDungeonClearStatKey: math.MaxUint32}},
})
if got.ConsecutiveLoginDays != 24 {
t.Fatalf("consecutive days=%d, want 24", got.ConsecutiveLoginDays)
}
if got.ContentCounts[ContentTypeRecommendedDungeon] != math.MaxUint32 {
t.Fatalf("recommended clears=%d, want %d", got.ContentCounts[ContentTypeRecommendedDungeon], math.MaxUint32)
}
for index := 1; index < ContentTypeCount; index++ {
if got.ContentCounts[index] != 0 {
t.Fatalf("unsupported content type %d=%d, want 0", index, got.ContentCounts[index])
}
}
}
@@ -0,0 +1,186 @@
package adventuregroup
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrCreditAccountNotFound = errors.New("adventure-group credit account is missing")
// ExperienceCredit 描述"一次已经发给角色的经验"该怎么进账号级冒险团账本。
//
// 口径(2026-09-24 服主确认,此前只有满级经验才记账):
//
// - 骁勇结晶:**任意等级**获得的经验都按 PVF `[exp to get shop point]` 累计
// (真值 1500 万 = 1 个,封顶 `[max shop point]` = 250)。角色自己的升级
// 经验完全不受影响 —— 这里只是把同一笔经验**镜像**一份进账号账本,
// "获得多少经验就累计多少"。
// - 成长胶囊槽 / 名誉经验:**满级专属**。客户端自己的说明写着
// "Lv90以上的角色获得经验值时,经验值会累积在成长胶囊槽中",
// 名誉等级同样是满级之后的系统,所以只有越过等级上限的那部分进这两个账本。
//
// 三个账本共用一个数值来源,是为了让同一笔结算永远算出互相一致的账号状态;
// 现在两个数值来源必须一起给出,免得调用方只填一个而悄悄改了别人的口径。
type ExperienceCredit struct {
// AwardedExperience 是这次**真正发给角色**的经验(含成长契约 / 装备 /
// 疲劳蓄电池 / 任务连续性补足等加成),进骁勇结晶。
AwardedExperience uint64
// MaximumLevelExperience 是其中**越过等级上限**的那部分,进成长胶囊槽与
// 名誉经验。它永远不该大于 AwardedExperience,Normalize 会兜住。
MaximumLevelExperience uint64
}
// Normalize 收敛两个数值的关系:满级部分不可能多于总经验。
func (c ExperienceCredit) Normalize() ExperienceCredit {
if c.MaximumLevelExperience > c.AwardedExperience {
c.MaximumLevelExperience = c.AwardedExperience
}
return c
}
// Empty 表示这笔经验什么都不用记(零经验或重放)。
func (c ExperienceCredit) Empty() bool {
return c.AwardedExperience == 0 && c.MaximumLevelExperience == 0
}
// ExperienceCreditResult 汇报记入之后的账号级数值,便于日志与测试核对。
type ExperienceCreditResult struct {
AccountID string
AwardedExperience uint64
MaximumLevelExperience uint64
BravePoints uint32
BraveExperience uint64
CapsuleExperience uint64
HonorExperience uint64
// GrowthLevel/GrowthExperience are the adventure-group level ledger after
// this credit, for logging and tests. A credit that carries no creditable
// level experience (a max-level character, or a zero-exp replay) is a no-op
// and reports zero here without touching the stored ledger.
GrowthLevel int
GrowthExperience uint64
}
// ApplyExperienceCredit 在**调用方已经加载、并即将在自己的事务里保存**的账号
// 记录上记入一笔经验:
//
// - 骁勇结晶:任意等级的经验都算,按 PVF `[exp to get shop point]` 换算,
// 封顶 `[max shop point]`;
// - 成长胶囊槽:只算满级溢出部分,封顶 `[capsule max exp] × [capsule max count]`;
// - 账号名誉经验 `accounts.honor_exp`:只算满级溢出部分(名誉等级是满级专属)。
//
// 调用方负责保存;本函数不碰仓库,因此不会和调用方已做的账号改动互相覆盖。
func ApplyExperienceCredit(
account *dnfrepo.AccountRecord,
config RuntimeConfig,
credit ExperienceCredit,
now time.Time,
) (ExperienceCreditResult, error) {
if account == nil {
return ExperienceCreditResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(account.AccountID)
if accountID == "" {
return ExperienceCreditResult{}, ErrAccountRequired
}
credit = credit.Normalize()
if credit.Empty() {
return ExperienceCreditResult{AccountID: accountID}, nil
}
if now.IsZero() {
now = time.Now().UTC()
}
state, err := ParseRuntimeState(*account, config, now)
if err != nil {
return ExperienceCreditResult{}, err
}
state.AddExperience(config, credit.AwardedExperience, credit.MaximumLevelExperience)
// 测试口径:满级角色获得的经验也计入冒险团等级账本,验证冒险团经验
// 是否正常增长;原始规则原本只计算 AwardedExperience - MaximumLevelExperience。
levelGain := credit.AwardedExperience
growth, err := ApplyGrowthExperience(account, config.Growth, Summary{}, uint32(min(levelGain, math.MaxUint32)), now)
if err != nil {
return ExperienceCreditResult{}, err
}
encoded, err := state.Marshal()
if err != nil {
return ExperienceCreditResult{}, err
}
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
account.Metadata[RuntimeStateMetadataKey] = encoded
// 名誉经验只收满级溢出:未满级经验不进名誉面板,这是服主确认的口径。
if credit.MaximumLevelExperience > 0 {
if account.HonorExp > math.MaxUint64-credit.MaximumLevelExperience {
account.HonorExp = math.MaxUint64
} else {
account.HonorExp += credit.MaximumLevelExperience
}
}
account.UpdatedAt = now
return ExperienceCreditResult{
AccountID: accountID,
AwardedExperience: credit.AwardedExperience,
MaximumLevelExperience: credit.MaximumLevelExperience,
BravePoints: state.ShopPoints[ShopPointBrave],
BraveExperience: state.BraveExperience,
CapsuleExperience: state.GrowthExperience,
HonorExperience: account.HonorExp,
GrowthLevel: growth.Level,
GrowthExperience: growth.Experience,
}, nil
}
// CreditExperience 是 ApplyExperienceCredit 的读-改-写版本:在**调用方已经
// 打开的事务**里加载账号、记入经验并保存。
//
// 怪物击杀、任务完成这些路径平时只碰角色/技能两张表,没有账号记录在手,
// 用这个版本就能把账号级冒险团状态并进同一个事务提交,而不是事后再补一次
// 可能失败的写(补写失败会让"经验涨了结晶没涨"这种状态静默留存)。
func CreditExperience(
ctx context.Context,
accounts dnfrepo.AccountRepository,
config RuntimeConfig,
accountID string,
credit ExperienceCredit,
now time.Time,
) (ExperienceCreditResult, error) {
if accounts == nil {
return ExperienceCreditResult{}, ErrOwnerUnavailable
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return ExperienceCreditResult{}, ErrAccountRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return ExperienceCreditResult{}, err
}
if credit.Normalize().Empty() {
return ExperienceCreditResult{AccountID: accountID}, nil
}
account, found, err := accounts.Load(ctx, accountID)
if err != nil {
return ExperienceCreditResult{}, fmt.Errorf("load adventure-group credit account: %w", err)
}
if !found || strings.TrimSpace(account.AccountID) != accountID {
return ExperienceCreditResult{}, fmt.Errorf("%w: account=%s", ErrCreditAccountNotFound, accountID)
}
account = dnfrepo.CloneAccount(account)
result, err := ApplyExperienceCredit(&account, config, credit, now)
if err != nil {
return ExperienceCreditResult{}, err
}
if err := accounts.Save(ctx, account); err != nil {
return ExperienceCreditResult{}, fmt.Errorf("save adventure-group credit account: %w", err)
}
return result, nil
}
@@ -0,0 +1,367 @@
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)
}
}
@@ -0,0 +1,83 @@
package adventuregroup
import (
"context"
"os"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestExpeditionRewardNativeRoundingAndPartyAttributes(t *testing.T) {
c := RuntimeConfig{
ExpeditionAreas: []ExpeditionArea{{Index: 1, RewardRates: map[uint32]float64{21600: 3.5}}},
PartyMemberRewardRates: []float64{1, 1, 2, 3, 4}, ExpeditionExtraRate: 0.5,
AttributeRewardRates: []float64{1, 1, 1.4, 1.8, 2.2},
AttributeIDs: map[string]byte{"a": 1, "b": 2},
CharacterAttributes: map[[2]byte][]string{{0, 2}: {"a"}, {1, 2}: {"b"}},
}
for _, test := range []struct {
name string
members []ExpeditionMemberInput
attributes []byte
want uint32
}{
{"round_before_extra", []ExpeditionMemberInput{{Level: 90, Job: 0, GrowType: 0x22}}, nil, 2},
{"level_is_not_multiplier", []ExpeditionMemberInput{{Level: 100, Job: 0, GrowType: 0x22}}, nil, 2},
{"two_members_one_attribute", []ExpeditionMemberInput{{Level: 90, Job: 0, GrowType: 2}, {Level: 90, Job: 0, GrowType: 2}}, []byte{1, 2}, 3},
{"two_members_two_attributes", []ExpeditionMemberInput{{Level: 90, Job: 0, GrowType: 2}, {Level: 90, Job: 1, GrowType: 0x22}}, []byte{1, 2}, 5},
} {
t.Run(test.name, func(t *testing.T) {
got, ok := c.ExpeditionReward(1, 21600, test.members, test.attributes)
if !ok || got != test.want {
t.Fatalf("reward=%d/%t want=%d", got, ok, test.want)
}
})
}
if _, ok := c.ExpeditionReward(1, 21600, nil, nil); ok {
t.Fatal("empty party accepted")
}
if _, ok := c.ExpeditionReward(1, 1, []ExpeditionMemberInput{{Level: 90}}, nil); ok {
t.Fatal("unsupported duration accepted")
}
clone := c.Clone()
clone.PartyMemberRewardRates[1] = 7
if c.PartyMemberRewardRates[1] != 1 {
t.Fatal("clone changed original party rates")
}
}
func TestRealPVFExpeditionRewardsMatchNativePreview(t *testing.T) {
path := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if path == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: path})
if err != nil {
t.Fatal(err)
}
tables, err := LoadComplete(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
c := tables.Runtime()
if c.ExpeditionExtraRate != 0.5 || len(c.PartyMemberRewardRates) != 5 {
t.Fatalf("native bonus config=%+v", c)
}
want := [4][4]uint32{{1, 2, 3, 4}, {1, 2, 3, 5}, {1, 2, 4, 6}, {1, 3, 5, 6}}
for _, area := range c.ExpeditionAreas {
for timeIndex, hours := range []uint32{6, 12, 18, 24} {
for count := 1; count <= 4; count++ {
members := make([]ExpeditionMemberInput, count)
for i := range members {
members[i] = ExpeditionMemberInput{Level: 90, Job: 0, GrowType: 2}
}
got, ok := c.ExpeditionReward(area.Index, hours*3600, members, nil)
if !ok || got != want[area.Index-1][timeIndex] {
t.Fatalf("area=%d hours=%d members=%d reward=%d/%t", area.Index, hours, count, got, ok)
}
t.Logf("NATIVE_CASE rate=%g party=%g match=1 extra=%g duration=%d count=%d reward=%d", area.RewardRates[hours*3600], c.PartyMemberRewardRates[count], c.ExpeditionExtraRate, hours*3600, count, got)
}
}
}
}
@@ -0,0 +1,220 @@
package adventuregroup
import (
"encoding/json"
"fmt"
"math"
"math/bits"
"strconv"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// GrowthMetadataKey is the account-owned adventure-group growth ledger. It is
// separate from the runtime state (shop points / capsule) because its level is
// what the client renders as the adventure-group level.
const GrowthMetadataKey = "adventure_group_growth_v1"
const growthRateScale = uint64(1_000_000)
// GrowthConfig is the PVF `[max adventure level]` / `[adventure exp rate]` /
// `[adventure exp table]` / per-level `[stat]` benefits.
type GrowthConfig struct {
MaximumLevel int
RateParts uint64
// Entry N is the experience needed to enter level N, not a cumulative total.
RequiredExperience []uint64
LevelBenefits []Benefits
}
type growthState struct {
Level int `json:"level"`
Experience uint64 `json:"experience"`
Remainder uint64 `json:"remainder"`
}
func parseGrowthConfig(doc *dnfpvf.Document) (GrowthConfig, error) {
c := GrowthConfig{MaximumLevel: int(firstSectionInt(doc, "max adventure level"))}
if c.MaximumLevel < 1 || c.MaximumLevel > 255 {
return c, fmt.Errorf("%w: adventure maximum level", ErrTableMalformed)
}
tokens, _ := doc.Section("adventure exp rate")
if len(tokens) != 1 || tokenNumber(tokens[0]) <= 0 || tokenNumber(tokens[0]) > 1 {
return c, fmt.Errorf("%w: adventure experience rate", ErrTableMalformed)
}
c.RateParts = uint64(math.Round(tokenNumber(tokens[0]) * float64(growthRateScale)))
c.RequiredExperience = make([]uint64, c.MaximumLevel+1)
c.LevelBenefits = make([]Benefits, c.MaximumLevel+1)
tokens, _ = doc.Section("adventure exp table")
seen := make(map[int]bool)
if len(tokens)%2 != 0 {
return c, fmt.Errorf("%w: adventure experience rows", ErrTableMalformed)
}
for i := 0; i < len(tokens); i += 2 {
level := int(tokens[i].Int)
value, err := strconv.ParseUint(tokens[i+1].Value, 10, 64)
if tokens[i].Kind != dnfpvf.TokenInt || tokens[i+1].Kind != dnfpvf.TokenString || err != nil || level < 1 || seen[level] {
return c, fmt.Errorf("%w: adventure experience row %d", ErrTableMalformed, i/2)
}
seen[level] = true
if level <= c.MaximumLevel {
c.RequiredExperience[level] = value
}
}
for level := 1; level <= c.MaximumLevel; level++ {
if !seen[level] || (level > 1 && c.RequiredExperience[level] == 0) {
return c, fmt.Errorf("%w: adventure experience level %d", ErrTableMalformed, level)
}
}
level := 0
for _, section := range doc.Sections {
values := sectionTokens(doc, section)
switch normalizedPVFName(section.Name) {
case "attribute":
level = int(firstInt(values))
case "/attribute":
level = 0
case "stat", "grow":
if level < 1 || level > c.MaximumLevel || len(values) < 3 {
continue
}
value := tokenNumber(values[2])
if value < 0 || value > math.MaxUint32 {
return c, fmt.Errorf("%w: adventure benefit level %d", ErrTableMalformed, level)
}
switch values[1].Value {
case "str":
c.LevelBenefits[level].ManageOption = uint64(value)
case "clear result exp up":
c.LevelBenefits[level].ExpBonusPercent = uint64(value)
}
}
}
return c, nil
}
// CurrentSummary returns the account's adventure-group level. Once the first
// earned experience is committed the growth ledger owns it; before that the
// legacy roster-derived level is migrated into the ledger so a roster change
// cannot lower an established account.
func (t *Tables) CurrentSummary(account dnfrepo.AccountRecord, characters []Character) (Summary, error) {
legacy, err := t.Calculate(characters)
if err != nil || t.runtime.Growth.MaximumLevel == 0 {
return legacy, err
}
c := t.runtime.Growth
initial := growthState{Level: max(1, min(legacy.ManageLevel, c.MaximumLevel))}
if legacy.ManageLevel > 0 && legacy.ManageLevel < len(t.manageThresholds) && initial.Level < c.MaximumLevel {
lower, upper := t.manageThresholds[legacy.ManageLevel-1], t.manageThresholds[legacy.ManageLevel]
if upper > lower {
hi, lo := bits.Mul64(legacy.TotalPoint-lower, c.RequiredExperience[initial.Level+1])
initial.Experience, _ = bits.Div64(hi, lo, upper-lower)
}
}
state, err := readGrowthState(account, c, Summary{ManageLevel: initial.Level, TotalPoint: initial.Experience})
if err != nil {
return Summary{}, err
}
return Summary{ManageLevel: state.Level, TotalPoint: state.Experience, Benefits: c.LevelBenefits[state.Level]}, nil
}
func readGrowthState(account dnfrepo.AccountRecord, c GrowthConfig, initial Summary) (growthState, error) {
state := growthState{Level: max(1, initial.ManageLevel), Experience: initial.TotalPoint}
if value := strings.TrimSpace(account.Metadata[GrowthMetadataKey]); value != "" {
if err := json.Unmarshal([]byte(value), &state); err != nil {
return state, fmt.Errorf("%w: growth: %v", ErrRuntimeStateInvalid, err)
}
}
if state.Level < 1 || state.Level > c.MaximumLevel || state.Remainder >= growthRateScale ||
(state.Level < c.MaximumLevel && state.Experience >= c.RequiredExperience[state.Level+1]) {
return state, fmt.Errorf("%w: growth level/progress", ErrRuntimeStateInvalid)
}
return state, nil
}
// SeedGrowthState migrates the roster-derived legacy level into the growth
// ledger the first time an account is read with both the account record and
// its roster in hand. Until the ledger exists, CurrentSummary can only show the
// migrated value without persisting it; seeding at login means a later
// experience grant can never start the ledger below an established level.
func SeedGrowthState(account *dnfrepo.AccountRecord, c GrowthConfig, legacy Summary, now time.Time) (bool, error) {
if account == nil || c.MaximumLevel == 0 {
return false, nil
}
if strings.TrimSpace(account.Metadata[GrowthMetadataKey]) != "" {
return false, nil
}
if account.Metadata == nil {
return false, fmt.Errorf("%w: account metadata not loaded", ErrRuntimeStateInvalid)
}
state := growthState{Level: max(1, min(legacy.ManageLevel, c.MaximumLevel)), Experience: legacy.TotalPoint}
if state.Level < c.MaximumLevel && state.Experience >= c.RequiredExperience[state.Level+1] {
state.Experience = 0
}
if state.Level > 1 && state.Level < len(c.RequiredExperience) && c.RequiredExperience[state.Level] > 0 {
// A level carried over from the roster starts its bar from zero; the
// roster's total point is a different scale and must not be reused.
state.Experience = 0
}
encoded, err := json.Marshal(state)
if err != nil {
return false, err
}
account.Metadata[GrowthMetadataKey] = string(encoded)
account.UpdatedAt = now
return true, nil
}
// ApplyGrowthExperience records one earned-experience grant on an account the
// caller has already loaded and is about to save itself. It mirrors the
// ApplyExperienceCredit contract (no repository access), which is what keeps a
// single settlement from double-counting the same experience.
//
// gain is the character experience that was actually awarded. RateParts turns
// it into adventure-group experience; the remainder is carried so many small
// grants still add up to the same total as one large one.
func ApplyGrowthExperience(
account *dnfrepo.AccountRecord,
config GrowthConfig,
initial Summary,
gain uint32,
now time.Time,
) (growthState, error) {
c := config
if account == nil {
return growthState{}, ErrOwnerUnavailable
}
if c.MaximumLevel == 0 || gain == 0 {
return growthState{}, nil
}
if account.Metadata == nil {
return growthState{}, fmt.Errorf("%w: account metadata not loaded", ErrRuntimeStateInvalid)
}
state, err := readGrowthState(*account, c, initial)
if err != nil {
return growthState{}, err
}
scaled := uint64(gain)*c.RateParts + state.Remainder
remaining := scaled / growthRateScale
state.Remainder = scaled % growthRateScale
for state.Level < c.MaximumLevel && remaining >= c.RequiredExperience[state.Level+1]-state.Experience {
remaining -= c.RequiredExperience[state.Level+1] - state.Experience
state.Level++
state.Experience = 0
}
if state.Level == c.MaximumLevel {
state.Experience, state.Remainder = 0, 0
} else {
state.Experience += remaining
}
encoded, err := json.Marshal(state)
if err != nil {
return state, err
}
account.Metadata[GrowthMetadataKey] = string(encoded)
account.UpdatedAt = now
return state, nil
}
@@ -0,0 +1,99 @@
package adventuregroup
import (
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func growthTestConfig() GrowthConfig {
// Three levels; entering level 2 costs 100, level 3 costs 200.
return GrowthConfig{
MaximumLevel: 3,
RateParts: growthRateScale, // 1:1, so gains map straight through
RequiredExperience: []uint64{0, 0, 100, 200},
LevelBenefits: make([]Benefits, 4),
}
}
func TestApplyGrowthExperienceLevelsAndCarriesRemainder(t *testing.T) {
c := growthTestConfig()
account := dnfrepo.AccountRecord{AccountID: "a", Metadata: map[string]string{}}
now := time.Unix(1_800_000_000, 0)
state, err := ApplyGrowthExperience(&account, c, Summary{}, 60, now)
if err != nil {
t.Fatal(err)
}
if state.Level != 1 || state.Experience != 60 {
t.Fatalf("first grant level=%d exp=%d", state.Level, state.Experience)
}
// 60 + 60 crosses the 100 needed for level 2 with 20 left over.
state, err = ApplyGrowthExperience(&account, c, Summary{}, 60, now)
if err != nil {
t.Fatal(err)
}
if state.Level != 2 || state.Experience != 20 {
t.Fatalf("level-up level=%d exp=%d, want 2/20", state.Level, state.Experience)
}
// Idempotent read: the ledger is durable, so a fresh read agrees.
read, err := readGrowthState(account, c, Summary{})
if err != nil {
t.Fatal(err)
}
if read != state {
t.Fatalf("persisted=%+v in-memory=%+v", read, state)
}
// At the cap the bar and the fraction are both cleared.
state, err = ApplyGrowthExperience(&account, c, Summary{}, 1000, now)
if err != nil {
t.Fatal(err)
}
if state.Level != 3 || state.Experience != 0 || state.Remainder != 0 {
t.Fatalf("capped level=%d exp=%d rem=%d", state.Level, state.Experience, state.Remainder)
}
}
// A sub-unit rate must carry the fraction so many small grants add up to the
// same total as one large one.
func TestApplyGrowthExperienceCarriesSubUnitRateRemainder(t *testing.T) {
c := growthTestConfig()
c.RateParts = growthRateScale / 2 // every grant counts half
account := dnfrepo.AccountRecord{AccountID: "a", Metadata: map[string]string{}}
now := time.Unix(1_800_000_000, 0)
if _, err := ApplyGrowthExperience(&account, c, Summary{}, 100, now); err != nil {
t.Fatal(err)
}
state, err := ApplyGrowthExperience(&account, c, Summary{}, 100, now)
if err != nil {
t.Fatal(err)
}
// 100 halved twice = 100 total, i.e. exactly level 2.
if state.Level != 2 || state.Experience != 0 {
t.Fatalf("level=%d exp=%d, want 2/0 (the half-unit must not be dropped)", state.Level, state.Experience)
}
}
func TestSeedGrowthStateMigratesLegacyLevelOnce(t *testing.T) {
c := growthTestConfig()
account := dnfrepo.AccountRecord{AccountID: "a", Metadata: map[string]string{}}
now := time.Unix(1_800_000_000, 0)
seeded, err := SeedGrowthState(&account, c, Summary{ManageLevel: 2, TotalPoint: 999}, now)
if err != nil || !seeded {
t.Fatalf("seed=%t err=%v", seeded, err)
}
state, err := readGrowthState(account, c, Summary{})
if err != nil {
t.Fatal(err)
}
// The legacy level is carried over, but its point scale is the roster's and
// must not be reused as growth progress.
if state.Level != 2 || state.Experience != 0 {
t.Fatalf("migrated level=%d exp=%d, want 2/0", state.Level, state.Experience)
}
again, err := SeedGrowthState(&account, c, Summary{ManageLevel: 1}, now)
if err != nil || again {
t.Fatalf("second seed=%t err=%v, want no-op", again, err)
}
}
@@ -0,0 +1,224 @@
package adventuregroup
import (
"context"
"fmt"
"math"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const (
pointBonusSection = "point bonus"
managePointSection = "manage level point"
manageLevelMaxSection = "manage level max"
expBonusSection = "exp bonus"
goldBonusSection = "gold bonus"
manageOptionSection = "manage option"
)
// Load parses the typed character-management document. It remains the narrow
// calculator loader used by domain tests and tooling.
func Load(ctx context.Context, source dnfpvf.Source) (*Tables, error) {
if source == nil {
return nil, dnfpvf.ErrSourceRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
text, err := source.ReadText(CharacterManagementPath)
if err != nil {
return nil, fmt.Errorf("read dnf adventure-group PVF %q: %w", CharacterManagementPath, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(CharacterManagementPath, text)
if err != nil {
return nil, fmt.Errorf("parse dnf adventure-group PVF %q: %w", CharacterManagementPath, err)
}
tables, err := parseTables(doc)
if err != nil {
return nil, err
}
return tables, nil
}
// LoadComplete loads the management table together with the current 2018
// expedition, shop and growth-capsule documents. Production composition uses
// this strict compatibility-unit loader.
func LoadComplete(ctx context.Context, source dnfpvf.Source) (*Tables, error) {
tables, err := Load(ctx, source)
if err != nil {
return nil, err
}
runtime, err := loadRuntimeConfig(ctx, source)
if err != nil {
return nil, err
}
tables.runtime = runtime
return tables, nil
}
func parseTables(doc *dnfpvf.Document) (*Tables, error) {
pointValues, err := numericSection(doc, pointBonusSection, false)
if err != nil {
return nil, err
}
pointRanges, err := parsePointRanges(pointValues)
if err != nil {
return nil, err
}
thresholdValues, err := numericSection(doc, managePointSection, false)
if err != nil {
return nil, err
}
thresholds, err := parseThresholds(thresholdValues)
if err != nil {
return nil, err
}
maxValues, err := numericSection(doc, manageLevelMaxSection, false)
if err != nil {
return nil, err
}
if len(maxValues) != 1 || maxValues[0] <= 0 || maxValues[0] > int64(math.MaxInt) {
return nil, fmt.Errorf("%w: [%s] values=%v", ErrTableMalformed, manageLevelMaxSection, maxValues)
}
manageLevelMax := int(maxValues[0])
expValues, err := numericSection(doc, expBonusSection, true)
if err != nil {
return nil, err
}
expBonus, err := parseLevelValueTable(expBonusSection, expValues, manageLevelMax, true)
if err != nil {
return nil, err
}
goldValues, err := numericSection(doc, goldBonusSection, true)
if err != nil {
return nil, err
}
goldBonus, err := parseLevelValueTable(goldBonusSection, goldValues, manageLevelMax, true)
if err != nil {
return nil, err
}
optionValues, err := numericSection(doc, manageOptionSection, false)
if err != nil {
return nil, err
}
options, err := parseLevelValueTable(manageOptionSection, optionValues, manageLevelMax, false)
if err != nil {
return nil, err
}
return &Tables{
pointRanges: pointRanges,
manageThresholds: thresholds,
manageLevelMax: manageLevelMax,
expBonusByLevel: expBonus,
goldBonusByLevel: goldBonus,
optionByLevel: options,
}, nil
}
func numericSection(doc *dnfpvf.Document, name string, allowEmpty bool) ([]int64, error) {
if doc == nil {
return nil, fmt.Errorf("%w: %s [%s]", ErrTableEmpty, CharacterManagementPath, name)
}
count := 0
for _, section := range doc.Sections {
if strings.EqualFold(strings.TrimSpace(section.Name), name) {
count++
}
}
if count == 0 {
return nil, fmt.Errorf("%w: %s missing [%s]", ErrTableEmpty, CharacterManagementPath, name)
}
if count != 1 {
return nil, fmt.Errorf("%w: %s duplicate [%s] count=%d", ErrTableMalformed, CharacterManagementPath, name, count)
}
tokens, ok := doc.Section(name)
if !ok {
return nil, fmt.Errorf("%w: %s unreadable [%s]", ErrTableMalformed, CharacterManagementPath, name)
}
values := make([]int64, 0, len(tokens))
for _, token := range tokens {
if token.Kind != dnfpvf.TokenInt {
return nil, fmt.Errorf("%w: %s [%s] non-integer token %q at %d:%d", ErrTableMalformed, CharacterManagementPath, name, token.Raw, token.Line, token.Column)
}
values = append(values, token.Int)
}
if len(values) == 0 && !allowEmpty {
return nil, fmt.Errorf("%w: %s [%s]", ErrTableEmpty, CharacterManagementPath, name)
}
return values, nil
}
func parsePointRanges(values []int64) ([]pointRange, error) {
if len(values)%3 != 0 {
return nil, fmt.Errorf("%w: [%s] integers=%d", ErrTableMalformed, pointBonusSection, len(values))
}
rows := make([]pointRange, 0, len(values)/3)
previousMax := 0
for index := 0; index < len(values); index += 3 {
minValue, maxValue, pointValue := values[index], values[index+1], values[index+2]
if minValue <= 0 || minValue > int64(math.MaxInt) || maxValue < minValue || maxValue > int64(math.MaxInt) || pointValue < 0 {
return nil, fmt.Errorf("%w: [%s] row=%d min=%d max=%d point=%d", ErrTableMalformed, pointBonusSection, index/3, minValue, maxValue, pointValue)
}
minLevel, maxLevel := int(minValue), int(maxValue)
if minLevel <= previousMax {
return nil, fmt.Errorf("%w: [%s] row=%d range=%d..%d overlaps or is unsorted after max=%d", ErrTableMalformed, pointBonusSection, index/3, minLevel, maxLevel, previousMax)
}
rows = append(rows, pointRange{minLevel: minLevel, maxLevel: maxLevel, point: uint64(pointValue)})
previousMax = maxLevel
}
return rows, nil
}
func parseThresholds(values []int64) ([]uint64, error) {
thresholds := make([]uint64, len(values))
var previous uint64
for index, value := range values {
if value <= 0 {
return nil, fmt.Errorf("%w: [%s] index=%d value=%d", ErrTableMalformed, managePointSection, index, value)
}
threshold := uint64(value)
if index > 0 && threshold <= previous {
return nil, fmt.Errorf("%w: [%s] index=%d value=%d previous=%d", ErrTableMalformed, managePointSection, index, threshold, previous)
}
thresholds[index] = threshold
previous = threshold
}
return thresholds, nil
}
func parseLevelValueTable(name string, values []int64, manageLevelMax int, allowEmpty bool) (map[int]uint64, error) {
if len(values) == 0 {
if allowEmpty {
return map[int]uint64{}, nil
}
return nil, fmt.Errorf("%w: [%s]", ErrTableEmpty, name)
}
if len(values)%2 != 0 {
return nil, fmt.Errorf("%w: [%s] integers=%d", ErrTableMalformed, name, len(values))
}
table := make(map[int]uint64, len(values)/2)
previousLevel := 0
for index := 0; index < len(values); index += 2 {
levelValue, dataValue := values[index], values[index+1]
if levelValue <= 0 || levelValue > int64(manageLevelMax) || levelValue > int64(math.MaxInt) || dataValue < 0 {
return nil, fmt.Errorf("%w: [%s] row=%d level=%d value=%d", ErrTableMalformed, name, index/2, levelValue, dataValue)
}
level := int(levelValue)
if level <= previousLevel {
return nil, fmt.Errorf("%w: [%s] row=%d level=%d previous=%d", ErrTableMalformed, name, index/2, level, previousLevel)
}
table[level] = uint64(dataValue)
previousLevel = level
}
return table, nil
}
@@ -0,0 +1,124 @@
package adventuregroup
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrDailyLoginStateInvalid = errors.New("adventure-group daily-login state is invalid")
var adventureGroupCalendarLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
type ObserveDailyLoginCommand struct {
AccountID string
ObservedAt time.Time
}
type ObserveDailyLoginResult struct {
AccountID string
CalendarDate string
ConsecutiveDays uint32
Changed bool
}
// ObserveDailyLogin advances one account's UTC+8 calendar-day streak. The date
// and count share one metadata value so the key-level write cannot persist a
// partially updated streak.
func (o *Owner) ObserveDailyLogin(
ctx context.Context,
command ObserveDailyLoginCommand,
) (ObserveDailyLoginResult, error) {
if o == nil || o.accounts == nil {
return ObserveDailyLoginResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(command.AccountID)
if accountID == "" {
return ObserveDailyLoginResult{}, ErrAccountRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return ObserveDailyLoginResult{}, err
}
observedAt := command.ObservedAt
if observedAt.IsZero() {
observedAt = time.Now()
}
today := calendarDate(observedAt)
account, found, err := o.accounts.Load(ctx, accountID)
if err != nil {
return ObserveDailyLoginResult{}, fmt.Errorf("load adventure-group account: %w", err)
}
if !found || strings.TrimSpace(account.AccountID) != accountID {
return ObserveDailyLoginResult{}, fmt.Errorf("%w: account=%s", ErrAccountNotFound, accountID)
}
previousDate, previousCount, hasState, err := parseDailyLoginState(account.Metadata[DailyLoginStateMetadataKey])
if err != nil {
return ObserveDailyLoginResult{}, err
}
result := ObserveDailyLoginResult{
AccountID: accountID,
CalendarDate: today.Format(time.DateOnly),
ConsecutiveDays: previousCount,
}
if hasState && !today.After(previousDate) {
return result, nil
}
switch {
case !hasState:
result.ConsecutiveDays = 1
case today.Equal(previousDate.AddDate(0, 0, 1)):
if previousCount < math.MaxUint32 {
result.ConsecutiveDays = previousCount + 1
}
default:
result.ConsecutiveDays = 1
}
value := result.CalendarDate + "|" + strconv.FormatUint(uint64(result.ConsecutiveDays), 10)
if err := dnfrepo.SaveAccountMetadataEntry(
ctx,
o.accounts,
account,
DailyLoginStateMetadataKey,
value,
observedAt,
); err != nil {
return ObserveDailyLoginResult{}, fmt.Errorf("save adventure-group daily login: %w", err)
}
result.Changed = true
return result, nil
}
func calendarDate(value time.Time) time.Time {
local := value.In(adventureGroupCalendarLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, adventureGroupCalendarLocation)
}
func parseDailyLoginState(value string) (time.Time, uint32, bool, error) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, 0, false, nil
}
dateText, countText, ok := strings.Cut(value, "|")
if !ok {
return time.Time{}, 0, false, fmt.Errorf("%w: %q", ErrDailyLoginStateInvalid, value)
}
date, err := time.ParseInLocation(time.DateOnly, dateText, adventureGroupCalendarLocation)
if err != nil {
return time.Time{}, 0, false, fmt.Errorf("%w: date=%q", ErrDailyLoginStateInvalid, dateText)
}
count, err := strconv.ParseUint(countText, 10, 32)
if err != nil || count == 0 {
return time.Time{}, 0, false, fmt.Errorf("%w: count=%q", ErrDailyLoginStateInvalid, countText)
}
return date, uint32(count), true, nil
}
@@ -0,0 +1,110 @@
package adventuregroup
import (
"context"
"errors"
"math"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerObserveDailyLoginStartsAdvancesAndResetsStreak(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{"account_cera": "123"},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
location := time.FixedZone("test", 8*60*60)
observe := func(at time.Time) ObserveDailyLoginResult {
t.Helper()
result, observeErr := owner.ObserveDailyLogin(ctx, ObserveDailyLoginCommand{
AccountID: "account-1",
ObservedAt: at,
})
if observeErr != nil {
t.Fatal(observeErr)
}
return result
}
first := observe(time.Date(2026, 7, 28, 8, 0, 0, 0, location))
if !first.Changed || first.ConsecutiveDays != 1 || first.CalendarDate != "2026-07-28" {
t.Fatalf("first=%+v", first)
}
same := observe(time.Date(2026, 7, 28, 23, 59, 0, 0, location))
if same.Changed || same.ConsecutiveDays != 1 {
t.Fatalf("same=%+v", same)
}
next := observe(time.Date(2026, 7, 29, 0, 1, 0, 0, location))
if !next.Changed || next.ConsecutiveDays != 2 {
t.Fatalf("next=%+v", next)
}
gap := observe(time.Date(2026, 7, 31, 0, 1, 0, 0, location))
if !gap.Changed || gap.ConsecutiveDays != 1 {
t.Fatalf("gap=%+v", gap)
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load found=%t err=%v", found, err)
}
if account.Metadata[DailyLoginStateMetadataKey] != "2026-07-31|1" ||
account.Metadata["account_cera"] != "123" {
t.Fatalf("account metadata=%v", account.Metadata)
}
}
func TestOwnerObserveDailyLoginPreservesFutureAndSaturates(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{
DailyLoginStateMetadataKey: "2026-07-29|4294967295",
},
}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
result, err := owner.ObserveDailyLogin(ctx, ObserveDailyLoginCommand{
AccountID: "account-1",
ObservedAt: time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
})
if err != nil || result.Changed || result.ConsecutiveDays != math.MaxUint32 {
t.Fatalf("future result=%+v err=%v", result, err)
}
result, err = owner.ObserveDailyLogin(ctx, ObserveDailyLoginCommand{
AccountID: "account-1",
ObservedAt: time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC),
})
if err != nil || !result.Changed || result.ConsecutiveDays != math.MaxUint32 {
t.Fatalf("saturated result=%+v err=%v", result, err)
}
}
func TestOwnerObserveDailyLoginRejectsCorruptState(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{DailyLoginStateMetadataKey: "bad"},
}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
if _, err := owner.ObserveDailyLogin(ctx, ObserveDailyLoginCommand{
AccountID: "account-1",
ObservedAt: time.Now(),
}); !errors.Is(err, ErrDailyLoginStateInvalid) {
t.Fatalf("error=%v", err)
}
}
@@ -0,0 +1,99 @@
package adventuregroup
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const SelectorSlotMetadataKey = "selector_adventure_info_slot"
var (
ErrOwnerUnavailable = errors.New("adventure-group owner is unavailable")
ErrAccountRequired = errors.New("adventure-group account id is required")
ErrAccountNotFound = errors.New("adventure-group account is missing")
ErrSelectorSlotRange = errors.New("adventure-group selector slot is outside the verified range")
)
type RememberSelectorSlotCommand struct {
AccountID string
Slot int
SlotLimit int
UpdatedAt time.Time
}
type RememberSelectorSlotResult struct {
AccountID string
Slot int
Changed bool
}
type Owner struct {
accounts dnfrepo.AccountRepository
}
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
if repositories.Account == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{accounts: repositories.Account}, nil
}
// RememberSelectorSlot persists only a slot already verified against the
// account roster by the bridge. The metadata write is key-scoped so unrelated
// account fields and metadata cannot be replaced.
func (o *Owner) RememberSelectorSlot(
ctx context.Context,
command RememberSelectorSlotCommand,
) (RememberSelectorSlotResult, error) {
if o == nil || o.accounts == nil {
return RememberSelectorSlotResult{}, ErrOwnerUnavailable
}
accountID := strings.TrimSpace(command.AccountID)
if accountID == "" {
return RememberSelectorSlotResult{}, ErrAccountRequired
}
if command.SlotLimit <= 0 || command.Slot < 0 || command.Slot >= command.SlotLimit {
return RememberSelectorSlotResult{}, fmt.Errorf(
"%w: slot=%d limit=%d",
ErrSelectorSlotRange,
command.Slot,
command.SlotLimit,
)
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return RememberSelectorSlotResult{}, err
}
account, found, err := o.accounts.Load(ctx, accountID)
if err != nil {
return RememberSelectorSlotResult{}, fmt.Errorf("load adventure-group account: %w", err)
}
if !found || strings.TrimSpace(account.AccountID) != accountID {
return RememberSelectorSlotResult{}, fmt.Errorf("%w: account=%s", ErrAccountNotFound, accountID)
}
result := RememberSelectorSlotResult{AccountID: accountID, Slot: command.Slot}
value := strconv.Itoa(command.Slot)
if strings.TrimSpace(account.Metadata[SelectorSlotMetadataKey]) == value {
return result, nil
}
if err := dnfrepo.SaveAccountMetadataEntry(
ctx,
o.accounts,
account,
SelectorSlotMetadataKey,
value,
command.UpdatedAt,
); err != nil {
return RememberSelectorSlotResult{}, fmt.Errorf("save adventure-group selector slot: %w", err)
}
result.Changed = true
return result, nil
}
@@ -0,0 +1,101 @@
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 TestOwnerRememberSelectorSlotUpdatesOnlyOwnedMetadataKey(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
now := time.Unix(2_000_000_000, 0).UTC()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
State: "active",
HonorExp: 77,
RepresentAccountName: "group",
Metadata: map[string]string{
"account_cera": "123",
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.RememberSelectorSlot(ctx, RememberSelectorSlotCommand{
AccountID: "account-1",
Slot: 4,
SlotLimit: 32,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if !result.Changed || result.Slot != 4 {
t.Fatalf("result=%+v", result)
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load found=%t err=%v", found, err)
}
if account.Metadata[SelectorSlotMetadataKey] != "4" ||
account.Metadata["account_cera"] != "123" ||
account.HonorExp != 77 ||
account.RepresentAccountName != "group" ||
!account.UpdatedAt.Equal(now) {
t.Fatalf("account=%+v", account)
}
}
func TestOwnerRememberSelectorSlotPreservesExistingValue(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{SelectorSlotMetadataKey: "4"},
}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
result, err := owner.RememberSelectorSlot(ctx, RememberSelectorSlotCommand{
AccountID: "account-1",
Slot: 4,
SlotLimit: 32,
})
if err != nil {
t.Fatal(err)
}
if result.Changed {
t.Fatalf("result=%+v", result)
}
}
func TestOwnerRememberSelectorSlotRejectsInvalidScope(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "account-1"}); err != nil {
t.Fatal(err)
}
owner, _ := NewOwner(repositories)
if _, err := owner.RememberSelectorSlot(ctx, RememberSelectorSlotCommand{
AccountID: "account-1",
Slot: 32,
SlotLimit: 32,
}); !errors.Is(err, ErrSelectorSlotRange) {
t.Fatalf("slot error=%v", err)
}
if _, err := owner.RememberSelectorSlot(ctx, RememberSelectorSlotCommand{
AccountID: "missing",
Slot: 1,
SlotLimit: 32,
}); !errors.Is(err, ErrAccountNotFound) {
t.Fatalf("account error=%v", err)
}
}
@@ -0,0 +1,246 @@
package adventuregroup
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
representativeSettingsScopePrefix = "account:"
representativeSettingsScopeSuffix = ":adventure_representatives"
maximumRepresentativeJob = byte(15)
minimumRepresentativeAwakening = byte(2)
minimumRepresentativeBaseGrow = byte(1)
maximumRepresentativeBaseGrow = byte(15)
representativeRemovedValue = "removed"
representativeSelectedPrefix = "selected:"
)
var (
ErrRepresentativeRepository = errors.New("adventure-group representative repository is unavailable")
ErrRepresentativeSlot = errors.New("adventure-group representative slot is invalid")
ErrRepresentativeCharacter = errors.New("adventure-group representative character is invalid")
)
type RepresentativeSlot struct {
Job byte
BaseGrow byte
}
type RepresentativeSelection struct {
CharacterID string
Removed bool
}
type RepresentativeState struct {
Selections map[RepresentativeSlot]RepresentativeSelection
}
func ValidRepresentativeSlot(slot RepresentativeSlot) bool {
return slot.Job <= maximumRepresentativeJob &&
slot.BaseGrow >= minimumRepresentativeBaseGrow &&
slot.BaseGrow <= maximumRepresentativeBaseGrow
}
func RepresentativeCharacterEligible(character dnfrepo.CharacterRecord, slot RepresentativeSlot, maximumLevel int) bool {
if !ValidRepresentativeSlot(slot) || maximumLevel <= 0 || character.Level < maximumLevel ||
byte(numericRecordValue(character.Job)) != slot.Job {
return false
}
packed := byte(character.Stats["grow_type"])
return packed&0x0f == slot.BaseGrow && packed>>4 >= minimumRepresentativeAwakening
}
func RepresentativeSlotForCharacter(character dnfrepo.CharacterRecord, maximumLevel int) (RepresentativeSlot, bool) {
packed := byte(character.Stats["grow_type"])
slot := RepresentativeSlot{Job: byte(numericRecordValue(character.Job)), BaseGrow: packed & 0x0f}
return slot, RepresentativeCharacterEligible(character, slot, maximumLevel)
}
func ProjectRepresentativeCharacters(
characters []dnfrepo.CharacterRecord,
state RepresentativeState,
maximumLevel int,
) []dnfrepo.CharacterRecord {
fallbacks := make(map[RepresentativeSlot]dnfrepo.CharacterRecord)
eligibleByID := make(map[string]dnfrepo.CharacterRecord)
order := make([]RepresentativeSlot, 0)
for _, character := range characters {
slot, eligible := RepresentativeSlotForCharacter(character, maximumLevel)
if !eligible {
continue
}
characterID := strings.TrimSpace(character.CharacterID)
if characterID != "" {
eligibleByID[characterID] = character
}
if _, exists := fallbacks[slot]; !exists {
fallbacks[slot] = character
order = append(order, slot)
}
}
result := make([]dnfrepo.CharacterRecord, 0, len(order))
for _, slot := range order {
selection, selected := state.Selections[slot]
if selected && selection.Removed {
continue
}
if selected && strings.TrimSpace(selection.CharacterID) != "" {
if character, found := eligibleByID[strings.TrimSpace(selection.CharacterID)]; found {
characterSlot, eligible := RepresentativeSlotForCharacter(character, maximumLevel)
if eligible && characterSlot == slot {
result = append(result, character)
continue
}
}
}
result = append(result, fallbacks[slot])
}
return result
}
type RepresentativeOwner struct {
settings dnfrepo.SettingsRepository
}
func NewRepresentativeOwner(repositories dnfrepo.Group) (*RepresentativeOwner, error) {
if repositories.Settings == nil {
return nil, ErrRepresentativeRepository
}
return &RepresentativeOwner{settings: repositories.Settings}, nil
}
func RepresentativeSettingsScope(accountID string) string {
return representativeSettingsScopePrefix + strings.TrimSpace(accountID) + representativeSettingsScopeSuffix
}
func (o *RepresentativeOwner) Load(ctx context.Context, accountID string) (RepresentativeState, error) {
if o == nil || o.settings == nil {
return RepresentativeState{}, ErrRepresentativeRepository
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return RepresentativeState{}, ErrAccountRequired
}
if ctx == nil {
ctx = context.Background()
}
record, found, err := o.settings.Load(ctx, RepresentativeSettingsScope(accountID))
if err != nil {
return RepresentativeState{}, fmt.Errorf("load adventure-group representatives: %w", err)
}
state := RepresentativeState{Selections: make(map[RepresentativeSlot]RepresentativeSelection)}
if !found {
return state, nil
}
for key, value := range record.Values {
slot, valid := parseRepresentativeSlotKey(key)
if !valid {
continue
}
switch {
case strings.TrimSpace(value) == representativeRemovedValue:
state.Selections[slot] = RepresentativeSelection{Removed: true}
case strings.HasPrefix(strings.TrimSpace(value), representativeSelectedPrefix):
characterID := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(value), representativeSelectedPrefix))
if characterID != "" {
state.Selections[slot] = RepresentativeSelection{CharacterID: characterID}
}
}
}
return state, nil
}
func (o *RepresentativeOwner) Select(
ctx context.Context,
accountID string,
slot RepresentativeSlot,
characterID string,
updatedAt time.Time,
) error {
characterID = strings.TrimSpace(characterID)
if !ValidRepresentativeSlot(slot) {
return ErrRepresentativeSlot
}
if characterID == "" {
return ErrRepresentativeCharacter
}
return o.save(ctx, accountID, slot, representativeSelectedPrefix+characterID, updatedAt)
}
func (o *RepresentativeOwner) Remove(
ctx context.Context,
accountID string,
slot RepresentativeSlot,
updatedAt time.Time,
) error {
if !ValidRepresentativeSlot(slot) {
return ErrRepresentativeSlot
}
return o.save(ctx, accountID, slot, representativeRemovedValue, updatedAt)
}
func (o *RepresentativeOwner) save(
ctx context.Context,
accountID string,
slot RepresentativeSlot,
value string,
updatedAt time.Time,
) error {
if o == nil || o.settings == nil {
return ErrRepresentativeRepository
}
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return ErrAccountRequired
}
if ctx == nil {
ctx = context.Background()
}
scope := RepresentativeSettingsScope(accountID)
record, found, err := o.settings.Load(ctx, scope)
if err != nil {
return fmt.Errorf("load adventure-group representatives for update: %w", err)
}
if !found {
record = dnfrepo.SettingsRecord{Scope: scope, Values: make(map[string]string)}
} else {
record = dnfrepo.CloneSettings(record)
if record.Values == nil {
record.Values = make(map[string]string)
}
}
record.Values[representativeSlotKey(slot)] = value
record.UpdatedAt = updatedAt
if err := o.settings.Save(ctx, record); err != nil {
return fmt.Errorf("save adventure-group representatives: %w", err)
}
return nil
}
func representativeSlotKey(slot RepresentativeSlot) string {
return strconv.Itoa(int(slot.Job)) + ":" + strconv.Itoa(int(slot.BaseGrow))
}
func parseRepresentativeSlotKey(value string) (RepresentativeSlot, bool) {
parts := strings.Split(strings.TrimSpace(value), ":")
if len(parts) != 2 {
return RepresentativeSlot{}, false
}
job, jobErr := strconv.ParseUint(parts[0], 10, 8)
grow, growErr := strconv.ParseUint(parts[1], 10, 8)
slot := RepresentativeSlot{Job: byte(job), BaseGrow: byte(grow)}
return slot, jobErr == nil && growErr == nil && ValidRepresentativeSlot(slot)
}
func numericRecordValue(value string) int64 {
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed
}
@@ -0,0 +1,95 @@
package adventuregroup
import (
"context"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestRepresentativeOwnerRoundTripAndAccountIsolation(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
owner, err := NewRepresentativeOwner(repositories)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
slot := RepresentativeSlot{Job: 11, BaseGrow: 4}
if err := owner.Select(ctx, "dnf:1", slot, "267", time.Unix(1, 0)); err != nil {
t.Fatal(err)
}
state, err := owner.Load(ctx, "dnf:1")
if err != nil {
t.Fatal(err)
}
if got := state.Selections[slot]; got != (RepresentativeSelection{CharacterID: "267"}) {
t.Fatalf("selection=%+v", got)
}
other, err := owner.Load(ctx, "dnf:2")
if err != nil {
t.Fatal(err)
}
if len(other.Selections) != 0 {
t.Fatalf("cross-account selections=%+v", other.Selections)
}
if err := owner.Remove(ctx, "dnf:1", slot, time.Unix(2, 0)); err != nil {
t.Fatal(err)
}
state, err = owner.Load(ctx, "dnf:1")
if err != nil {
t.Fatal(err)
}
if got := state.Selections[slot]; !got.Removed || got.CharacterID != "" {
t.Fatalf("removed selection=%+v", got)
}
}
func TestProjectRepresentativeCharactersUsesSelectionRemovalAndStaleFallback(t *testing.T) {
characters := []dnfrepo.CharacterRecord{
{CharacterID: "1", Job: "0", Level: 90, Stats: map[string]int64{"grow_type": 0x21}},
{CharacterID: "2", Job: "0", Level: 90, Stats: map[string]int64{"grow_type": 0x31}},
{CharacterID: "3", Job: "1", Level: 90, Stats: map[string]int64{"grow_type": 0x22}},
{CharacterID: "4", Job: "1", Level: 89, Stats: map[string]int64{"grow_type": 0x22}},
{CharacterID: "5", Job: "2", Level: 90, Stats: map[string]int64{"grow_type": 0x12}},
}
slotA := RepresentativeSlot{Job: 0, BaseGrow: 1}
slotB := RepresentativeSlot{Job: 1, BaseGrow: 2}
projected := ProjectRepresentativeCharacters(characters, RepresentativeState{
Selections: map[RepresentativeSlot]RepresentativeSelection{
slotA: {CharacterID: "2"},
slotB: {Removed: true},
},
}, 90)
if len(projected) != 1 || projected[0].CharacterID != "2" {
t.Fatalf("selected/removal projection=%+v", projected)
}
projected = ProjectRepresentativeCharacters(characters, RepresentativeState{
Selections: map[RepresentativeSlot]RepresentativeSelection{
slotA: {CharacterID: "999"},
},
}, 90)
if len(projected) != 2 || projected[0].CharacterID != "1" || projected[1].CharacterID != "3" {
t.Fatalf("stale fallback projection=%+v", projected)
}
}
func TestRepresentativeCharacterEligibilityUsesPackedGrow(t *testing.T) {
slot := RepresentativeSlot{Job: 11, BaseGrow: 4}
character := dnfrepo.CharacterRecord{
Job: "11", Level: 90, Stats: map[string]int64{"grow_type": 0x24},
}
if !RepresentativeCharacterEligible(character, slot, 90) {
t.Fatal("second-awakening packed grow was rejected")
}
character.Stats["grow_type"] = 0x14
if RepresentativeCharacterEligible(character, slot, 90) {
t.Fatal("first-awakening packed grow was accepted")
}
character.Stats["grow_type"] = 0x24
character.Level = 89
if RepresentativeCharacterEligible(character, slot, 90) {
t.Fatal("below-cap character was accepted")
}
}
@@ -0,0 +1,756 @@
package adventuregroup
import (
"context"
"fmt"
"math"
"sort"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const (
ShopPointBrave = iota
ShopPointGlory
ShopPointPure
ShopPointTypeCount
)
type ShopProduct struct {
ItemID uint32
RequiredManageLevel int
Cost uint32
MonthlyPurchaseLimit uint32
ResetType int
}
type ShopCategory struct {
Index byte
MaxPoint uint32
ResetPointMonthly bool
ExperiencePerPoint uint64
PointPerExperience uint32
Products []ShopProduct
}
type CapsuleConfig struct {
MinimumExperience uint64
MaximumExperience uint64
MaximumCount uint32
MinimumLevel int
MaximumLevel int
GrantedExperience uint32
UpdateAt time.Time
}
// CapsuleTotalCapacity 返回成长胶囊槽的**总容量**。
//
// PVF 里的两个数是两件事,绝不能混:
//
// [capsule max exp] 116,914,950 = **一格**的容量(界面上的 100%)
// [capsule max count] 10 = 可以攒几格(界面写"累积数上限 : 10个")
//
// 所以总容量 = 116,914,950 × 10。把 `[capsule max exp]` 当成总上限会让累计值
// 卡死在正好 100% 上:客户端那条进度条的分子是 `累计值 mod [capsule max exp]`,
// 于是永远显示 0%、玩家看到的就是"胶囊不累计"(2026-09-23 线上现场)。
func CapsuleTotalCapacity(config CapsuleConfig) uint64 {
if config.MaximumExperience == 0 {
return 0
}
count := config.MaximumCount
if count == 0 {
count = 1
}
if config.MaximumExperience > math.MaxUint64/uint64(count) {
return math.MaxUint64
}
return config.MaximumExperience * uint64(count)
}
// CapsuleGaugeCapacity 返回一格(100%)的容量,也就是客户端进度条的分母。
func CapsuleGaugeCapacity(config CapsuleConfig) uint64 {
return config.MaximumExperience
}
// CapsuleClaimCount 返回"现在可以领取几次成长胶囊经验值"。
//
// 客户端自己的说明文字(Interface2/Adventurer2018/AdvShop 的
// `capsule_explain`)把规则写死了:
//
// 经验值按照10% : 1,000,000点的比例换算。
// (累积100%时, 可获得10,000,000点经验值)
// 角色Lv : Lv.50 ~ 86
// 累积数上限 : 10个
//
// 所以一次领取 = 消耗 `[capsule need minimum exp]`(= 一格 10%,
// 116,914,950 / 10 = 11,691,495)= 换到 `[capsule give exp]`(1,000,000),
// 可领取次数 = floor(累计经验 / 该档位),一格 = 10 次。
//
// 上限必须是"格数 × 每格档数"(10 × 10 = 100):客户端拿到这个数会**按它
// 建列表**(0x13F0FA0 里 `for (i = 0; i < count; i++)` 逐个拼字符串),
// 所以绝不能把"可领取的经验值总量"(1,000 万级)当成次数发出去 —— 那会让
// 客户端建上千万个字符串而卡死。2026-09-23 第一版就写错了,装机前发现并回滚。
func CapsuleClaimCount(config CapsuleConfig, accumulated uint64) uint32 {
if config.MinimumExperience == 0 || config.MaximumExperience < config.MinimumExperience {
return 0
}
chunks := accumulated / config.MinimumExperience
maximum := uint64(config.MaximumCount)
if maximum == 0 {
maximum = 1
}
if perGauge := config.MaximumExperience / config.MinimumExperience; perGauge > 0 {
if maximum > math.MaxUint32/perGauge {
maximum = math.MaxUint32
} else {
maximum *= perGauge
}
}
if chunks > maximum {
chunks = maximum
}
if chunks > math.MaxUint32 {
return math.MaxUint32
}
return uint32(chunks)
}
type ExpeditionArea struct {
Index byte
RequiredManageLevel int
RewardRates map[uint32]float64
DesignatedGroups []string
}
type RuntimeConfig struct {
ShopCategories []ShopCategory
Growth GrowthConfig
Capsule CapsuleConfig
ExpeditionAreas []ExpeditionArea
AttributeIDs map[string]byte
AttributeGroups map[string][]string
CharacterAttributes map[[2]byte][]string
AttributeRewardRates []float64
// PartyMemberRewardRates is `[party member rate]` (5 entries, indexed by
// member count) and ExpeditionExtraRate is `[extra rate]`. Both feed the
// native expedition reward formula.
PartyMemberRewardRates []float64
ExpeditionExtraRate float64
RotationDays int
professionClasses map[[2]byte]byte
professionGroupCount int
}
func (c RuntimeConfig) Clone() RuntimeConfig {
out := c
out.Growth.RequiredExperience = append([]uint64(nil), c.Growth.RequiredExperience...)
out.Growth.LevelBenefits = append([]Benefits(nil), c.Growth.LevelBenefits...)
out.ShopCategories = make([]ShopCategory, len(c.ShopCategories))
for i, category := range c.ShopCategories {
out.ShopCategories[i] = category
out.ShopCategories[i].Products = append([]ShopProduct(nil), category.Products...)
}
out.ExpeditionAreas = make([]ExpeditionArea, len(c.ExpeditionAreas))
for i, area := range c.ExpeditionAreas {
out.ExpeditionAreas[i] = area
out.ExpeditionAreas[i].RewardRates = make(map[uint32]float64, len(area.RewardRates))
for duration, rate := range area.RewardRates {
out.ExpeditionAreas[i].RewardRates[duration] = rate
}
out.ExpeditionAreas[i].DesignatedGroups = append([]string(nil), area.DesignatedGroups...)
}
out.AttributeIDs = make(map[string]byte, len(c.AttributeIDs))
for key, value := range c.AttributeIDs {
out.AttributeIDs[key] = value
}
out.AttributeGroups = make(map[string][]string, len(c.AttributeGroups))
for key, values := range c.AttributeGroups {
out.AttributeGroups[key] = append([]string(nil), values...)
}
out.CharacterAttributes = make(map[[2]byte][]string, len(c.CharacterAttributes))
for key, values := range c.CharacterAttributes {
out.CharacterAttributes[key] = append([]string(nil), values...)
}
out.AttributeRewardRates = append([]float64(nil), c.AttributeRewardRates...)
out.PartyMemberRewardRates = append([]float64(nil), c.PartyMemberRewardRates...)
out.professionClasses = make(map[[2]byte]byte, len(c.professionClasses))
for key, value := range c.professionClasses {
out.professionClasses[key] = value
}
return out
}
// ProfessionClassification resolves the ordered [expert list] group used by
// the current client collection UI for one job/base-grow slot.
func (c RuntimeConfig) ProfessionClassification(job, baseGrow int) (byte, bool) {
if job < 0 || job > math.MaxUint8 || baseGrow <= 0 || baseGrow > math.MaxUint8 {
return 0, false
}
value, found := c.professionClasses[[2]byte{byte(job), byte(baseGrow)}]
return value, found
}
func (c RuntimeConfig) ProfessionGroupCount() int {
return c.professionGroupCount
}
func (c RuntimeConfig) Shop(index byte) (ShopCategory, bool) {
for _, category := range c.ShopCategories {
if category.Index == index {
copy := category
copy.Products = append([]ShopProduct(nil), category.Products...)
return copy, true
}
}
return ShopCategory{}, false
}
func (c RuntimeConfig) Area(index byte) (ExpeditionArea, bool) {
for _, area := range c.ExpeditionAreas {
if area.Index == index {
copy := area
copy.DesignatedGroups = append([]string(nil), area.DesignatedGroups...)
copy.RewardRates = make(map[uint32]float64, len(area.RewardRates))
for duration, rate := range area.RewardRates {
copy.RewardRates[duration] = rate
}
return copy, true
}
}
return ExpeditionArea{}, false
}
// AreaAttributes resolves the PVF rotation deterministically from the UTC+8
// calendar day. Group order comes from the PVF, and duplicate attributes are
// skipped just like the client's uniqueness presentation.
func (c RuntimeConfig) AreaAttributes(index byte, now time.Time) []byte {
area, ok := c.Area(index)
if !ok {
return nil
}
rotationDays := c.RotationDays
if rotationDays <= 0 {
rotationDays = 1
}
local := now.In(adventureGroupCalendarLocation)
// local.Unix() is absolute UTC seconds, so dividing it by 86400 would roll
// the rotation over at UTC midnight. Add the zone offset to get local
// wall-clock seconds, which puts the boundary at local (UTC+8) midnight.
_, offsetSeconds := local.Zone()
day := int((local.Unix() + int64(offsetSeconds)) / 86400 / int64(rotationDays))
out := make([]byte, 0, len(area.DesignatedGroups))
seen := make(map[byte]struct{}, len(area.DesignatedGroups))
for offset, groupName := range area.DesignatedGroups {
group := c.AttributeGroups[normalizedPVFName(groupName)]
if len(group) == 0 {
if id, exists := c.AttributeIDs[normalizedPVFName(groupName)]; exists {
group = []string{groupName}
_ = id
}
}
if len(group) == 0 {
continue
}
for attempt := 0; attempt < len(group); attempt++ {
name := group[(day+int(index)+offset+attempt)%len(group)]
id, exists := c.AttributeIDs[normalizedPVFName(name)]
if !exists {
continue
}
if _, duplicate := seen[id]; duplicate {
continue
}
seen[id] = struct{}{}
out = append(out, id)
break
}
}
return out
}
// ExpeditionReward follows the native current-EXE formula at 0x38327F0.
//
// Character levels are NOT an input: the reward is the area/duration rate times
// the party-size rate times the attribute-match bonus, rounded to float32 and
// truncated, then scaled by the extra rate. designated is the area's rotated
// attribute set, supplied by the caller so the value the client was shown and
// the value the server pays come from the same snapshot.
func (c RuntimeConfig) ExpeditionReward(areaIndex byte, durationSeconds uint32, members []ExpeditionMemberInput, designated []byte) (uint32, bool) {
area, ok := c.Area(areaIndex)
if !ok || len(members) == 0 || len(members) > 4 ||
len(c.PartyMemberRewardRates) <= len(members) || c.ExpeditionExtraRate <= 0 {
return 0, false
}
rate, ok := area.RewardRates[durationSeconds]
if !ok || rate <= 0 {
return 0, false
}
partyAttributes := make(map[byte]struct{})
for _, member := range members {
if member.Level <= 0 {
return 0, false
}
attributes := c.CharacterAttributes[[2]byte{member.Job, member.GrowType & 0x0f}]
for _, name := range attributes {
if id, exists := c.AttributeIDs[normalizedPVFName(name)]; exists {
partyAttributes[id] = struct{}{}
}
}
}
matched := 0
for _, wanted := range designated {
if _, found := partyAttributes[wanted]; found {
matched++
}
}
if matched >= len(c.AttributeRewardRates) {
matched = len(c.AttributeRewardRates) - 1
}
bonus := 1.0
if matched >= 0 && len(c.AttributeRewardRates) > 0 {
bonus = c.AttributeRewardRates[matched]
}
// The current EXE rounds the float32 product, then truncates after the
// extra rate. Character levels are not an input to this formula.
product := float32(float32(rate) * float32(c.PartyMemberRewardRates[len(members)]))
product = float32(product * float32(bonus))
rounded := math.Trunc(float64(float32(product + 0.5)))
value := math.Trunc(float64(float32(float32(rounded) * float32(c.ExpeditionExtraRate))))
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
return 0, false
}
if value > math.MaxUint32 {
return math.MaxUint32, true
}
return uint32(value), true
}
type ExpeditionMemberInput struct {
Level int
Job byte
GrowType byte
}
func loadRuntimeConfig(ctx context.Context, source dnfpvf.Source) (RuntimeConfig, error) {
systemText, err := source.ReadText(AdventureSystem2018Path)
if err != nil {
return RuntimeConfig{}, fmt.Errorf("read dnf adventure-group PVF %q: %w", AdventureSystem2018Path, err)
}
systemDoc, err := dnfpvf.Parse(AdventureSystem2018Path, systemText)
if err != nil {
return RuntimeConfig{}, fmt.Errorf("parse dnf adventure-group PVF %q: %w", AdventureSystem2018Path, err)
}
expeditionText, err := source.ReadText(ExpeditionSystemPath)
if err != nil {
return RuntimeConfig{}, fmt.Errorf("read dnf adventure-group PVF %q: %w", ExpeditionSystemPath, err)
}
expeditionDoc, err := dnfpvf.Parse(ExpeditionSystemPath, expeditionText)
if err != nil {
return RuntimeConfig{}, fmt.Errorf("parse dnf adventure-group PVF %q: %w", ExpeditionSystemPath, err)
}
if ctx != nil {
if err := ctx.Err(); err != nil {
return RuntimeConfig{}, err
}
}
shops, err := parseShopCategories(systemDoc)
if err != nil {
return RuntimeConfig{}, err
}
growth, err := parseGrowthConfig(systemDoc)
if err != nil {
return RuntimeConfig{}, err
}
capsule, err := parseCapsule(systemDoc)
if err != nil {
return RuntimeConfig{}, err
}
areas, err := parseExpeditionAreas(expeditionDoc)
if err != nil {
return RuntimeConfig{}, err
}
professionClasses, professionGroupCount, err := parseProfessionClassifications(systemDoc)
if err != nil {
return RuntimeConfig{}, err
}
config := RuntimeConfig{
ShopCategories: shops,
Growth: growth,
Capsule: capsule,
ExpeditionAreas: areas,
AttributeIDs: parseAttributeIDs(expeditionDoc),
AttributeGroups: parseAttributeGroups(expeditionDoc),
CharacterAttributes: parseCharacterAttributes(expeditionDoc),
RotationDays: int(firstSectionInt(expeditionDoc, "area attribute rotation period")),
professionClasses: professionClasses,
professionGroupCount: professionGroupCount,
}
config.AttributeRewardRates, err = sectionFloats(expeditionDoc, "attribute achieve rate")
if err != nil || len(config.AttributeRewardRates) == 0 {
return RuntimeConfig{}, fmt.Errorf("%w: %s [attribute achieve rate]", ErrTableMalformed, ExpeditionSystemPath)
}
config.PartyMemberRewardRates, err = sectionFloats(expeditionDoc, "party member rate")
if err != nil || len(config.PartyMemberRewardRates) != 5 {
return RuntimeConfig{}, fmt.Errorf("%w: %s [party member rate]", ErrTableMalformed, ExpeditionSystemPath)
}
extraRates, err := sectionFloats(expeditionDoc, "extra rate")
if err != nil || len(extraRates) != 1 {
return RuntimeConfig{}, fmt.Errorf("%w: %s [extra rate]", ErrTableMalformed, ExpeditionSystemPath)
}
config.ExpeditionExtraRate = extraRates[0]
if len(config.AttributeIDs) == 0 || len(config.AttributeGroups) == 0 || len(config.CharacterAttributes) == 0 {
return RuntimeConfig{}, fmt.Errorf("%w: %s attribute configuration", ErrTableMalformed, ExpeditionSystemPath)
}
return config, nil
}
var adventureProfessionJobIDs = map[string]byte{
"[swordman]": 0,
"[fighter]": 1,
"[gunner]": 2,
"[mage]": 3,
"[priest]": 4,
"[at gunner]": 5,
"[thief]": 6,
"[at fighter]": 7,
"[at mage]": 8,
"[demonic swordman]": 9,
"[creator mage]": 10,
"[at swordman]": 11,
"[knight]": 12,
"[demonic lancer]": 13,
"[at priest]": 14,
"[gun blader]": 15,
}
func parseProfessionClassifications(doc *dnfpvf.Document) (map[[2]byte]byte, int, error) {
result := make(map[[2]byte]byte)
if doc == nil {
return result, 0, nil
}
insideList := false
currentGroup := -1
groupCount := 0
infoCount := 0
for _, section := range doc.Sections {
switch normalizedPVFName(section.Name) {
case "expert list":
insideList = true
currentGroup = -1
case "/expert list":
if !insideList {
continue
}
if currentGroup >= 0 && infoCount == 0 {
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d has no [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
}
// The group count bounds the profession classification space, so it
// is checked against ProfessionTypeCount (the 20-row descriptor
// block), NOT ContentTypeCount (the 6 u32 content counters).
if groupCount == 0 || groupCount > ProfessionTypeCount {
return nil, 0, fmt.Errorf("%w: %s [expert list] groups=%d", ErrTableMalformed, AdventureSystem2018Path, groupCount)
}
return result, groupCount, nil
case "group":
if !insideList {
continue
}
if currentGroup >= 0 && infoCount == 0 {
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d has no [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
}
tokens := sectionTokens(doc, section)
ordinal := int(firstInt(tokens))
if ordinal != groupCount || ordinal > math.MaxUint8 {
return nil, 0, fmt.Errorf("%w: %s [expert list] group ordinal=%d want=%d", ErrTableMalformed, AdventureSystem2018Path, ordinal, groupCount)
}
currentGroup = ordinal
groupCount++
infoCount = 0
case "info":
if !insideList || currentGroup < 0 {
continue
}
tokens := sectionTokens(doc, section)
if len(tokens) != 3 || tokens[0].Kind != dnfpvf.TokenString ||
tokens[1].Kind != dnfpvf.TokenString || tokens[2].Kind != dnfpvf.TokenInt {
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d malformed [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
}
job, found := adventureProfessionJobIDs[normalizedPVFName(tokens[0].Value)]
awakening := normalizedPVFName(tokens[1].Value)
grow := tokens[2].Int
if !found || (awakening != "second awakening" && awakening != "second realization") ||
grow <= 0 || grow > math.MaxUint8 {
return nil, 0, fmt.Errorf("%w: %s [expert list] group=%d invalid [info]", ErrTableMalformed, AdventureSystem2018Path, currentGroup)
}
key := [2]byte{job, byte(grow)}
if _, duplicate := result[key]; duplicate {
return nil, 0, fmt.Errorf("%w: %s [expert list] duplicate job=%d grow=%d", ErrTableMalformed, AdventureSystem2018Path, job, grow)
}
result[key] = byte(currentGroup)
infoCount++
}
}
// Synthetic progression tables may omit [expert list]. Production uses the
// current real PVF and therefore exposes the mapping to representative flows.
if !insideList {
return result, 0, nil
}
return nil, 0, fmt.Errorf("%w: %s unterminated [expert list]", ErrTableMalformed, AdventureSystem2018Path)
}
func parseShopCategories(doc *dnfpvf.Document) ([]ShopCategory, error) {
var categories []ShopCategory
for index := 0; index < len(doc.Sections); index++ {
if !strings.EqualFold(strings.TrimSpace(doc.Sections[index].Name), "shop") {
continue
}
end := index + 1
for end < len(doc.Sections) && !strings.EqualFold(strings.TrimSpace(doc.Sections[end].Name), "/shop") {
end++
}
category := ShopCategory{}
for cursor := index + 1; cursor < end; cursor++ {
section := doc.Sections[cursor]
tokens := sectionTokens(doc, section)
switch normalizedPVFName(section.Name) {
case "index":
category.Index = byte(firstInt(tokens))
case "max shop point":
category.MaxPoint = uint32(firstInt(tokens))
case "reset point":
category.ResetPointMonthly = true
case "exp to get shop point":
values := ints(tokens)
if len(values) == 2 && values[0] > 0 && values[1] > 0 {
category.ExperiencePerPoint = uint64(values[0])
category.PointPerExperience = uint32(values[1])
}
case "adventurer shop purchase info":
values := ints(tokens)
if len(values)%5 != 0 {
return nil, fmt.Errorf("%w: %s shop=%d purchase integers=%d", ErrTableMalformed, AdventureSystem2018Path, category.Index, len(values))
}
for offset := 0; offset < len(values); offset += 5 {
if values[offset] <= 0 || values[offset+1] < 0 || values[offset+2] <= 0 || values[offset+3] <= 0 {
return nil, fmt.Errorf("%w: %s shop=%d purchase row=%d", ErrTableMalformed, AdventureSystem2018Path, category.Index, offset/5)
}
category.Products = append(category.Products, ShopProduct{
ItemID: uint32(values[offset]),
RequiredManageLevel: int(values[offset+1]),
Cost: uint32(values[offset+2]),
MonthlyPurchaseLimit: uint32(values[offset+3]),
ResetType: int(values[offset+4]),
})
}
}
}
if len(category.Products) > 0 {
categories = append(categories, category)
}
index = end
}
sort.Slice(categories, func(i, j int) bool { return categories[i].Index < categories[j].Index })
if len(categories) < ShopPointTypeCount {
return nil, fmt.Errorf("%w: %s shop categories=%d", ErrTableEmpty, AdventureSystem2018Path, len(categories))
}
return categories, nil
}
func parseCapsule(doc *dnfpvf.Document) (CapsuleConfig, error) {
config := CapsuleConfig{
MinimumExperience: uint64(firstSectionInt(doc, "capsule need minimum exp")),
MaximumExperience: uint64(firstSectionInt(doc, "capsule max exp")),
MaximumCount: uint32(firstSectionInt(doc, "capsule max count")),
MinimumLevel: int(firstSectionInt(doc, "capsule limit level min")),
MaximumLevel: int(firstSectionInt(doc, "capsule limit level max")),
GrantedExperience: uint32(firstSectionInt(doc, "capsule give exp")),
}
tokens, _ := doc.Section("capsule update date")
if len(tokens) == 1 && tokens[0].Kind == dnfpvf.TokenString {
config.UpdateAt, _ = time.ParseInLocation("2006-01-02 15:04:05", tokens[0].Value, adventureGroupCalendarLocation)
}
if config.MinimumExperience == 0 || config.MaximumExperience < config.MinimumExperience ||
config.MaximumCount == 0 || config.MinimumLevel <= 0 || config.MaximumLevel < config.MinimumLevel ||
config.GrantedExperience == 0 {
return CapsuleConfig{}, fmt.Errorf("%w: %s capsule configuration", ErrTableMalformed, AdventureSystem2018Path)
}
return config, nil
}
func parseExpeditionAreas(doc *dnfpvf.Document) ([]ExpeditionArea, error) {
var areas []ExpeditionArea
for index := 0; index < len(doc.Sections); index++ {
if normalizedPVFName(doc.Sections[index].Name) != "area" {
continue
}
end := index + 1
for end < len(doc.Sections) && normalizedPVFName(doc.Sections[end].Name) != "/area" {
end++
}
area := ExpeditionArea{RewardRates: make(map[uint32]float64)}
for cursor := index + 1; cursor < end; cursor++ {
section := doc.Sections[cursor]
tokens := sectionTokens(doc, section)
switch normalizedPVFName(section.Name) {
case "index":
area.Index = byte(firstInt(tokens))
case "required level":
area.RequiredManageLevel = int(firstInt(tokens))
case "designated attribute":
for _, token := range tokens {
if token.Kind == dnfpvf.TokenString {
area.DesignatedGroups = append(area.DesignatedGroups, token.Value)
}
}
case "reward info":
if len(tokens)%2 != 0 {
return nil, fmt.Errorf("%w: %s area=%d reward tokens=%d", ErrTableMalformed, ExpeditionSystemPath, area.Index, len(tokens))
}
for offset := 0; offset < len(tokens); offset += 2 {
hours, rate := tokenNumber(tokens[offset]), tokenNumber(tokens[offset+1])
if hours <= 0 || rate <= 0 {
return nil, fmt.Errorf("%w: %s area=%d reward row=%d", ErrTableMalformed, ExpeditionSystemPath, area.Index, offset/2)
}
area.RewardRates[uint32(hours*3600)] = rate
}
}
}
if area.Index > 0 && area.RequiredManageLevel > 0 && len(area.RewardRates) > 0 {
areas = append(areas, area)
}
index = end
}
sort.Slice(areas, func(i, j int) bool { return areas[i].Index < areas[j].Index })
if len(areas) == 0 {
return nil, fmt.Errorf("%w: %s expedition areas", ErrTableEmpty, ExpeditionSystemPath)
}
return areas, nil
}
func parseAttributeIDs(doc *dnfpvf.Document) map[string]byte {
out := make(map[string]byte)
tokens, _ := doc.Section("attribute data")
for index := 0; index+1 < len(tokens); {
if tokens[index].Kind != dnfpvf.TokenInt || tokens[index+1].Kind != dnfpvf.TokenString {
index++
continue
}
id := tokens[index].Int
name := normalizedPVFName(tokens[index+1].Value)
if id > 0 && id <= math.MaxUint8 && id != math.MaxUint8 {
out[name] = byte(id)
}
index += 2
for index < len(tokens) && tokens[index].Kind != dnfpvf.TokenInt {
index++
}
if index < len(tokens) {
index++
}
}
return out
}
func parseAttributeGroups(doc *dnfpvf.Document) map[string][]string {
out := make(map[string][]string)
for _, section := range doc.Sections {
if normalizedPVFName(section.Name) != "group" {
continue
}
tokens := sectionTokens(doc, section)
if len(tokens) < 2 || tokens[0].Kind != dnfpvf.TokenString {
continue
}
key := normalizedPVFName(tokens[0].Value)
for _, token := range tokens[1:] {
if token.Kind == dnfpvf.TokenString {
out[key] = append(out[key], token.Value)
}
}
}
if groupC := out[normalizedPVFName("[group C]")]; len(groupC) > 0 {
out[normalizedPVFName("[GROUP C ALLOW]")] = append([]string(nil), groupC...)
}
return out
}
func parseCharacterAttributes(doc *dnfpvf.Document) map[[2]byte][]string {
out := make(map[[2]byte][]string)
tokens, _ := doc.Section("character attribute")
for index := 0; index+5 < len(tokens); index += 6 {
if tokens[index].Kind != dnfpvf.TokenInt || tokens[index+1].Kind != dnfpvf.TokenInt {
continue
}
key := [2]byte{byte(tokens[index].Int), byte(tokens[index+1].Int)}
for _, token := range tokens[index+3 : index+6] {
if token.Kind == dnfpvf.TokenString {
out[key] = append(out[key], token.Value)
}
}
}
return out
}
func sectionTokens(doc *dnfpvf.Document, section dnfpvf.Section) []dnfpvf.Token {
if doc == nil || section.Start < 0 || section.End > len(doc.Tokens) || section.Start > section.End {
return nil
}
return doc.Tokens[section.Start:section.End]
}
func firstSectionInt(doc *dnfpvf.Document, name string) int64 {
tokens, _ := doc.Section(name)
return firstInt(tokens)
}
func firstInt(tokens []dnfpvf.Token) int64 {
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
return token.Int
}
}
return 0
}
func ints(tokens []dnfpvf.Token) []int64 {
values := make([]int64, 0, len(tokens))
for _, token := range tokens {
if token.Kind == dnfpvf.TokenInt {
values = append(values, token.Int)
}
}
return values
}
func sectionFloats(doc *dnfpvf.Document, name string) ([]float64, error) {
tokens, ok := doc.Section(name)
if !ok {
return nil, ErrTableEmpty
}
values := make([]float64, 0, len(tokens))
for _, token := range tokens {
value := tokenNumber(token)
if value <= 0 {
return nil, ErrTableMalformed
}
values = append(values, value)
}
return values, nil
}
func tokenNumber(token dnfpvf.Token) float64 {
switch token.Kind {
case dnfpvf.TokenInt:
return float64(token.Int)
case dnfpvf.TokenFloat:
return token.Float
default:
return 0
}
}
func normalizedPVFName(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
@@ -0,0 +1,116 @@
package adventuregroup
import (
"fmt"
"strings"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
// The live adventurersystem2018.etc ships 15 expert groups, and that count
// bounds the 20-row profession descriptor block (ProfessionTypeCount) rather
// than the 6 u32 content counters (ContentTypeCount). Confusing the two made
// the table look malformed and the server refused to start against the real
// PVF while every fixture-based test still passed, so this pins the real
// group count.
func TestParseProfessionClassificationsAcceptsTheFullNativeGroupCount(t *testing.T) {
var b strings.Builder
b.WriteString("[expert list]\n")
for group := 0; group < 15; group++ {
b.WriteString("[group]\n")
fmt.Fprintf(&b, "%d `group %d`\n", group, group)
b.WriteString("[info]\n")
fmt.Fprintf(&b, "`[swordman]` `second awakening` %d\n", group+1)
b.WriteString("[/info]\n")
b.WriteString("[/group]\n")
}
b.WriteString("[/expert list]")
document, err := dnfpvf.Parse(AdventureSystem2018Path, b.String())
if err != nil {
t.Fatal(err)
}
_, groups, err := parseProfessionClassifications(document)
if err != nil {
t.Fatalf("15 native groups rejected: %v", err)
}
if groups != 15 {
t.Fatalf("groups=%d, want 15", groups)
}
if groups > ProfessionTypeCount {
t.Fatalf("ProfessionTypeCount=%d cannot hold %d native groups", ProfessionTypeCount, groups)
}
}
func TestParseProfessionClassificationsUsesOrderedExpertGroups(t *testing.T) {
document, err := dnfpvf.Parse(AdventureSystem2018Path, `[expert list]
[group]
0 `+"`鬼剑士 (男)`"+`
[info]
`+"`[swordman]` `second awakening` 1"+`
[/info]
[info]
`+"`[swordman]` `second awakening` 4"+`
[/info]
[/group]
[group]
1 `+"`外传`"+`
[info]
`+"`[creator mage]` `second realization` 1"+`
[/info]
[info]
`+"`[demonic swordman]` `second realization` 1"+`
[/info]
[/group]
[/expert list]`)
if err != nil {
t.Fatal(err)
}
classes, groups, err := parseProfessionClassifications(document)
if err != nil {
t.Fatal(err)
}
config := RuntimeConfig{professionClasses: classes, professionGroupCount: groups}
if config.ProfessionGroupCount() != 2 {
t.Fatalf("groups=%d", config.ProfessionGroupCount())
}
for _, test := range []struct {
job, grow int
want byte
}{
{job: 0, grow: 1, want: 0},
{job: 0, grow: 4, want: 0},
{job: 10, grow: 1, want: 1},
{job: 9, grow: 1, want: 1},
} {
got, found := config.ProfessionClassification(test.job, test.grow)
if !found || got != test.want {
t.Fatalf("job=%d grow=%d classification=%d/%t want=%d", test.job, test.grow, got, found, test.want)
}
}
if _, found := config.ProfessionClassification(0, 2); found {
t.Fatal("undefined profession slot resolved")
}
clone := config.Clone()
clone.professionClasses[[2]byte{0, 1}] = 9
if original, _ := config.ProfessionClassification(0, 1); original != 0 {
t.Fatal("runtime clone shares profession map")
}
}
func TestParseProfessionClassificationsRejectsMalformedOrder(t *testing.T) {
document, err := dnfpvf.Parse(AdventureSystem2018Path, `[expert list]
[group]
1 `+"`bad ordinal`"+`
[info]
`+"`[swordman]` `second awakening` 1"+`
[/info]
[/group]
[/expert list]`)
if err != nil {
t.Fatal(err)
}
if _, _, err := parseProfessionClassifications(document); err == nil {
t.Fatal("malformed group order accepted")
}
}
@@ -0,0 +1,263 @@
package adventuregroup
import (
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const RuntimeStateMetadataKey = "adventure_group_runtime_v1"
var (
ErrRuntimeStateInvalid = errors.New("adventure-group runtime state is invalid")
// ErrPurchaseLimitReached / ErrInsufficientShopPoints 是购买被拒的两种
// 业务原因。客户端 op1414 的结果码要按原因区分(19 = 超过购买限制、
// 22 = 购买材料不足),回一个笼统的失败码会让客户端**完全不弹提示** ——
// 所以这两种情况必须是可识别的独立错误,不能都并进 ErrRuntimeStateInvalid。
ErrPurchaseLimitReached = errors.New("adventure-group shop monthly purchase limit is reached")
ErrInsufficientShopPoints = errors.New("adventure-group shop points are insufficient")
)
type RuntimeState struct {
Version int `json:"version"`
ShopPoints [ShopPointTypeCount]uint32 `json:"shop_points"`
BraveExperience uint64 `json:"brave_experience"`
GrowthExperience uint64 `json:"growth_experience"`
Month string `json:"month"`
// CapsuleWeek 记录成长胶囊槽上一次"归属"的那一周(每周四 06:00 为界)。
// 见 Normalize:跨周才清零,且第一次见到空值是**认领当前值**而不是清零。
CapsuleWeek string `json:"capsule_week,omitempty"`
Purchases map[string]uint32 `json:"purchases,omitempty"`
Expeditions map[string]Expedition `json:"expeditions,omitempty"`
}
type Expedition struct {
Area byte `json:"area"`
State byte `json:"state"`
StartedAt int64 `json:"started_at"`
EndsAt int64 `json:"ends_at"`
Attributes []byte `json:"attributes,omitempty"`
Members []ExpeditionMember `json:"members"`
Reward uint32 `json:"reward"`
}
type ExpeditionMember struct {
// PersistentCharacterID is the durable character ID. The native client
// echoes the u16 selector, but the selector is a roster slot and moves when
// the roster is reordered, so busy detection must key on this instead.
PersistentCharacterID string `json:"persistent_character_id,omitempty"`
CharacterID uint16 `json:"character_id"`
Name string `json:"name"`
Level uint32 `json:"level"`
Job byte `json:"job"`
GrowType byte `json:"grow_type"`
Status byte `json:"status"`
}
func ParseRuntimeState(account dnfrepo.AccountRecord, config RuntimeConfig, now time.Time) (RuntimeState, error) {
state := RuntimeState{Version: 1}
value := strings.TrimSpace(account.Metadata[RuntimeStateMetadataKey])
if value != "" {
if err := json.Unmarshal([]byte(value), &state); err != nil {
return RuntimeState{}, fmt.Errorf("%w: %v", ErrRuntimeStateInvalid, err)
}
if state.Version != 1 {
return RuntimeState{}, fmt.Errorf("%w: version=%d", ErrRuntimeStateInvalid, state.Version)
}
}
if state.Purchases == nil {
state.Purchases = make(map[string]uint32)
}
if state.Expeditions == nil {
state.Expeditions = make(map[string]Expedition)
}
state.Normalize(config, now)
return state, nil
}
func (s *RuntimeState) Normalize(config RuntimeConfig, now time.Time) {
if s == nil {
return
}
s.Version = 1
month := now.In(adventureGroupCalendarLocation).Format("2006-01")
if s.Month != month {
s.Month = month
s.Purchases = make(map[string]uint32)
for _, category := range config.ShopCategories {
if category.ResetPointMonthly && int(category.Index) < len(s.ShopPoints) {
s.ShopPoints[category.Index] = 0
}
}
}
// 成长胶囊槽按周重置:客户端界面写明"成长胶囊于每周四凌晨6点重置"。
// 第一次读到(旧存档没有这个字段)只**认领**当前周,绝不清零 —— 否则升级
// 部署那一刻会把玩家攒下的一整槽胶囊静默清掉。
capsuleWeek := adventureGroupCapsuleWeek(now)
if s.CapsuleWeek == "" {
s.CapsuleWeek = capsuleWeek
} else if s.CapsuleWeek != capsuleWeek {
s.CapsuleWeek = capsuleWeek
s.GrowthExperience = 0
}
if s.Purchases == nil {
s.Purchases = make(map[string]uint32)
}
if s.Expeditions == nil {
s.Expeditions = make(map[string]Expedition)
}
for _, category := range config.ShopCategories {
if int(category.Index) < len(s.ShopPoints) && category.MaxPoint > 0 &&
s.ShopPoints[category.Index] > category.MaxPoint {
s.ShopPoints[category.Index] = category.MaxPoint
}
}
if capacity := CapsuleTotalCapacity(config.Capsule); capacity > 0 && s.GrowthExperience > capacity {
s.GrowthExperience = capacity
}
}
// adventureGroupCapsuleWeek 返回 now 所属"胶囊周"的标识:以**每周四 06:00**
// (Asia/Shanghai)为分界,标识取该周起始那一天的日期。界面文案是
// "成长胶囊于每周四凌晨6点重置。",所以周四 06:00 之前仍算上一周。
func adventureGroupCapsuleWeek(now time.Time) string {
local := now.In(adventureGroupCalendarLocation)
// time.Weekday: Sunday=0 ... Thursday=4
daysSinceThursday := (int(local.Weekday()) - int(time.Thursday) + 7) % 7
boundary := time.Date(local.Year(), local.Month(), local.Day(), 6, 0, 0, 0, adventureGroupCalendarLocation).
AddDate(0, 0, -daysSinceThursday)
if local.Before(boundary) {
boundary = boundary.AddDate(0, 0, -7)
}
return boundary.Format("2006-01-02")
}
func (s RuntimeState) Marshal() (string, error) {
data, err := json.Marshal(s)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrRuntimeStateInvalid, err)
}
return string(data), nil
}
func (s RuntimeState) PurchaseCount(category byte, itemID uint32) uint32 {
return s.Purchases[purchaseKey(category, itemID)]
}
func (s *RuntimeState) Spend(category ShopCategory, product ShopProduct) error {
if s == nil || int(category.Index) >= len(s.ShopPoints) {
return ErrRuntimeStateInvalid
}
count := s.PurchaseCount(category.Index, product.ItemID)
if count >= product.MonthlyPurchaseLimit {
return ErrPurchaseLimitReached
}
if s.ShopPoints[category.Index] < product.Cost {
return ErrInsufficientShopPoints
}
if s.Purchases == nil {
// ParseRuntimeState/Normalize 一定会建好这张表;这里只是不让手工构造的
// 状态把"记一次购买"变成 panic。
s.Purchases = make(map[string]uint32)
}
s.ShopPoints[category.Index] -= product.Cost
s.Purchases[purchaseKey(category.Index, product.ItemID)] = count + 1
return nil
}
func (s *RuntimeState) AddGlory(config RuntimeConfig, value uint32) {
if s == nil || value == 0 {
return
}
category, ok := config.Shop(ShopPointGlory)
if !ok {
return
}
s.ShopPoints[ShopPointGlory] = saturatingPointAdd(s.ShopPoints[ShopPointGlory], value, category.MaxPoint)
}
// AddExperience 记入一笔**已经发给角色**的经验。
//
// - crystalExperience(任意等级获得的经验)按 PVF `[exp to get shop point]`
// 进骁勇结晶,余数带在 BraveExperience 里,封顶 `[max shop point]`。
// 角色自己的升级经验不受影响,这只是账号账本上的镜像累计
// (2026-09-24 服主口径:没满级的角色也要能攒结晶)。
// - maximumLevelExperience(其中越过等级上限的部分)再进成长胶囊槽:胶囊槽
// 是满级专属(客户端说明写着"Lv90以上的角色获得经验值时,经验值会累积在
// 成长胶囊槽中"),未满级经验一律不进。
func (s *RuntimeState) AddExperience(
config RuntimeConfig,
crystalExperience uint64,
maximumLevelExperience uint64,
) {
if s == nil || (crystalExperience == 0 && maximumLevelExperience == 0) {
return
}
if category, ok := config.Shop(ShopPointBrave); ok && category.ExperiencePerPoint > 0 && category.PointPerExperience > 0 {
total := s.BraveExperience + crystalExperience
points := total / category.ExperiencePerPoint
s.BraveExperience = total % category.ExperiencePerPoint
if points > math.MaxUint32 {
points = math.MaxUint32
}
add := uint64(category.PointPerExperience) * points
if add > math.MaxUint32 {
add = math.MaxUint32
}
s.ShopPoints[ShopPointBrave] = saturatingPointAdd(s.ShopPoints[ShopPointBrave], uint32(add), category.MaxPoint)
}
if maximumLevelExperience == 0 {
return
}
// 胶囊槽可以攒多格:总容量 = [capsule max exp] × [capsule max count]。
// 只按单格上限截断会让累计值停在正好 100%,客户端那条进度条永远 0%
// (分子的 "累计值 mod 单格" 恒为 0),现场表现为"胶囊不累计"。
if capacity := CapsuleTotalCapacity(config.Capsule); capacity > 0 {
remaining := capacity - minUint64(capacity, s.GrowthExperience)
add := maximumLevelExperience
if add > remaining {
add = remaining
}
s.GrowthExperience += add
}
}
func (s *RuntimeState) ConsumeCapsule(config RuntimeConfig) bool {
if s == nil || config.Capsule.MinimumExperience == 0 || s.GrowthExperience < config.Capsule.MinimumExperience {
return false
}
s.GrowthExperience -= config.Capsule.MinimumExperience
return true
}
func purchaseKey(category byte, itemID uint32) string {
return strconv.Itoa(int(category)) + ":" + strconv.FormatUint(uint64(itemID), 10)
}
func ExpeditionKey(area byte) string {
return strconv.Itoa(int(area))
}
func saturatingPointAdd(current, value, maximum uint32) uint32 {
total := uint64(current) + uint64(value)
if maximum > 0 && total > uint64(maximum) {
return maximum
}
if total > math.MaxUint32 {
return math.MaxUint32
}
return uint32(total)
}
func minUint64(left, right uint64) uint64 {
if left < right {
return left
}
return right
}
@@ -0,0 +1,219 @@
package adventuregroup
import (
"errors"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestRuntimeStateMonthlyResetAccrualPurchaseAndCapsule(t *testing.T) {
config := RuntimeConfig{
ShopCategories: []ShopCategory{
{
Index: ShopPointBrave,
MaxPoint: 250,
ExperiencePerPoint: 15_000_000,
PointPerExperience: 1,
Products: []ShopProduct{{
ItemID: 10, Cost: 1, MonthlyPurchaseLimit: 2,
}},
},
{Index: ShopPointGlory, MaxPoint: 9999, ResetPointMonthly: true},
{Index: ShopPointPure},
},
Capsule: CapsuleConfig{
MinimumExperience: 10,
MaximumExperience: 100,
MaximumCount: 10,
MinimumLevel: 50,
MaximumLevel: 86,
GrantedExperience: 1,
},
}
account := dnfrepo.AccountRecord{
AccountID: "dnf:1",
Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"shop_points":[7,80,0],"month":"2026-06","purchases":{"0:10":2}}`,
},
}
now := time.Date(2026, time.July, 28, 12, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
state, err := ParseRuntimeState(account, config, now)
if err != nil {
t.Fatal(err)
}
if state.ShopPoints != [ShopPointTypeCount]uint32{7, 0, 0} || len(state.Purchases) != 0 {
t.Fatalf("monthly normalized state=%+v", state)
}
// 满级角色的整笔经验:全额进结晶,同样全额(= 越过上限的部分)进胶囊槽。
state.AddExperience(config, 30_000_005, 30_000_005)
// 胶囊总容量 = [capsule max exp] 100 × [capsule max count] 10 = 1000
// (PVF 真值:116,914,950 × 10;界面写"累积数上限 : 10个")。
// 早先这里按"单格 100 就是总上限"写,会把累计值卡死在正好 100%,
// 客户端进度分子(累计值 mod 单格)恒 0 —— 现场就是"胶囊不累计"。
if state.ShopPoints[ShopPointBrave] != 9 || state.BraveExperience != 5 || state.GrowthExperience != 1000 {
t.Fatalf("maximum-level accrual=%+v", state)
}
category, _ := config.Shop(ShopPointBrave)
if err := state.Spend(category, category.Products[0]); err != nil {
t.Fatal(err)
}
if state.ShopPoints[ShopPointBrave] != 8 || state.PurchaseCount(ShopPointBrave, 10) != 1 {
t.Fatalf("purchase state=%+v", state)
}
if !state.ConsumeCapsule(config) || state.GrowthExperience != 990 {
t.Fatalf("capsule state=%+v", state)
}
}
// 未满级经验只进骁勇结晶:胶囊槽与名誉经验都是满级专属(2026-09-24 口径)。
func TestRuntimeStateAddExperienceKeepsSubCapExperienceOutOfCapsule(t *testing.T) {
config := creditTestConfig()
state := RuntimeState{Version: 1}
state.AddExperience(config, 30_000_005, 0)
if state.ShopPoints[ShopPointBrave] != 2 || state.BraveExperience != 5 {
t.Fatalf("sub-cap crystals=%+v", state)
}
if state.GrowthExperience != 0 {
t.Fatalf("sub-cap experience entered the capsule slot: %+v", state)
}
}
// 购买被拒的两种业务原因必须是可区分的错误:客户端 op1414 的结果码要按原因回
// (19 = 超过购买限制、22 = 购买材料不足),回一个笼统失败码客户端什么都不弹。
func TestRuntimeStateSpendDistinguishesLimitAndInsufficientPoints(t *testing.T) {
product := ShopProduct{ItemID: 10, Cost: 5, MonthlyPurchaseLimit: 1}
category := ShopCategory{Index: ShopPointBrave, MaxPoint: 250, Products: []ShopProduct{product}}
state := RuntimeState{Version: 1}
// 点数不足:0 点买 5 点的商品。
if err := state.Spend(category, product); !errors.Is(err, ErrInsufficientShopPoints) {
t.Fatalf("insufficient points error=%v", err)
}
// 给够点数后先买一次成功,再用完本月次数。
state.ShopPoints[ShopPointBrave] = 10
if err := state.Spend(category, product); err != nil {
t.Fatalf("first purchase=%v", err)
}
if err := state.Spend(category, product); !errors.Is(err, ErrPurchaseLimitReached) {
t.Fatalf("limit error=%v", err)
}
// 分类号越界仍然是"状态非法",不是业务拒绝。
bad := ShopCategory{Index: ShopPointTypeCount, Products: []ShopProduct{product}}
if err := state.Spend(bad, product); !errors.Is(err, ErrRuntimeStateInvalid) {
t.Fatalf("invalid category error=%v", err)
}
}
// 成长胶囊槽按周重置:界面写明"成长胶囊于每周四凌晨6点重置"。
// 两条关键契约:① 第一次读到旧存档(没有 capsule_week 字段)**不许清零**;
// ② 跨越周四 06:00 之后才清零。
func TestRuntimeStateGrowthCapsuleResetsWeeklyOnThursdaySix(t *testing.T) {
// 上限取真实 PVF 值(116,914,950 = 10 × [capsule need minimum exp]),
// 免得归一化里的封顶把"是否被周重置"这件事掩盖掉。
config := RuntimeConfig{Capsule: CapsuleConfig{MaximumExperience: 116_914_950}}
shanghai := time.FixedZone("UTC+8", 8*60*60)
// 2026-09-23 是周三。周四 06:00 之前仍属上一周(2026-09-17)。
wednesday := time.Date(2026, time.September, 23, 12, 0, 0, 0, shanghai)
state, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":116914950,"month":"2026-09"}`,
}},
config,
wednesday,
)
if err != nil {
t.Fatal(err)
}
// 旧存档首次归一化:认领当前值,绝不清零。
if state.GrowthExperience != 116_914_950 || state.CapsuleWeek != "2026-09-17" {
t.Fatalf("first observation reset the capsule: %+v", state)
}
// 同一周内再读一次:不动。
sameWeek, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":500,"capsule_week":"2026-09-17"}`,
}},
config,
time.Date(2026, time.September, 23, 23, 59, 0, 0, shanghai),
)
if err != nil {
t.Fatal(err)
}
if sameWeek.GrowthExperience != 500 {
t.Fatalf("same-week state reset: %+v", sameWeek)
}
// 周四 05:59 仍是上一周;06:00 起进入新的一周 → 清零。
beforeBoundary, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":500,"capsule_week":"2026-09-17"}`,
}},
config,
time.Date(2026, time.September, 24, 5, 59, 0, 0, shanghai),
)
if err != nil {
t.Fatal(err)
}
if beforeBoundary.GrowthExperience != 500 {
t.Fatalf("reset happened before Thursday 06:00: %+v", beforeBoundary)
}
afterBoundary, err := ParseRuntimeState(
dnfrepo.AccountRecord{AccountID: "dnf:1", Metadata: map[string]string{
RuntimeStateMetadataKey: `{"version":1,"growth_experience":500,"capsule_week":"2026-09-17"}`,
}},
config,
time.Date(2026, time.September, 24, 6, 0, 0, 0, shanghai),
)
if err != nil {
t.Fatal(err)
}
if afterBoundary.GrowthExperience != 0 || afterBoundary.CapsuleWeek != "2026-09-24" {
t.Fatalf("weekly capsule reset missing: %+v", afterBoundary)
}
// 骁勇结晶一辈子不重置,跨周也不许动。
if afterBoundary.BraveExperience != 0 || afterBoundary.ShopPoints[ShopPointBrave] != 0 {
t.Fatalf("weekly reset touched crystals: %+v", afterBoundary)
}
}
func TestRuntimeConfigExpeditionRewardUsesRotatedPVFAttributes(t *testing.T) {
config := RuntimeConfig{
ExpeditionAreas: []ExpeditionArea{{
Index: 1,
RewardRates: map[uint32]float64{
6 * 60 * 60: 2,
},
DesignatedGroups: []string{"[group A]"},
}},
AttributeIDs: map[string]byte{
"[a]": 1,
},
AttributeGroups: map[string][]string{
"[group a]": {"[a]"},
},
CharacterAttributes: map[[2]byte][]string{
{1, 2}: {"[a]"},
},
AttributeRewardRates: []float64{1, 1.5},
PartyMemberRewardRates: []float64{1, 1, 2, 3, 4},
ExpeditionExtraRate: 0.5,
RotationDays: 1,
}
// Native formula: rate 2 x party rate 1 x attribute bonus 1.5 = 3, rounded,
// then x extra rate 0.5 and truncated -> 1.
reward, ok := config.ExpeditionReward(
1,
6*60*60,
[]ExpeditionMemberInput{{Level: 90, Job: 1, GrowType: 2}},
config.AreaAttributes(1, time.Unix(1_700_000_000, 0)),
)
if !ok || reward != 1 {
t.Fatalf("reward=%d ok=%v", reward, ok)
}
}
@@ -0,0 +1,198 @@
package adventuregroup
import (
"errors"
"fmt"
"math"
)
const CharacterManagementPath = "etc/linksystem/charactermanage.etc"
const (
AdventureSystem2018Path = "etc/adventurersystem/adventurersystem2018.etc"
ExpeditionSystemPath = "etc/adventurersystem/adventurerexpeditionsystem.etc"
)
var (
ErrTableEmpty = errors.New("dnf adventure-group PVF table is empty")
ErrTableMalformed = errors.New("dnf adventure-group PVF table is malformed")
ErrCharacterLevel = errors.New("dnf adventure-group character level is invalid")
ErrPointOverflow = errors.New("dnf adventure-group point total overflows uint64")
)
// Character contains only the account-roster state needed by the adventure
// group calculation. Deleted characters never contribute points.
type Character struct {
Level int
Deleted bool
}
// Benefits are exact lookups for one PVF management level. A missing table row
// is represented by zero; values are not inherited from an earlier level.
type Benefits struct {
ExpBonusPercent uint64
GoldBonusPercent uint64
ManageOption uint64
}
// Summary is the pure account-wide result derived from character levels and
// the current runtime PVF tables.
type Summary struct {
TotalPoint uint64
ManageLevel int
Benefits
}
// Snapshot reports typed source coverage without exposing mutable table maps.
type Snapshot struct {
PointRanges int
ManageThresholds int
ManageLevelMax int
ExpBonusLevels int
GoldBonusLevels int
ManageOptionLevels int
}
type pointRange struct {
minLevel int
maxLevel int
point uint64
}
// Tables is an immutable snapshot of etc/linksystem/charactermanage.etc.
type Tables struct {
pointRanges []pointRange
manageThresholds []uint64
manageLevelMax int
expBonusByLevel map[int]uint64
goldBonusByLevel map[int]uint64
optionByLevel map[int]uint64
runtime RuntimeConfig
}
// Snapshot returns table coverage counts useful for startup diagnostics and
// real-PVF smoke tests.
func (t *Tables) Snapshot() Snapshot {
if t == nil {
return Snapshot{}
}
return Snapshot{
PointRanges: len(t.pointRanges),
ManageThresholds: len(t.manageThresholds),
ManageLevelMax: t.manageLevelMax,
ExpBonusLevels: len(t.expBonusByLevel),
GoldBonusLevels: len(t.goldBonusByLevel),
ManageOptionLevels: len(t.optionByLevel),
}
}
// Runtime returns a detached copy of the current PVF-backed adventure-group
// expedition, shop and growth-capsule configuration.
func (t *Tables) Runtime() RuntimeConfig {
if t == nil {
return RuntimeConfig{}
}
return t.runtime.Clone()
}
// CharacterPoint sums every per-level point value whose level is at or below
// the character's level. PVF point ranges may contain gaps.
func (t *Tables) CharacterPoint(level int) (uint64, error) {
if t == nil || len(t.pointRanges) == 0 {
return 0, ErrTableEmpty
}
if level <= 0 {
return 0, fmt.Errorf("%w: %d", ErrCharacterLevel, level)
}
var total uint64
for _, row := range t.pointRanges {
if level < row.minLevel {
break
}
end := level
if end > row.maxLevel {
end = row.maxLevel
}
count := uint64(end - row.minLevel + 1)
if row.point != 0 && count > math.MaxUint64/row.point {
return 0, fmt.Errorf("%w: level=%d range=%d..%d", ErrPointOverflow, level, row.minLevel, row.maxLevel)
}
contribution := count * row.point
if total > math.MaxUint64-contribution {
return 0, fmt.Errorf("%w: level=%d", ErrPointOverflow, level)
}
total += contribution
}
return total, nil
}
// ManageLevelForPoint resolves passed thresholds in order, then applies the
// explicit PVF management-level maximum. If PVF provides fewer thresholds than
// that maximum, the highest reachable level remains the last threshold.
func (t *Tables) ManageLevelForPoint(totalPoint uint64) int {
if t == nil {
return 0
}
level := 0
for _, threshold := range t.manageThresholds {
if totalPoint < threshold {
break
}
level++
}
if level > t.manageLevelMax {
return t.manageLevelMax
}
return level
}
// BenefitsForLevel performs exact PVF table lookups. Sparse tables do not
// inherit the previous level's values.
func (t *Tables) BenefitsForLevel(manageLevel int) Benefits {
if t == nil || manageLevel <= 0 {
return Benefits{}
}
return Benefits{
ExpBonusPercent: t.expBonusByLevel[manageLevel],
GoldBonusPercent: t.goldBonusByLevel[manageLevel],
ManageOption: t.optionByLevel[manageLevel],
}
}
// Calculate derives one account summary from lightweight character rows.
func (t *Tables) Calculate(characters []Character) (Summary, error) {
if t == nil || len(t.pointRanges) == 0 {
return Summary{}, ErrTableEmpty
}
var total uint64
for _, character := range characters {
if character.Deleted {
continue
}
point, err := t.CharacterPoint(character.Level)
if err != nil {
return Summary{}, err
}
if total > math.MaxUint64-point {
return Summary{}, fmt.Errorf("%w: account roster", ErrPointOverflow)
}
total += point
}
level := t.ManageLevelForPoint(total)
return Summary{
TotalPoint: total,
ManageLevel: level,
Benefits: t.BenefitsForLevel(level),
}, nil
}
// CalculateLevels is a convenience for callers that already filtered deleted
// characters and only have an account's character levels.
func (t *Tables) CalculateLevels(levels []int) (Summary, error) {
characters := make([]Character, len(levels))
for index, level := range levels {
characters[index] = Character{Level: level}
}
return t.Calculate(characters)
}
@@ -0,0 +1,290 @@
package adventuregroup
import (
"context"
"errors"
"fmt"
"math"
"os"
"strings"
"testing"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestCalculateUsesCumulativePerLevelPointsAndExactBenefits(t *testing.T) {
tables, err := Load(context.Background(), adventureMemorySource{
CharacterManagementPath: managementDocument(
"2 3 10 5 5 20",
"10 30 50",
"2",
"1 5 2 10",
"1 2 2 4",
"1 7 2 9",
),
})
if err != nil {
t.Fatal(err)
}
point, err := tables.CharacterPoint(5)
if err != nil {
t.Fatal(err)
}
if point != 40 {
t.Fatalf("level-5 point = %d, want 40", point)
}
summary, err := tables.Calculate([]Character{
{Level: 1},
{Level: 2},
{Level: 3},
{Level: 5},
{Level: 5, Deleted: true},
})
if err != nil {
t.Fatal(err)
}
if summary.TotalPoint != 70 || summary.ManageLevel != 2 {
t.Fatalf("summary = %+v, want total=70 level=2", summary)
}
if summary.ExpBonusPercent != 10 || summary.GoldBonusPercent != 4 || summary.ManageOption != 9 {
t.Fatalf("benefits = %+v", summary.Benefits)
}
if got := tables.BenefitsForLevel(3); got != (Benefits{}) {
t.Fatalf("sparse level inherited benefits: %+v", got)
}
if got := tables.ManageLevelForPoint(9); got != 0 {
t.Fatalf("point 9 level = %d, want 0", got)
}
if got := tables.ManageLevelForPoint(10); got != 1 {
t.Fatalf("point 10 level = %d, want 1", got)
}
if got := tables.ManageLevelForPoint(50); got != 2 {
t.Fatalf("max-capped level = %d, want 2", got)
}
levelsSummary, err := tables.CalculateLevels([]int{2, 3, 5})
if err != nil {
t.Fatal(err)
}
if levelsSummary.TotalPoint != 70 || levelsSummary.ManageLevel != 2 {
t.Fatalf("level-only summary = %+v", levelsSummary)
}
if got := tables.Snapshot(); got != (Snapshot{PointRanges: 2, ManageThresholds: 3, ManageLevelMax: 2, ExpBonusLevels: 2, GoldBonusLevels: 2, ManageOptionLevels: 2}) {
t.Fatalf("snapshot = %+v", got)
}
}
func TestCurrentShapeAllowsEmptyExpAndGoldTables(t *testing.T) {
tables, err := Load(context.Background(), adventureMemorySource{
CharacterManagementPath: managementDocument("40 40 100", "100", "1", "", "", "1 10"),
})
if err != nil {
t.Fatal(err)
}
summary, err := tables.CalculateLevels([]int{40})
if err != nil {
t.Fatal(err)
}
if summary != (Summary{TotalPoint: 100, ManageLevel: 1, Benefits: Benefits{ManageOption: 10}}) {
t.Fatalf("summary = %+v", summary)
}
}
func TestLoadRejectsMalformedTypedSections(t *testing.T) {
valid := managementDocument("2 3 10", "10 30", "2", "1 5", "1 2", "1 7")
tests := []struct {
name string
doc string
want error
}{
{name: "missing section", doc: strings.Replace(valid, "[exp bonus]\n1 5\n[/exp bonus]\n", "", 1), want: ErrTableEmpty},
{name: "duplicate section", doc: valid + "\n[gold bonus]\n[/gold bonus]\n", want: ErrTableMalformed},
{name: "point tuple", doc: managementDocument("2 3", "10 30", "2", "1 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "point overlap", doc: managementDocument("2 3 10 3 4 20", "10 30", "2", "1 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "negative point", doc: managementDocument("2 3 -1", "10 30", "2", "1 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "threshold order", doc: managementDocument("2 3 10", "30 10", "2", "1 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "max arity", doc: managementDocument("2 3 10", "10 30", "2 3", "1 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "odd pair", doc: managementDocument("2 3 10", "10 30", "2", "1", "1 2", "1 7"), want: ErrTableMalformed},
{name: "duplicate pair level", doc: managementDocument("2 3 10", "10 30", "2", "1 5 1 6", "1 2", "1 7"), want: ErrTableMalformed},
{name: "pair level above max", doc: managementDocument("2 3 10", "10 30", "2", "3 5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "non integer", doc: managementDocument("2 3 10", "10 30", "2", "1 5.5", "1 2", "1 7"), want: ErrTableMalformed},
{name: "empty option", doc: managementDocument("2 3 10", "10 30", "2", "1 5", "1 2", ""), want: ErrTableEmpty},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := Load(context.Background(), adventureMemorySource{CharacterManagementPath: test.doc})
if !errors.Is(err, test.want) {
t.Fatalf("Load error = %v, want %v", err, test.want)
}
})
}
}
func TestCalculateRejectsInvalidLevelsAndPointOverflow(t *testing.T) {
tables, err := Load(context.Background(), adventureMemorySource{
CharacterManagementPath: managementDocument("1 1 10", "10", "1", "", "", "1 10"),
})
if err != nil {
t.Fatal(err)
}
if _, err := tables.Calculate([]Character{{Level: 0}}); !errors.Is(err, ErrCharacterLevel) {
t.Fatalf("zero-level error = %v", err)
}
if summary, err := tables.Calculate([]Character{{Level: 0, Deleted: true}}); err != nil || summary != (Summary{}) {
t.Fatalf("deleted invalid row should not contribute: summary=%+v err=%v", summary, err)
}
largePoint := fmt.Sprintf("1 1 %d", int64(math.MaxInt64))
overflowTables, err := Load(context.Background(), adventureMemorySource{
CharacterManagementPath: managementDocument(largePoint, "1", "1", "", "", "1 1"),
})
if err != nil {
t.Fatal(err)
}
if _, err := overflowTables.CalculateLevels([]int{1, 1, 1}); !errors.Is(err, ErrPointOverflow) {
t.Fatalf("roster overflow error = %v", err)
}
rangePoint := fmt.Sprintf("1 3 %d", int64(math.MaxInt64))
rangeTables, err := Load(context.Background(), adventureMemorySource{
CharacterManagementPath: managementDocument(rangePoint, "1", "1", "", "", "1 1"),
})
if err != nil {
t.Fatal(err)
}
if _, err := rangeTables.CharacterPoint(3); !errors.Is(err, ErrPointOverflow) {
t.Fatalf("range overflow error = %v", err)
}
}
func TestLoadHonorsContextAndSourceErrors(t *testing.T) {
if _, err := Load(context.Background(), nil); err == nil {
t.Fatal("nil source accepted")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Load(ctx, adventureMemorySource{}); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled context error = %v", err)
}
if _, err := Load(context.Background(), adventureMemorySource{}); !errors.Is(err, platformpvf.ErrFileNotFound) {
t.Fatalf("missing source error = %v", err)
}
}
func TestRealScriptPVFAdventureGroupTables(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify real adventure-group tables")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
tables, err := LoadComplete(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
snapshot := tables.Snapshot()
wantSnapshot := Snapshot{
PointRanges: 61,
ManageThresholds: 13,
ManageLevelMax: 17,
ExpBonusLevels: 0,
GoldBonusLevels: 0,
ManageOptionLevels: 11,
}
if snapshot != wantSnapshot {
t.Fatalf("real snapshot = %+v, want %+v", snapshot, wantSnapshot)
}
runtime := tables.Runtime()
if len(runtime.ShopCategories) != 3 || len(runtime.ExpeditionAreas) != 4 ||
runtime.Capsule.MinimumExperience != 11691495 ||
runtime.Capsule.GrantedExperience != 1000000 {
t.Fatalf("real runtime config = %+v", runtime)
}
if runtime.ProfessionGroupCount() != 15 {
t.Fatalf("real profession groups=%d, want 15", runtime.ProfessionGroupCount())
}
for _, test := range []struct {
job, grow int
want byte
}{
{job: 0, grow: 4, want: 0},
{job: 11, grow: 4, want: 1},
{job: 7, grow: 4, want: 2},
{job: 1, grow: 4, want: 3},
{job: 10, grow: 1, want: 14},
{job: 9, grow: 1, want: 14},
} {
got, found := runtime.ProfessionClassification(test.job, test.grow)
if !found || got != test.want {
t.Fatalf("real classification job=%d grow=%d got=%d/%t want=%d", test.job, test.grow, got, found, test.want)
}
}
point, err := tables.CharacterPoint(86)
if err != nil {
t.Fatal(err)
}
if point != 10785 {
t.Fatalf("real Lv86 point = %d, want 10785", point)
}
one, err := tables.CalculateLevels([]int{86})
if err != nil {
t.Fatal(err)
}
if one != (Summary{TotalPoint: 10785, ManageLevel: 2, Benefits: Benefits{ManageOption: 30}}) {
t.Fatalf("real one-Lv86 summary = %+v", one)
}
eight, err := tables.CalculateLevels([]int{86, 86, 86, 86, 86, 86, 86, 86})
if err != nil {
t.Fatal(err)
}
if eight != (Summary{TotalPoint: 86280, ManageLevel: 7, Benefits: Benefits{ManageOption: 125}}) {
t.Fatalf("real eight-Lv86 summary = %+v", eight)
}
currentAccount, err := tables.CalculateLevels([]int{86, 90, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1})
if err != nil {
t.Fatal(err)
}
if currentAccount != (Summary{TotalPoint: 23310, ManageLevel: 4, Benefits: Benefits{ManageOption: 70}}) {
t.Fatalf("real current-account 16-character summary = %+v", currentAccount)
}
zero, err := tables.Calculate([]Character{{Level: 100, Deleted: true}})
if err != nil || zero != (Summary{}) {
t.Fatalf("real deleted-only summary = %+v err=%v", zero, err)
}
t.Logf("real adventure-group snapshot=%+v oneLv86=%+v eightLv86=%+v currentAccount=%+v", snapshot, one, eight, currentAccount)
}
type adventureMemorySource map[string]string
func (s adventureMemorySource) ReadText(path string) (string, error) {
value, ok := s[path]
if !ok {
return "", fmt.Errorf("%w: %s", platformpvf.ErrFileNotFound, path)
}
return value, nil
}
func managementDocument(point, thresholds, maxLevel, exp, gold, option string) string {
return fmt.Sprintf(`[point bonus]
%s
[/point bonus]
[manage level point]
%s
[/manage level point]
[manage level max]
%s
[exp bonus]
%s
[/exp bonus]
[gold bonus]
%s
[/gold bonus]
[manage option]
%s
[/manage option]
`, point, thresholds, maxLevel, exp, gold, option)
}