DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,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)
|
||||
}
|
||||
Reference in new issue
Block a user