DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,231 @@
package dungeon
import (
"context"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// 翻牌(副本通关卡牌)是玩家最常撞上的晶块/灵魂来源,必须和其余获取途径一样,把六个
// 晶块(354..359)与六个灵魂(360..365)送进账号共享仓(权威表见
// repository/account_shared_materials.go)。这条路过去直接 addCardItem 写角色背包
// —— 与 2026-09-21 NPC 商店金币购买落错槽是同一类漏点。
//
// 既有夹具 seedDungeonOwnerCharacter 种的角色记录**没有 AccountID**,所以这里自己种
// 一个带 AccountID 的角色,外加一条账号仓库记录。
func seedCardRewardAccountCharacter(t *testing.T, ctx context.Context, repos dnfrepo.Group, gold int64) {
t.Helper()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Stats: map[string]int64{"gold": gold},
}); err != nil {
t.Fatalf("seed character: %v", err)
}
}
func seedCardRewardAccountInventory(t *testing.T, ctx context.Context, repos dnfrepo.Group, slots map[string]dnfrepo.ItemStack) {
t.Helper()
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: "account-1",
Slots: slots,
}); err != nil {
t.Fatalf("seed account inventory: %v", err)
}
}
func cardRewardBagCount(t *testing.T, ctx context.Context, repos dnfrepo.Group, itemID int64) int64 {
t.Helper()
inventory, found, err := repos.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
var total int64
for _, stack := range inventory.Slots {
if stack.ItemID == itemID {
total += stack.Count
}
}
return total
}
func grantCardRewardForTest(t *testing.T, ctx context.Context, repos dnfrepo.Group, reward CardItemReward) CardRewardResult {
t.Helper()
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner: %v", err)
}
result, err := owner.GrantCardReward(ctx, CardRewardCommand{
CharacterID: "19",
MainSlots: 24,
Bundle: CardRewardBundle{Items: []CardItemReward{reward}},
UpdatedAt: time.Unix(1000, 0).UTC(),
})
if err != nil {
t.Fatalf("GrantCardReward: %v", err)
}
return result
}
// 通关奖励是无色小晶块 → 必须并进账号晶体仓 0:358,背包里一个都不许有。
func TestGrantCardRewardRoutesClearCubeIntoAccountCell(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
seedCardRewardAccountCharacter(t, ctx, repos, 100)
// 现场形态:账号晶体仓里已经堆着 43890 个无色。
seedCardRewardAccountInventory(t, ctx, repos, map[string]dnfrepo.ItemStack{
"0:358": {ItemID: 3037, Count: 43890},
})
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
result := grantCardRewardForTest(t, ctx, repos, CardItemReward{
Stack: dnfrepo.ItemStack{ItemID: 3037, Count: 20, RawEntry: make([]byte, 0x77)},
Stackable: true,
})
if len(result.ItemSlots) != 1 || result.ItemSlots[0] != 358 {
t.Fatalf("ItemSlots=%v want the account crystal cell 358", result.ItemSlots)
}
account, found, err := repos.AccountInventory.Load(ctx, "account-1")
if err != nil || !found {
t.Fatalf("load account inventory found=%t err=%v", found, err)
}
if stack := account.Slots["0:358"]; stack.ItemID != 3037 || stack.Count != 43910 {
t.Fatalf("account crystal cell=%+v want 3037x43910", stack)
}
if got := cardRewardBagCount(t, ctx, repos, 3037); got != 0 {
t.Fatalf("the backpack must stay empty of the cube, got %d", got)
}
}
// 通关奖励是灵魂 → 落账号灵魂仓 0:360。
func TestGrantCardRewardRoutesSoulIntoAccountCell(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
seedCardRewardAccountCharacter(t, ctx, repos, 0)
seedCardRewardAccountInventory(t, ctx, repos, map[string]dnfrepo.ItemStack{})
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
result := grantCardRewardForTest(t, ctx, repos, CardItemReward{
Stack: dnfrepo.ItemStack{ItemID: 10100115, Count: 2, RawEntry: make([]byte, 0x77)},
Stackable: true,
})
if len(result.ItemSlots) != 1 || result.ItemSlots[0] != 360 {
t.Fatalf("ItemSlots=%v want the account soul cell 360", result.ItemSlots)
}
account, _, err := repos.AccountInventory.Load(ctx, "account-1")
if err != nil {
t.Fatalf("load account inventory: %v", err)
}
if stack := account.Slots["0:360"]; stack.ItemID != 10100115 || stack.Count != 2 {
t.Fatalf("account soul cell=%+v want 10100115x2", stack)
}
if got := cardRewardBagCount(t, ctx, repos, 10100115); got != 0 {
t.Fatalf("the backpack must stay empty of the soul, got %d", got)
}
}
// 行为保持:普通通关奖励(7001)照旧落角色背包,账号记录一点不动。
func TestGrantCardRewardLeavesOrdinaryItemsInTheBag(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
seedCardRewardAccountCharacter(t, ctx, repos, 0)
seedCardRewardAccountInventory(t, ctx, repos, map[string]dnfrepo.ItemStack{})
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{"0:0": {ItemID: 7001, Count: 2}},
}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
result := grantCardRewardForTest(t, ctx, repos, CardItemReward{
Stack: dnfrepo.ItemStack{ItemID: 7001, Count: 3},
Stackable: true,
})
if len(result.ItemSlots) != 1 || result.ItemSlots[0] != 0 {
t.Fatalf("ItemSlots=%v want the existing backpack row 0", result.ItemSlots)
}
if got := cardRewardBagCount(t, ctx, repos, 7001); got != 5 {
t.Fatalf("backpack count=%d want 5 (2 + 3)", got)
}
account, _, err := repos.AccountInventory.Load(ctx, "account-1")
if err != nil {
t.Fatalf("load account inventory: %v", err)
}
if len(account.Slots) != 0 {
t.Fatalf("ordinary rewards must not touch the account warehouse: %+v", account.Slots)
}
}
// 固定格被**别的**道具占着时绝不驱赶:整件退回角色背包,占用者的行原样不动。
func TestGrantCardRewardFallsBackToBagWhenTheFixedCellIsTaken(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
seedCardRewardAccountCharacter(t, ctx, repos, 0)
seedCardRewardAccountInventory(t, ctx, repos, map[string]dnfrepo.ItemStack{
"0:358": {ItemID: 9999, Count: 4},
})
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
result := grantCardRewardForTest(t, ctx, repos, CardItemReward{
Stack: dnfrepo.ItemStack{ItemID: 3037, Count: 7, RawEntry: make([]byte, 0x77)},
Stackable: true,
})
if len(result.ItemSlots) != 1 || result.ItemSlots[0] == 358 {
t.Fatalf("ItemSlots=%v want a backpack slot (the fixed cell is taken)", result.ItemSlots)
}
if got := cardRewardBagCount(t, ctx, repos, 3037); got != 7 {
t.Fatalf("the whole reward must fall back to the bag, got %d want 7", got)
}
account, _, err := repos.AccountInventory.Load(ctx, "account-1")
if err != nil {
t.Fatalf("load account inventory: %v", err)
}
if got := account.Slots["0:358"]; got.ItemID != 9999 || got.Count != 4 {
t.Fatalf("foreign occupant was disturbed: %+v", got)
}
if len(account.Slots) != 1 {
t.Fatalf("no extra account rows may be left behind: %+v", account.Slots)
}
}
// 角色记录里没有 AccountID(或没有账号仓库仓库)时,整条退回角色背包那条既有路径,
// 绝不能拿空 AccountID 去写账号记录。既有夹具正是这种角色,所以这条门必须存在。
func TestGrantCardRewardSkipsRoutingWhenTheCharacterHasNoAccount(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
// 故意用不带 AccountID 的角色(既有夹具的形态)。
seedDungeonOwnerCharacter(t, ctx, repos, 0)
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatalf("seed inventory: %v", err)
}
result := grantCardRewardForTest(t, ctx, repos, CardItemReward{
Stack: dnfrepo.ItemStack{ItemID: 3037, Count: 5, RawEntry: make([]byte, 0x77)},
Stackable: true,
})
if len(result.ItemSlots) != 1 || result.ItemSlots[0] == 358 {
t.Fatalf("ItemSlots=%v want a backpack slot when the character has no account id", result.ItemSlots)
}
if got := cardRewardBagCount(t, ctx, repos, 3037); got != 5 {
t.Fatalf("the reward must land in the bag, got %d want 5", got)
}
if account, found, err := repos.AccountInventory.Load(ctx, "account-1"); err != nil {
t.Fatalf("load account inventory: %v", err)
} else if found && len(account.Slots) != 0 {
t.Fatalf("no account row may be written without an account id: %+v", account.Slots)
}
}
@@ -0,0 +1,4 @@
// Package dungeon 负责 DNF 副本 PVF 的项目层强类型加载。
//
// 本包只解析静态副本配置;进入校验、房间生命周期和结算写路径由 dungeon owner 承担。
package dungeon
@@ -0,0 +1,136 @@
package dungeon
import (
"context"
"errors"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
"longheng.io/server/internal/modules/dnf/progression"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrExperienceAwardStateInvalid = errors.New("dungeon experience award persisted state is invalid")
// ExperienceAwardCommand 是"一次已证明的角色经验结算"的完整输入:计划本身由
// bridge 用运行时 PVF 算好(角色经验/等级/SP + 熟练等级),本命令只负责把它
// 和账号级冒险团状态放进同一个事务。
type ExperienceAwardCommand struct {
AccountID string
CharacterID string
// Plan 由调用方用运行时 PVF 规划(含幂等前置条件 expected → next)。
Plan progression.CommitPlan
// AdventureRuntime 是运行时 PVF 的冒险团配置;AdventureCredit 描述这笔经验
// 怎么进账号账本(任意等级进骁勇结晶,满级部分才进胶囊槽与名誉经验)。
// 两者都缺时只落角色经验。
AdventureRuntime *adventuregroup.RuntimeConfig
AdventureCredit adventuregroup.ExperienceCredit
UpdatedAt time.Time
}
// ExperienceAwardResult 汇报事务内实际提交的状态。
type ExperienceAwardResult struct {
Commit progression.CommitResult
AdventureGroupCredit uint64
AdventureGroupMaximum uint64
AdventureGroupCrystals uint32
}
// CommitExperienceAward 在同一个事务里同时落角色经验/等级/SP 与账号级冒险团
// 状态(骁勇结晶 / 成长胶囊槽 / 名誉经验)。
//
// 为什么必须是一个事务:同一笔经验同时进角色和账号账本。分成两次写,
// 就会出现"经验涨了、结晶没涨"这种既不报错也不自愈的状态 —— 线上长期表现为
// 打怪一辈子换不到一个结晶(怪物经验占一次通关总经验的 3~8 成)。
//
// 口径见 adventuregroup.ExperienceCredit:骁勇结晶收**任意等级**的经验,
// 成长胶囊槽与名誉经验只收满级溢出部分。
//
// 重放(同一次死亡已经落过账)只返回幂等结果,不再记第二笔结晶。
func (o *Owner) CommitExperienceAward(
ctx context.Context,
cmd ExperienceAwardCommand,
) (ExperienceAwardResult, error) {
if o == nil || o.settlements == nil ||
strings.TrimSpace(cmd.CharacterID) == "" || strings.TrimSpace(cmd.AccountID) == "" {
return ExperienceAwardResult{}, ErrOwnerUnavailable
}
ctx = contextOrBackground(ctx)
now := updatedAtOrNow(cmd.UpdatedAt)
var result ExperienceAwardResult
err := o.settlements.WithinCharacterSettlement(ctx, cmd.CharacterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Skill == nil {
return ErrExperienceAwardStateInvalid
}
applied, err := progression.ApplyCommitPlan(ctx, tx.Character, tx.Skill, cmd.Plan)
if err != nil {
return err
}
result.Commit = applied
// 打怪升级必须同步剧情摘要等级(story_digest_last_level)。
//
// 客户端按「由 last_level 选出的段」与「由角色等级选出的段」是否相同
// 决定播放。若升级后 last_level 仍停在旧段,客户端会自行播放该段摘要
// —— 表现为「一到某等级就播剧情回顾/章节过场」。
//
// 单任务交付路径已在 bridge 侧同步;副本内打怪升级不经那条路,
// 必须在同一事务里补上,否则升级瞬间仍有段差。
if applied.Applied && !applied.Idempotent &&
applied.Current.Level > applied.Previous.Level && applied.Current.Level > 0 {
record, found, loadErr := tx.Character.Load(ctx, cmd.CharacterID)
if loadErr != nil {
return loadErr
}
if found {
if record.Stats == nil {
record.Stats = map[string]int64{}
}
current := record.Stats[dnfrepo.CharacterStoryDigestLastLevelStatKey]
if current < int64(applied.Current.Level) {
record.Stats[dnfrepo.CharacterStoryDigestLastLevelStatKey] = int64(applied.Current.Level)
record.Stats[dnfrepo.CharacterStoryDigestMigrationVersionStatKey] =
int64(dnfrepo.CurrentCharacterStoryDigestMigrationVersion)
if saveErr := tx.Character.Save(ctx, record); saveErr != nil {
return saveErr
}
}
}
}
if applied.Idempotent || cmd.AdventureCredit.Normalize().Empty() || cmd.AdventureRuntime == nil {
return nil
}
if tx.Account == nil {
return ErrExperienceAwardStateInvalid
}
credit, err := adventuregroup.CreditExperience(
ctx,
tx.Account,
*cmd.AdventureRuntime,
strings.TrimSpace(cmd.AccountID),
cmd.AdventureCredit,
now,
)
if err != nil {
// 账号行缺失时的取舍:
// - 这笔经验里有**满级部分**(进胶囊槽/名誉经验,属于原版规则)→
// 整笔失败。账号行没了说明库不对,静默丢掉这两个账本比报错更糟。
// - 只有**未满级经验**(2026-09-24 新增的"镜像进骁勇结晶")→
// 跳过记账但照常发经验。这只是加分项,不该因为一行账本缺失
// 就让玩家连杀怪经验都拿不到("改了A把B弄坏"正是要避免的)。
if errors.Is(err, adventuregroup.ErrCreditAccountNotFound) &&
cmd.AdventureCredit.Normalize().MaximumLevelExperience == 0 {
return nil
}
return err
}
result.AdventureGroupCredit = credit.AwardedExperience
result.AdventureGroupMaximum = credit.MaximumLevelExperience
result.AdventureGroupCrystals = credit.BravePoints
return nil
})
if err != nil {
return ExperienceAwardResult{}, err
}
return result, nil
}
@@ -0,0 +1,53 @@
// 本文件负责从 DNF PVF 内存索引加载地下城强类型表。
package dungeon
import (
"context"
"fmt"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
// Load 从已构建的 DNF PVF 内存索引加载地下城表。
// 这里只解析副本静态数据,不创建房间、不扣疲劳,也不发放结算奖励。
func Load(ctx context.Context, index *dnfpvf.Index, options Options) (*Table, error) {
if index == nil {
return nil, ErrIndexRequired
}
if ctx == nil {
ctx = context.Background()
}
listPath := options.ListPath
if listPath == "" {
listPath = DefaultList
}
entries := index.List(listPath)
if len(entries) == 0 {
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
}
table := &Table{
dungeons: make([]Dungeon, 0, len(entries)),
byID: make(map[int64]int, len(entries)),
byPath: make(map[string]int, len(entries)),
}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
doc, ok := index.Document(entry.Path)
if !ok {
return nil, fmt.Errorf("%w: %s", ErrDocMissing, entry.Path)
}
dungeon := parse(entry.ID, entry.Path, doc)
idx := len(table.dungeons)
table.dungeons = append(table.dungeons, dungeon)
if _, exists := table.byID[dungeon.ID]; !exists {
table.byID[dungeon.ID] = idx
}
key := pathKey(dungeon.Path)
if key != "" {
table.byPath[key] = idx
}
}
return table, nil
}
@@ -0,0 +1,99 @@
package dungeon
import (
"context"
"errors"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
func TestLoadBuildsDungeonTable(t *testing.T) {
table, err := Load(context.Background(), testIndex(t), Options{})
if err != nil {
t.Fatalf("load dungeon: %v", err)
}
if snapshot := table.Snapshot(); snapshot.Dungeons != 1 {
t.Fatalf("unexpected snapshot: %+v", snapshot)
}
dungeon, ok := table.Find(4001)
if !ok {
t.Fatalf("expected dungeon")
}
if dungeon.Name != "training room" || dungeon.Area != "west coast" || dungeon.MinLevel != 10 || dungeon.Fatigue != 8 {
t.Fatalf("unexpected dungeon: %+v", dungeon)
}
if len(dungeon.MapPaths) != 2 || dungeon.MapPaths[1] != "map/training_b.map" {
t.Fatalf("unexpected map paths: %+v", dungeon.MapPaths)
}
if len(dungeon.MonsterIDs) != 2 || dungeon.MonsterIDs[0] != 3001 || dungeon.BossIDs[0] != 3999 {
t.Fatalf("unexpected monster refs: monsters=%+v boss=%+v", dungeon.MonsterIDs, dungeon.BossIDs)
}
dungeon.MapPaths[0] = "changed"
again, _ := table.FindPath("./DUNGEON/training_room.dgn")
if again.MapPaths[0] != "map/training_a.map" {
t.Fatalf("table returned mutable map paths: %+v", again.MapPaths)
}
}
func TestLoadRequiresIndex(t *testing.T) {
_, err := Load(context.Background(), nil, Options{})
if !errors.Is(err, ErrIndexRequired) {
t.Fatalf("expected ErrIndexRequired, got %v", err)
}
}
func TestLoadReportsEmptyList(t *testing.T) {
index, err := dnfpvf.Build(context.Background(), textSource{
"dungeon/dungeon.lst": "4001 `dungeon/missing.dgn`\n",
}, dnfpvf.BuildOptions{Paths: []string{"dungeon/dungeon.lst"}})
if err != nil {
t.Fatalf("build index: %v", err)
}
_, err = Load(context.Background(), index, Options{})
if !errors.Is(err, ErrListEmpty) {
t.Fatalf("expected ErrListEmpty, got %v", err)
}
}
func testIndex(t *testing.T) *dnfpvf.Index {
t.Helper()
index, err := dnfpvf.Build(context.Background(), textSource{
"dungeon/dungeon.lst": "4001 `dungeon/training_room.dgn`\n",
"dungeon/training_room.dgn": `
[name]
` + "`training room`" + `
[area]
` + "`west coast`" + `
[minimum level]
10
[maximum level]
20
[fatigue]
8
[maps]
` + "`map/training_a.map` `map/training_b.map`" + `
[monsters]
3001 3002
[boss]
3999
[reward]
` + "`drop/training.drop`" + `
`,
}, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatalf("build index: %v", err)
}
return index
}
type textSource map[string]string
func (s textSource) ReadText(relativePath string) (string, error) {
for key, value := range s {
if pathKey(key) == pathKey(relativePath) {
return value, nil
}
}
return "", dnfpvf.ErrDocNotFound
}
@@ -0,0 +1,322 @@
package dungeon
import (
"context"
"errors"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var ErrMutationRequired = errors.New("dungeon mutation callback is required")
type InventoryMutation func(*dnfrepo.InventoryRecord) (bool, error)
type InventoryMutationCommand struct {
CharacterID string
UpdatedAt time.Time
Apply InventoryMutation
}
type InventoryMutationResult struct {
Changed bool
Inventory dnfrepo.InventoryRecord
}
type OwnedInventoryMutation func(map[string]dnfrepo.ItemStack) (bool, error)
type OwnedInventoryMutationCommand struct {
AccountID string
CharacterID string
AccountOwned bool
UpdatedAt time.Time
Apply OwnedInventoryMutation
}
// MutateOwnedInventory atomically validates character ownership and mutates
// either account-shared slots or the selected character's ordinary slots.
func (o *Owner) MutateOwnedInventory(ctx context.Context, cmd OwnedInventoryMutationCommand) error {
if o == nil || o.accountAssets == nil || strings.TrimSpace(cmd.AccountID) == "" ||
strings.TrimSpace(cmd.CharacterID) == "" {
return ErrOwnerUnavailable
}
if cmd.Apply == nil {
return ErrMutationRequired
}
ctx = contextOrBackground(ctx)
now := updatedAtOrNow(cmd.UpdatedAt)
return o.accountAssets.WithinAccountCharacterAssets(
ctx,
cmd.AccountID,
cmd.CharacterID,
func(
accountInventories dnfrepo.AccountInventoryRepository,
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
character, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(character.AccountID) != strings.TrimSpace(cmd.AccountID) {
return ErrCharacterNotFound
}
if cmd.AccountOwned {
record, found, err := accountInventories.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
if !found {
return ErrInventoryNotFound
}
record = dnfrepo.CloneAccountInventory(record)
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
changed, err := cmd.Apply(record.Slots)
if err != nil {
return err
}
if !changed {
return nil
}
record.UpdatedAt = now
return accountInventories.Save(ctx, record)
}
record, found, err := inventories.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found {
return ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
changed, err := cmd.Apply(record.Slots)
if err != nil {
return err
}
if !changed {
return nil
}
record.UpdatedAt = now
return dnfrepo.SaveInventoryFields(ctx, inventories, record, dnfrepo.InventoryFieldSlots)
},
)
}
// MutateInventory is the transaction boundary for dungeon flows whose
// placement inputs are already resolved from runtime PVF evidence.
func (o *Owner) MutateInventory(ctx context.Context, cmd InventoryMutationCommand) (InventoryMutationResult, error) {
if o == nil || o.items == nil || strings.TrimSpace(cmd.CharacterID) == "" {
return InventoryMutationResult{}, ErrOwnerUnavailable
}
if cmd.Apply == nil {
return InventoryMutationResult{}, ErrMutationRequired
}
ctx = contextOrBackground(ctx)
now := updatedAtOrNow(cmd.UpdatedAt)
var result InventoryMutationResult
err := o.items.WithinCharacterItems(ctx, cmd.CharacterID, func(
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
inventory, found, err := inventories.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found {
return ErrInventoryNotFound
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
changed, err := cmd.Apply(&inventory)
if err != nil {
return err
}
if changed {
inventory.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
result = InventoryMutationResult{
Changed: changed,
Inventory: dnfrepo.CloneInventory(inventory),
}
return nil
})
if err != nil {
return InventoryMutationResult{}, err
}
return result, nil
}
// AccountCharacterInventoryMutation 在同一个事务里同时拿到账号仓与角色背包。
// 返回两个 owner 各自是否被改动(分别决定要不要提交)。
type AccountCharacterInventoryMutation func(
account *dnfrepo.AccountInventoryRecord,
inventory *dnfrepo.InventoryRecord,
) (accountChanged bool, characterChanged bool, err error)
type AccountCharacterInventoryMutationCommand struct {
AccountID string
CharacterID string
UpdatedAt time.Time
Apply AccountCharacterInventoryMutation
}
type AccountCharacterInventoryMutationResult struct {
AccountChanged bool
CharacterChanged bool
AccountInventory dnfrepo.AccountInventoryRecord
Inventory dnfrepo.InventoryRecord
}
// MutateAccountCharacterInventory 是"既要落账号共享仓、余量又要落角色背包"这类发放
// 流程的事务边界:账号记录与角色背包在一次事务里同时可写、同时提交。
//
// 为什么需要它:无色小晶块 / 灵魂 在同一个账号里只有账号共享仓那一个家,所以任何获取
// 途径都要**先写账号记录**,装不下的余量才写角色背包 —— 这是两个 owner,必须同事务。
// dungeon 包里原本只有 MutateInventory(只给角色背包)与 MutateOwnedInventory
// (账号与角色二选一),都表达不了这件事。
func (o *Owner) MutateAccountCharacterInventory(ctx context.Context, cmd AccountCharacterInventoryMutationCommand) (AccountCharacterInventoryMutationResult, error) {
if o == nil || o.accountAssets == nil || strings.TrimSpace(cmd.AccountID) == "" ||
strings.TrimSpace(cmd.CharacterID) == "" {
return AccountCharacterInventoryMutationResult{}, ErrOwnerUnavailable
}
if cmd.Apply == nil {
return AccountCharacterInventoryMutationResult{}, ErrMutationRequired
}
ctx = contextOrBackground(ctx)
now := updatedAtOrNow(cmd.UpdatedAt)
var result AccountCharacterInventoryMutationResult
err := o.accountAssets.WithinAccountCharacterAssets(
ctx,
cmd.AccountID,
cmd.CharacterID,
func(
accountInventories dnfrepo.AccountInventoryRepository,
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
character, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(character.AccountID) != strings.TrimSpace(cmd.AccountID) {
return ErrCharacterNotFound
}
account, _, err := accountInventories.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
account = dnfrepo.CloneAccountInventory(account)
account.AccountID = strings.TrimSpace(cmd.AccountID)
if account.Slots == nil {
account.Slots = make(map[string]dnfrepo.ItemStack)
}
inventory, found, err := inventories.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found {
return ErrInventoryNotFound
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
accountChanged, characterChanged, applyErr := cmd.Apply(&account, &inventory)
if applyErr != nil {
return applyErr
}
if accountChanged {
account.UpdatedAt = now
if err := accountInventories.Save(ctx, account); err != nil {
return err
}
}
if characterChanged {
inventory.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
result = AccountCharacterInventoryMutationResult{
AccountChanged: accountChanged,
CharacterChanged: characterChanged,
AccountInventory: dnfrepo.CloneAccountInventory(account),
Inventory: dnfrepo.CloneInventory(inventory),
}
return nil
},
)
if err != nil {
return AccountCharacterInventoryMutationResult{}, err
}
return result, nil
}
type TutorialCompletionCommand struct {
CharacterID string
CompletedKey string
Completed int64
NextLogin map[string]int64
UpdatedAt time.Time
}
type TutorialCompletionResult struct {
Previous int64
Changed bool
}
func (o *Owner) CompleteTutorial(ctx context.Context, cmd TutorialCompletionCommand) (TutorialCompletionResult, error) {
if o == nil || o.characters == nil || strings.TrimSpace(cmd.CharacterID) == "" ||
strings.TrimSpace(cmd.CompletedKey) == "" || cmd.Completed == 0 {
return TutorialCompletionResult{}, ErrOwnerUnavailable
}
ctx = contextOrBackground(ctx)
character, found, err := o.characters.Load(ctx, cmd.CharacterID)
if err != nil {
return TutorialCompletionResult{}, err
}
if !found {
return TutorialCompletionResult{}, ErrCharacterNotFound
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
result := TutorialCompletionResult{Previous: character.Stats[cmd.CompletedKey]}
values := make(map[string]int64, len(cmd.NextLogin)+1)
for key, value := range cmd.NextLogin {
values[key] = value
}
values[cmd.CompletedKey] = cmd.Completed
for key, value := range values {
if current, ok := character.Stats[key]; !ok || current != value {
character.Stats[key] = value
result.Changed = true
}
}
if !result.Changed {
return result, nil
}
character.UpdatedAt = updatedAtOrNow(cmd.UpdatedAt)
if err := dnfrepo.SaveCharacterFields(ctx, o.characters, character, dnfrepo.CharacterFieldStats); err != nil {
return TutorialCompletionResult{}, err
}
return result, nil
}
@@ -0,0 +1,730 @@
package dungeon
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/carrygold"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
)
const (
LuckyStarMetadataKey = "rental_lucky_stars"
cardRewardReceiptPrefix = "dungeon_card_reward_receipt_"
cardRewardReceiptVersion int64 = 1
cardRewardReceiptOverflowMarker = "durable-card-reward-receipt"
)
var (
ErrOwnerUnavailable = errors.New("dungeon asset owner is unavailable")
ErrCharacterNotFound = errors.New("dungeon character is not found")
ErrAccountNotFound = errors.New("dungeon account is not found")
ErrRewardInvalid = errors.New("dungeon reward is invalid")
ErrInventoryFull = errors.New("dungeon reward inventory is full")
ErrGoldOverflow = errors.New("dungeon gold would overflow")
ErrStackProjectorRequired = errors.New("dungeon expiring stack projector is required")
ErrPremiumDailyLimit = errors.New("dungeon premium service daily limit reached")
ErrCardRewardReceiptInvalid = errors.New("dungeon card reward receipt is invalid")
// ErrCardRewardGoldShort refuses a paid-card flip the character cannot pay
// for. 86JP's TryBuildRewardPlan rejects the plan before any mutation when
// the commission exceeds the carried gold, so the card is never revealed.
ErrCardRewardGoldShort = errors.New("dungeon card reward gold is insufficient")
)
// StackProjector lets the transport compatibility layer project current-client
// raw-entry details while the dungeon owner retains the durable transaction and
// inventory rules.
type StackProjector func(dnfrepo.ItemStack, time.Time) (dnfrepo.ItemStack, error)
type CardItemReward struct {
Stack dnfrepo.ItemStack
Stackable bool
StackLimit int64
SlotStart int16
SlotEnd int16
ExpireAt time.Time
}
type CardRewardBundle struct {
Gold int64
Items []CardItemReward
}
type CardRewardCommand struct {
CharacterID string
// CompletionKey is optional. When present, the owner writes a compact
// receipt into Character.Stats in the same asset/mailbox transaction and
// replays it after an indeterminate commit outcome. Empty preserves the
// legacy one-shot caller-owned behavior.
CompletionKey string
MainSlots uint16
// Cost is the gold spent before the bundle is credited. Only the paid card
// row sets it: the amount is the commission op35 printed for that card.
Cost int64
Bundle CardRewardBundle
UpdatedAt time.Time
Project StackProjector
ConsumePremiumDaily bool
PremiumDailySlot int64
}
type CardRewardResult struct {
GoldBefore int64
GoldAfter int64
// GoldSpent is the commission deducted before the bundle was credited.
GoldSpent int64
// GoldDiscarded is the part of the reward the carry cap refused.
GoldDiscarded int64
ItemSlots []int16
// OverflowMailID is set only when one or more card items could not fit in
// the real main inventory and were committed as a system-mail attachment.
OverflowMailID string
Replayed bool
}
type GoldPickupCommand struct {
CharacterID string
Amount uint32
UpdatedAt time.Time
}
type GoldPickupResult struct {
GoldBefore int64
GoldAfter int64
// GoldDiscarded is the part of the pickup the carry cap refused.
GoldDiscarded int64
}
// Owner is the durable mutation boundary for dungeon rewards. Packet parsing,
// runtime room state, packet ordering, and current-client raw projection stay
// in dnfbridge.
type Owner struct {
assets dnfrepo.CharacterAssetUnitOfWork
accounts dnfrepo.AccountRepository
settlements dnfrepo.CharacterSettlementUnitOfWork
items dnfrepo.CharacterItemUnitOfWork
inventory dnfrepo.InventoryRepository
characters dnfrepo.CharacterRepository
mailboxAssets dnfrepo.MailboxAssetUnitOfWork
accountAssets dnfrepo.AccountCharacterAssetUnitOfWork
}
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
if repos.CharacterAssets == nil && repos.Account == nil && repos.CharacterSettlement == nil &&
repos.CharacterItems == nil && repos.Inventory == nil && repos.Character == nil &&
repos.AccountAssets == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{
assets: repos.CharacterAssets,
accounts: repos.Account,
settlements: repos.CharacterSettlement,
items: repos.CharacterItems,
inventory: repos.Inventory,
characters: repos.Character,
mailboxAssets: repos.MailboxAssets,
accountAssets: repos.AccountAssets,
}, nil
}
func (o *Owner) GrantCardReward(ctx context.Context, cmd CardRewardCommand) (CardRewardResult, error) {
if o == nil || o.assets == nil || strings.TrimSpace(cmd.CharacterID) == "" {
return CardRewardResult{}, ErrOwnerUnavailable
}
if err := validateCardReward(cmd); err != nil {
return CardRewardResult{}, err
}
ctx = contextOrBackground(ctx)
now := updatedAtOrNow(cmd.UpdatedAt)
completionKey := strings.TrimSpace(cmd.CompletionKey)
var receiptHash [sha256.Size]byte
durableReceipt := completionKey != ""
if durableReceipt {
receiptHash = sha256.Sum256([]byte(completionKey))
}
var result CardRewardResult
apply := func(
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
mailboxes dnfrepo.MailboxRepository,
accountInventories dnfrepo.AccountInventoryRepository,
) error {
character, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found {
return ErrCharacterNotFound
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
if durableReceipt && cardRewardReceiptMatches(character.Stats, receiptHash) {
replayed, receiptErr := cardRewardReceiptResult(character.Stats)
if receiptErr != nil {
return receiptErr
}
result = replayed
result.Replayed = true
return nil
}
if cmd.ConsumePremiumDaily && !premium.TryConsumeDaily(&character, cmd.PremiumDailySlot, now) {
return ErrPremiumDailyLimit
}
currentGold := character.Stats["gold"]
result.GoldBefore = currentGold
if cmd.Cost < 0 {
return ErrRewardInvalid
}
if cmd.Cost > 0 {
if currentGold < cmd.Cost {
return ErrCardRewardGoldShort
}
currentGold -= cmd.Cost
character.Stats["gold"] = currentGold
result.GoldSpent = cmd.Cost
}
if cmd.Bundle.Gold > 0 && currentGold > math.MaxInt64-cmd.Bundle.Gold {
return ErrGoldOverflow
}
// The carry cap the character has bought bites here: a reward cannot be
// refused without losing the whole bundle, so the excess is discarded
// instead. carrygold.Credit never lowers a balance that is already
// above the cap.
result.GoldAfter, result.GoldDiscarded = carrygold.Credit(character, cmd.Bundle.Gold)
character.Stats["gold"] = result.GoldAfter
inventory, found, err := inventories.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found {
inventory = dnfrepo.InventoryRecord{CharacterID: cmd.CharacterID}
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
// 无色小晶块 / 灵魂 这两族通关奖励必须先落进账号共享仓库的固定格
// (晶体仓库 354..359 + 灵魂仓库 360..365,权威表见
// repository/account_shared_materials.go)。翻牌这条路过去直接 addCardItem
// 写角色背包 —— 与 2026-09-21 NPC 商店金币购买落错槽是同一类漏点。
accountInventory := dnfrepo.AccountInventoryRecord{}
accountInventoryLoaded := false
// accountInventoryReady 才代表"账号记录真的读到了":读不到(没有账号仓库仓库、
// 或角色记录里没有 AccountID)就必须整条退回角色背包那条既有路径,
// 绝不能拿一个空 AccountID 去写账号记录。
accountInventoryReady := false
accountDirty := false
loadAccountInventory := func() error {
if accountInventoryLoaded || accountInventories == nil {
return nil
}
accountInventoryLoaded = true
accountID := strings.TrimSpace(character.AccountID)
if accountID == "" {
return nil
}
record, _, loadErr := accountInventories.Load(ctx, accountID)
if loadErr != nil {
return loadErr
}
accountInventory = dnfrepo.CloneAccountInventory(record)
accountInventory.AccountID = accountID
if accountInventory.Slots == nil {
accountInventory.Slots = make(map[string]dnfrepo.ItemStack)
}
accountInventoryReady = true
return nil
}
overflow := make([]dnfrepo.MailAttachment, 0)
for _, reward := range cmd.Bundle.Items {
pending := reward
if accountInventories != nil {
if err := loadAccountInventory(); err != nil {
return err
}
}
if accountInventoryReady {
// 账号记录可用:无色/灵魂类奖励先落它的固定格,余量才交给角色背包。
_, remainder, accountChanged := grantCardRewardAccountShared(&accountInventory, reward)
if accountChanged {
accountDirty = true
if fixedSlot, member := dnfrepo.AccountSharedWarehouseSlotForItem(reward.Stack.ItemID); member {
result.ItemSlots = append(result.ItemSlots, fixedSlot)
}
}
if remainder <= 0 {
continue
}
if accountChanged {
// 只剩余量要落角色背包:数量换成余量,行本身沿用奖励的投影。
pending.Stack = cloneStack(reward.Stack)
pending.Stack.Count = remainder
}
}
slot, err := addCardItem(&inventory, cmd.MainSlots, pending, cmd.Project)
if err != nil {
if !errors.Is(err, ErrInventoryFull) || mailboxes == nil {
return err
}
attachment, attachmentErr := cardRewardMailAttachment(pending, cmd.Project)
if attachmentErr != nil {
return attachmentErr
}
overflow = append(overflow, attachment)
result.ItemSlots = append(result.ItemSlots, -1)
continue
}
result.ItemSlots = append(result.ItemSlots, slot)
}
if durableReceipt {
writeCardRewardReceipt(character.Stats, receiptHash, result, len(overflow) != 0)
}
character.UpdatedAt = now
inventory.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
if accountDirty {
accountInventory.UpdatedAt = now
if err := accountInventories.Save(ctx, accountInventory); err != nil {
return err
}
}
if len(overflow) == 0 {
return nil
}
mailID, mailErr := dnfrepo.AppendSystemMail(ctx, mailboxes, dnfrepo.SystemMailDelivery{
RecipientCharacterID: cmd.CharacterID,
Title: "背包已满:通关奖励",
Body: "背包空间不足,通关奖励已通过邮件发送。请清理对应道具分页后领取。",
Source: "dungeon_card_reward_inventory_full",
Attachments: overflow,
CreatedAt: now,
})
if mailErr != nil {
return mailErr
}
result.OverflowMailID = mailID
return nil
}
var err error
if o.mailboxAssets != nil {
err = o.mailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(
_ dnfrepo.AccountRepository,
accountInventories dnfrepo.AccountInventoryRepository,
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
mailboxes dnfrepo.MailboxRepository,
) error {
return apply(characters, inventories, mailboxes, accountInventories)
})
} else {
// 这条兜底事务拿不到账号仓库记录,无色/灵魂类奖励只能照旧落角色背包。
// 生产装配(memory / mysql)都提供 MailboxAssets,所以走的是上面那条。
err = o.assets.WithinCharacterAssets(ctx, cmd.CharacterID, func(
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
return apply(characters, inventories, nil, nil)
})
}
if err != nil {
return CardRewardResult{}, err
}
return result, nil
}
func cardRewardMailAttachment(reward CardItemReward, project StackProjector) (dnfrepo.MailAttachment, error) {
stack := cloneStack(reward.Stack)
if reward.Stackable && !reward.ExpireAt.IsZero() {
if project == nil {
return dnfrepo.MailAttachment{}, ErrStackProjectorRequired
}
var err error
stack, err = project(stack, reward.ExpireAt)
if err != nil {
return dnfrepo.MailAttachment{}, err
}
}
return dnfrepo.MailAttachment{
ItemID: stack.ItemID,
Count: stack.Count,
Bind: stack.Bind,
ExpireAt: stack.ExpireAt,
RawEntry: append([]byte(nil), stack.RawEntry...),
Extra: cloneCardRewardExtra(stack.Extra),
}, nil
}
func cloneCardRewardExtra(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func (o *Owner) GrantPickupGold(ctx context.Context, cmd GoldPickupCommand) (GoldPickupResult, error) {
if o == nil || o.assets == nil || strings.TrimSpace(cmd.CharacterID) == "" {
return GoldPickupResult{}, ErrOwnerUnavailable
}
if cmd.Amount == 0 {
return GoldPickupResult{}, ErrRewardInvalid
}
ctx = contextOrBackground(ctx)
now := updatedAtOrNow(cmd.UpdatedAt)
var result GoldPickupResult
err := dnfrepo.WithinCharacterAssetsScoped(ctx, o.assets, cmd.CharacterID, func(
characters dnfrepo.CharacterRepository,
_ dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
character, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found {
return ErrCharacterNotFound
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
result.GoldBefore = character.Stats["gold"]
delta := int64(cmd.Amount)
if result.GoldBefore < 0 || result.GoldBefore > math.MaxInt64-delta {
return ErrGoldOverflow
}
// A gold drop the player walks over cannot be handed back, so the carry
// cap discards what does not fit instead of refusing the pickup.
result.GoldAfter, result.GoldDiscarded = carrygold.Credit(character, delta)
character.Stats["gold"] = result.GoldAfter
character.UpdatedAt = now
return dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats)
})
if err != nil {
return GoldPickupResult{}, err
}
return result, nil
}
func validateCardReward(cmd CardRewardCommand) error {
if cmd.Bundle.Gold < 0 ||
cmd.ConsumePremiumDaily && (cmd.PremiumDailySlot < 0 || cmd.PremiumDailySlot >= premium.DevilSlotCount) ||
strings.TrimSpace(cmd.CompletionKey) != "" && len(cmd.Bundle.Items) > math.MaxUint16 {
return ErrRewardInvalid
}
for _, reward := range cmd.Bundle.Items {
if reward.Stack.ItemID <= 0 || reward.Stack.Count <= 0 ||
!reward.Stackable && reward.Stack.Count != 1 {
return ErrRewardInvalid
}
hasPVFRange := reward.SlotStart >= 3 && reward.SlotEnd >= reward.SlotStart
if !hasPVFRange && cmd.MainSlots == 0 {
return ErrRewardInvalid
}
if reward.Stackable && !reward.ExpireAt.IsZero() && cmd.Project == nil {
return ErrStackProjectorRequired
}
if reward.StackLimit < 0 || (reward.Stackable && reward.StackLimit > 0 && reward.Stack.Count > reward.StackLimit) {
return ErrRewardInvalid
}
}
return nil
}
func cardRewardReceiptMatches(stats map[string]int64, want [sha256.Size]byte) bool {
if stats == nil || stats[cardRewardReceiptPrefix+"version"] != cardRewardReceiptVersion {
return false
}
for index := 0; index < 4; index++ {
if uint64(stats[fmt.Sprintf("%shash_%d", cardRewardReceiptPrefix, index)]) !=
binary.LittleEndian.Uint64(want[index*8:(index+1)*8]) {
return false
}
}
return true
}
func writeCardRewardReceipt(
stats map[string]int64,
hash [sha256.Size]byte,
result CardRewardResult,
overflow bool,
) {
stats[cardRewardReceiptPrefix+"version"] = cardRewardReceiptVersion
for index := 0; index < 4; index++ {
stats[fmt.Sprintf("%shash_%d", cardRewardReceiptPrefix, index)] =
int64(binary.LittleEndian.Uint64(hash[index*8 : (index+1)*8]))
}
stats[cardRewardReceiptPrefix+"gold_before"] = result.GoldBefore
stats[cardRewardReceiptPrefix+"gold_after"] = result.GoldAfter
stats[cardRewardReceiptPrefix+"gold_spent"] = result.GoldSpent
stats[cardRewardReceiptPrefix+"item_slot_count"] = int64(len(result.ItemSlots))
for index, slot := range result.ItemSlots {
stats[fmt.Sprintf("%sitem_slot_%d", cardRewardReceiptPrefix, index)] = int64(slot)
}
stats[cardRewardReceiptPrefix+"overflow"] = 0
if overflow {
stats[cardRewardReceiptPrefix+"overflow"] = 1
}
}
func cardRewardReceiptResult(stats map[string]int64) (CardRewardResult, error) {
goldBefore, beforeFound := stats[cardRewardReceiptPrefix+"gold_before"]
goldAfter, afterFound := stats[cardRewardReceiptPrefix+"gold_after"]
count, countFound := stats[cardRewardReceiptPrefix+"item_slot_count"]
overflow, overflowFound := stats[cardRewardReceiptPrefix+"overflow"]
// Receipts written before the paid-card commission existed carry no
// gold_spent key; they describe a flip that spent nothing, so a missing key
// reads as zero instead of invalidating an already-committed transaction.
goldSpent, spentFound := stats[cardRewardReceiptPrefix+"gold_spent"]
if !spentFound {
goldSpent = 0
}
if !beforeFound || !afterFound || !countFound || !overflowFound ||
goldSpent < 0 || goldSpent > math.MaxInt64/2 || goldAfter < goldBefore-goldSpent ||
count < 0 || count > math.MaxUint16 || (overflow != 0 && overflow != 1) {
return CardRewardResult{}, ErrCardRewardReceiptInvalid
}
result := CardRewardResult{
GoldBefore: goldBefore,
GoldAfter: goldAfter,
GoldSpent: goldSpent,
ItemSlots: make([]int16, 0, int(count)),
}
for index := 0; index < int(count); index++ {
value, found := stats[fmt.Sprintf("%sitem_slot_%d", cardRewardReceiptPrefix, index)]
if !found || value < -1 || value > math.MaxInt16 {
return CardRewardResult{}, ErrCardRewardReceiptInvalid
}
result.ItemSlots = append(result.ItemSlots, int16(value))
}
if overflow == 1 {
result.OverflowMailID = cardRewardReceiptOverflowMarker
}
return result, nil
}
// grantCardRewardAccountShared 把一件通关奖励并进账号共享仓库的固定格。
//
// 六个晶块(354..359)与六个灵魂(360..365)在同一个账号里只有那一个家(权威表见
// repository/account_shared_materials.go)。翻牌这条路过去直接把这些奖励写进角色
// 背包 —— 2026-09-21 的同类漏点(NPC 商店金币购买)已经证明"每条路径自己判一次"
// 迟早会漏,所以这里不再自己判,交给唯一入库原语。
//
// 顺序上有个讲究:**先用奖励自带的那一行占住固定格**(保留 bridge 投影好的
// RawEntry / 绑定 / 到期,原语不认识线格式),再把数量交给原语加 —— 额度与并数归原语,
// 行本身仍是投影出来的那一条。数量变了以后由包内的 updateDungeonRewardRawAmount
// 补盖 0x77 行里的数量字节。
//
// 返回 (放进账号仓的数量, 需要交给角色背包的余量, 账号记录是否被改动)。
// 该道具不属于账号共享仓、或账号记录不可用时,原样退回给角色背包那条既有路径。
func grantCardRewardAccountShared(accountInventory *dnfrepo.AccountInventoryRecord, reward CardItemReward) (int64, int64, bool) {
if accountInventory == nil || !reward.Stackable || reward.Stack.ItemID <= 0 || reward.Stack.Count <= 0 {
return 0, reward.Stack.Count, false
}
fixedSlot, member := dnfrepo.AccountSharedWarehouseSlotForItem(reward.Stack.ItemID)
if !member {
return 0, reward.Stack.Count, false
}
key := dnfrepo.AccountSharedInventorySlotKey(fixedSlot)
seeded := false
if existing, exists := accountInventory.Slots[key]; !exists || existing.ItemID <= 0 {
seededStack := cloneStack(reward.Stack)
// 数量归零:全部交给原语去加,免得这里和原语各加一次。
seededStack.Count = 0
accountInventory.Slots[key] = seededStack
seeded = true
}
grant := dnfrepo.GrantAccountSharedWarehouseStack(
accountInventory,
reward.Stack.ItemID,
reward.StackLimit,
reward.Stack.Count,
dnfrepo.AccountSharedGrantMeta{
Source: "dungeon_card_reward",
ItemKind: reward.Stack.Extra["item_kind"],
PVFPath: reward.Stack.Extra["pvf_path"],
StackableType: reward.Stack.Extra["stackable_type"],
},
)
if !grant.Changed {
// 固定格被别人占着、或容器里同 id 已到 [stack limit]:整件退回角色背包。
// 绝不驱赶别人的行,也不留下一个空壳行。
if seeded {
delete(accountInventory.Slots, key)
}
return 0, reward.Stack.Count, false
}
finalKey := dnfrepo.AccountSharedInventorySlotKey(grant.Slot)
stack := accountInventory.Slots[finalKey]
updateDungeonRewardRawAmount(&stack)
accountInventory.Slots[finalKey] = stack
return grant.Placed, grant.Remainder, true
}
func addCardItem(
record *dnfrepo.InventoryRecord,
mainSlots uint16,
reward CardItemReward,
project StackProjector,
) (int16, error) {
if record == nil {
return 0, ErrRewardInvalid
}
// Card-flip revive coins belong in the fixed slot-1 wallet, like every
// other channel (see dnfrevivecoin.Grant).
if dnfrevivecoin.IsReward(reward.Stack.ItemID) && reward.Stack.Count > 0 {
if _, _, err := dnfrevivecoin.GrantSlots(
record.Slots, reward.Stack.Count, "revive_coin_card_reward"); err != nil {
return 0, err
}
return dnfrevivecoin.WalletSlot, nil
}
slotStart := int16(0)
slotEnd := int16(mainSlots) - 1
if reward.SlotStart >= 3 && reward.SlotEnd >= reward.SlotStart {
slotStart = reward.SlotStart
slotEnd = reward.SlotEnd
}
// PVF [stack limit] 是这个容器里该道具的总量上限:额度按容器里同 id 总量算,
// 容器里已经有该道具的行时不能再开第二行。装备(每件一行)与没写 [stack limit]
// 的道具沿用旧行为。
newRowStackLimit := int64(0)
space := int64(math.MaxInt32)
if reward.Stackable && reward.StackLimit > 0 {
newRowStackLimit = reward.StackLimit
space = dnfrepo.ContainerItemSpace(
record.Slots,
dnfrepo.MainInventoryListType,
reward.Stack.ItemID,
reward.StackLimit,
)
}
if reward.Stackable {
for slot := slotStart; slot <= slotEnd; slot++ {
key := mainSlotKey(uint16(slot))
stack, ok := record.Slots[key]
if !ok || stack.ItemID != reward.Stack.ItemID || stack.Bind != reward.Stack.Bind {
continue
}
if reward.ExpireAt.IsZero() {
if !stack.ExpireAt.IsZero() {
continue
}
} else {
var err error
stack, err = project(stack, reward.ExpireAt)
if err != nil {
return 0, err
}
}
if reward.StackLimit == 1 || (reward.StackLimit > 0 && stack.Count > reward.StackLimit-reward.Stack.Count) ||
reward.Stack.Count > space {
continue
}
if stack.Count > math.MaxInt64-reward.Stack.Count {
return 0, fmt.Errorf("%w: item=%d", ErrRewardInvalid, reward.Stack.ItemID)
}
stack.Count += reward.Stack.Count
updateDungeonRewardRawAmount(&stack)
record.Slots[key] = stack
return int16(slot), nil
}
}
for slot := slotStart; slot <= slotEnd; slot++ {
key := mainSlotKey(uint16(slot))
if _, occupied := record.Slots[key]; occupied {
continue
}
if !dnfrepo.CanOpenContainerItemRow(record.Slots, dnfrepo.MainInventoryListType, reward.Stack.ItemID, newRowStackLimit) {
break
}
stack := cloneStack(reward.Stack)
if reward.Stackable && !reward.ExpireAt.IsZero() {
var err error
stack, err = project(stack, reward.ExpireAt)
if err != nil {
return 0, err
}
}
record.Slots[key] = stack
return int16(slot), nil
}
return 0, ErrInventoryFull
}
func updateDungeonRewardRawAmount(stack *dnfrepo.ItemStack) {
if stack == nil || stack.Count <= 0 || stack.Count > math.MaxUint32 || len(stack.RawEntry) != 0x77 {
return
}
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
binary.LittleEndian.PutUint32(stack.RawEntry[0x06:0x0a], uint32(stack.Count))
}
func mainSlotKey(slot uint16) string {
return "0:" + strconv.FormatUint(uint64(slot), 10)
}
func cloneStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
if len(stack.Extra) == 0 {
stack.Extra = nil
return stack
}
extra := make(map[string]string, len(stack.Extra))
for key, value := range stack.Extra {
extra[key] = value
}
stack.Extra = extra
return stack
}
func parseLuckyStars(raw string) (uint32, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
value, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
return 0, err
}
return uint32(value), nil
}
func contextOrBackground(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
return ctx
}
func updatedAtOrNow(value time.Time) time.Time {
if value.IsZero() {
return time.Now().UTC()
}
return value
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,108 @@
// 本文件负责把通用 PVF Document 转成地下城领域字段。
package dungeon
import dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
// parse 把单个地下城 PVF 文档收敛成稳定字段。
// 进入校验、房间生命周期和战斗结算由 dungeon/battle owner 使用这些静态字段完成。
func parse(id int64, dungeonPath string, doc *dnfpvf.Document) Dungeon {
dungeon := Dungeon{
ID: id,
Path: dungeonPath,
Name: firstText(doc, "name", "display name"),
Area: firstText(doc, "area", "region", "town"),
Kind: firstText(doc, "dungeon type", "type", "kind"),
MinLevel: firstInt(doc, "minimum level", "min level", "required level"),
MaxLevel: firstInt(doc, "maximum level", "max level"),
Fatigue: firstInt(doc, "fatigue", "fatigue cost"),
PartyMin: firstInt(doc, "minimum party", "party min"),
PartyMax: firstInt(doc, "maximum party", "party max"),
MapPaths: firstTexts(doc, "maps", "map", "map path"),
MonsterIDs: firstInts(doc, "monsters", "monster ids", "monster list"),
MonsterPaths: firstTexts(doc, "monster paths", "monster path"),
BossIDs: firstInts(doc, "boss", "boss ids"),
BossPaths: firstTexts(doc, "boss paths", "boss path"),
RewardPath: firstText(doc, "reward", "drop", "drop table"),
Scalars: scalars(doc),
}
if dungeon.Name == "" {
dungeon.Name = dungeon.Path
}
// Abyss seal door positioning: [seal door map index] and [seal door pos].
dungeon.SealDoorMapIndex = firstInt(doc, "seal door map index")
if posValues := firstInts(doc, "seal door pos"); len(posValues) >= 2 {
dungeon.SealDoorPosX = posValues[0]
dungeon.SealDoorPosY = posValues[1]
}
return dungeon
}
// scalars 提取地下城基础数值,供进入校验和结算逻辑直接内存查询。
func scalars(doc *dnfpvf.Document) map[string]float64 {
names := map[string][]string{
"min_level": {"minimum level", "min level", "required level"},
"max_level": {"maximum level", "max level"},
"fatigue": {"fatigue", "fatigue cost"},
"party_min": {"minimum party", "party min"},
"party_max": {"maximum party", "party max"},
"clear_exp": {"clear exp", "reward exp"},
"gold": {"gold", "reward gold"},
}
out := make(map[string]float64)
for key, aliases := range names {
if value, ok := firstNumber(doc, aliases...); ok {
out[key] = value
}
}
if len(out) == 0 {
return nil
}
return out
}
func firstText(doc *dnfpvf.Document, names ...string) string {
for _, name := range names {
if value, ok := doc.Text(name); ok {
return value
}
}
return ""
}
func firstTexts(doc *dnfpvf.Document, names ...string) []string {
for _, name := range names {
values := doc.Texts(name)
if len(values) > 0 {
return values
}
}
return nil
}
func firstInt(doc *dnfpvf.Document, names ...string) int64 {
for _, name := range names {
if value, ok := doc.Int(name); ok {
return value
}
}
return 0
}
func firstInts(doc *dnfpvf.Document, names ...string) []int64 {
for _, name := range names {
values := doc.Ints(name)
if len(values) > 0 {
return values
}
}
return nil
}
func firstNumber(doc *dnfpvf.Document, names ...string) (float64, bool) {
for _, name := range names {
if value, ok := doc.Number(name); ok {
return value, true
}
}
return 0, false
}
@@ -0,0 +1,22 @@
package dungeon
import (
"path"
"strings"
)
func pathKey(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
for strings.HasPrefix(value, "./") || strings.HasPrefix(value, "/") {
value = strings.TrimPrefix(value, "./")
value = strings.TrimPrefix(value, "/")
}
if value == "" {
return ""
}
clean := strings.TrimSuffix(path.Clean(value), "/")
if clean == "." {
return ""
}
return strings.ToLower(clean)
}
@@ -0,0 +1,66 @@
package dungeon
import (
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// list 38 的公会勋章页合法地从 slot 0 开始,而主背包的 slot 0 是金币伪行。
// PlacePickupItem 原先无条件按 SlotStart <= 0 判非法,于是勋章改入 list 38 之后
// 每一次拾取都被挡成 "dungeon pickup item is invalid" —— 玩家表现为勋章捡不起来。
func TestPlacePickupItemAcceptsSlotZeroOnNonMainList(t *testing.T) {
record := &dnfrepo.InventoryRecord{
CharacterID: "1",
Slots: map[string]dnfrepo.ItemStack{},
}
placement := PickupItemPlacement{
ListType: 38,
Definition: PickupItemDefinition{
ItemID: 100380022,
Kind: PickupItemEquipment,
SlotStart: 0,
SlotEnd: 48,
},
Amount: 1,
BuildNew: func(slot int16) (dnfrepo.ItemStack, error) {
return dnfrepo.ItemStack{ItemID: 100380022, Count: 1}, nil
},
}
slot, stack, err := PlacePickupItem(record, placement)
if err != nil {
t.Fatalf("PlacePickupItem() error = %v, want a placed guild medal", err)
}
if slot != 0 {
t.Fatalf("slot = %d, want the first medal slot 0", slot)
}
if stack.ItemID != 100380022 {
t.Fatalf("stack = %+v", stack)
}
if _, ok := record.Slots["38:0"]; !ok {
t.Fatalf("medal was not written to the list-38 key; slots = %v", record.Slots)
}
}
// 主背包必须保持原样:slot 0 是金币伪行,页码从 0 开始的定义仍然非法。
func TestPlacePickupItemStillRejectsSlotZeroOnMainList(t *testing.T) {
record := &dnfrepo.InventoryRecord{
CharacterID: "1",
Slots: map[string]dnfrepo.ItemStack{},
}
placement := PickupItemPlacement{
Definition: PickupItemDefinition{
ItemID: 1001,
Kind: PickupItemEquipment,
SlotStart: 0,
SlotEnd: 64,
},
Amount: 1,
BuildNew: func(slot int16) (dnfrepo.ItemStack, error) {
return dnfrepo.ItemStack{ItemID: 1001, Count: 1}, nil
},
}
if _, _, err := PlacePickupItem(record, placement); err != ErrPickupItemInvalid {
t.Fatalf("PlacePickupItem() error = %v, want ErrPickupItemInvalid", err)
}
}
@@ -0,0 +1,481 @@
package dungeon
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
)
type PickupItemKind string
const (
PickupItemStackable PickupItemKind = "stackable"
PickupItemEquipment PickupItemKind = "equipment"
)
var (
ErrPickupItemInvalid = errors.New("dungeon pickup item is invalid")
ErrPickupInventoryFull = errors.New("dungeon pickup inventory category is full")
ErrPickupStackLimit = errors.New("dungeon pickup exceeds stack limit")
ErrInventoryNotFound = errors.New("dungeon inventory is not found")
)
type PickupItemDefinition struct {
ItemID uint32
Kind PickupItemKind
StackLimit int64
SlotStart int16
SlotEnd int16
PreferQuickSlot bool
}
type PickupItemPlacement struct {
Definition PickupItemDefinition
Amount uint32
// ListType is the inventory list the pickup is placed in. Zero keeps the
// ordinary main bag, which is what every drop used before guild medals
// ([flag]) and guardian gems ([flag gem]) needed list 38: the client can
// only wear a medal or embed a gem from that list, so a pickup written to
// list 0 sat in the bag unusable until the login migration moved it.
ListType byte
BuildNew func(int16) (dnfrepo.ItemStack, error)
NormalizeExisting func(dnfrepo.ItemStack) (dnfrepo.ItemStack, error)
// ReservedSlots 是本容器里"还空着、但已经许给别人"的格位,开新行时必须跳过。
//
// 目前唯一的来源是副本内丢弃:弃置物躺在地上期间必须能回到它被丢下的那一格
// (discard origin 记着 source slot),而装备不能并进已占用的行。一旦有新拾取
// 物按"最小空格优先"占掉那一格,那件弃置物就再也捡不起来,会在副本里一直留到
// 本局结束(2026-09-27 实测:新拾取占了 43 格之后,地面对象 504 永久无法拾取)。
// 归还原格位这条规则本身不动,这里只是把"已许诺"的格位从新行候选里排除。
// nil 表示没有任何保留,除副本拾取以外的调用方都用 nil。
ReservedSlots map[int16]struct{}
}
// minimumPickupSlot is the lowest slot a pickup may occupy on a given list.
//
// On the main bag slot 0 is the gold wallet's pseudo row, never a real item, so
// a page starting at 0 there is malformed. List 38 has no such row: the guild
// medal page legitimately starts at slot 0, and rejecting it made every medal
// pickup fail with "dungeon pickup item is invalid".
func minimumPickupSlot(listType byte) int16 {
if listType == dnfrepo.MainInventoryListType {
return 1
}
return 0
}
// pickupSlotKey is the repository key for one placed pickup. It exists so the
// placer is not hard-wired to the main bag; mainSlotKey stays the zero-value
// behaviour.
func pickupSlotKey(listType byte, slot uint16) string {
if listType == 0 {
return mainSlotKey(slot)
}
return strconv.FormatUint(uint64(listType), 10) + ":" + strconv.FormatUint(uint64(slot), 10)
}
type PickupItemCommand struct {
CharacterID string
Placement PickupItemPlacement
UpdatedAt time.Time
Finalize func(int16, dnfrepo.ItemStack) (dnfrepo.ItemStack, error)
}
type PickupItemResult struct {
Slot uint16
Stack dnfrepo.ItemStack
}
// AccountSharedPickupItemCommand places one stack in an account-owned list-0
// slot. The current client renders these fixed slots in the crystal/soul bars
// beside the character backpack even though their durable owner is the account.
type AccountSharedPickupItemCommand struct {
AccountID string
CharacterID string
SharedSlot int16
Placement PickupItemPlacement
UpdatedAt time.Time
Finalize func(int16, dnfrepo.ItemStack) (dnfrepo.ItemStack, error)
}
func (o *Owner) GrantPickupItem(ctx context.Context, cmd PickupItemCommand) (PickupItemResult, error) {
if o == nil || o.items == nil || strings.TrimSpace(cmd.CharacterID) == "" {
return PickupItemResult{}, ErrOwnerUnavailable
}
ctx = contextOrBackground(ctx)
now := updatedAtOrNow(cmd.UpdatedAt)
var result PickupItemResult
err := dnfrepo.WithinCharacterItemsScoped(ctx, o.items, cmd.CharacterID, func(
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
record, found, err := inventories.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found {
return ErrInventoryNotFound
}
record = dnfrepo.CloneInventory(record)
slot, stack, err := PlacePickupItem(&record, cmd.Placement)
if err != nil {
return err
}
if cmd.Finalize != nil {
stack, err = cmd.Finalize(int16(slot), stack)
if err != nil {
return err
}
record.Slots[pickupSlotKey(cmd.Placement.ListType, slot)] = cloneStack(stack)
}
record.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, inventories, record, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
result = PickupItemResult{Slot: slot, Stack: cloneStack(stack)}
return nil
})
if err != nil {
return PickupItemResult{}, err
}
return result, nil
}
// GrantAccountSharedPickupItem grants the complete pickup to its fixed shared
// slot when possible. A full or conflicting legacy slot falls back as a whole
// to the ordinary character backpack so one pickup has one client destination.
func (o *Owner) GrantAccountSharedPickupItem(ctx context.Context, cmd AccountSharedPickupItemCommand) (PickupItemResult, error) {
accountID := strings.TrimSpace(cmd.AccountID)
characterID := strings.TrimSpace(cmd.CharacterID)
if o == nil || o.accountAssets == nil || accountID == "" || characterID == "" ||
!dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, cmd.SharedSlot) {
return PickupItemResult{}, ErrOwnerUnavailable
}
if err := validateAccountSharedPickupPlacement(cmd.Placement); err != nil {
return PickupItemResult{}, err
}
ctx = contextOrBackground(ctx)
now := updatedAtOrNow(cmd.UpdatedAt)
var result PickupItemResult
err := o.accountAssets.WithinAccountCharacterAssets(ctx, accountID, characterID, func(
accounts dnfrepo.AccountInventoryRepository,
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return err
}
if !found || strings.TrimSpace(character.AccountID) != accountID {
return ErrCharacterNotFound
}
account, found, err := accounts.Load(ctx, accountID)
if err != nil {
return err
}
if !found {
account = dnfrepo.AccountInventoryRecord{AccountID: accountID}
} else {
account = dnfrepo.CloneAccountInventory(account)
}
if account.Slots == nil {
account.Slots = make(map[string]dnfrepo.ItemStack)
}
key := dnfrepo.AccountSharedInventorySlotKey(cmd.SharedSlot)
stack, occupied := account.Slots[key]
amount := int64(cmd.Placement.Amount)
itemID := int64(cmd.Placement.Definition.ItemID)
// 账号共享晶石/灵魂格属于账号金库这一个容器:额度按容器里同 id 总量算,
// 该道具已经落在别的共享格时也不再开第二行(整包退回角色背包那条既有回退)。
space := dnfrepo.ContainerItemSpace(
account.Slots,
dnfrepo.MainInventoryListType,
itemID,
cmd.Placement.Definition.StackLimit,
)
canUseShared := !occupied || stack.ItemID == itemID
if occupied && (stack.Count < 0 || cmd.Placement.Definition.StackLimit > 0 && amount > space) {
canUseShared = false
}
if rowKey, _, exists := dnfrepo.ContainerItemRow(account.Slots, dnfrepo.MainInventoryListType, itemID); exists && rowKey != key {
canUseShared = false
}
if canUseShared {
if !occupied {
stack, err = cmd.Placement.BuildNew(cmd.SharedSlot)
if err != nil {
return err
}
} else {
if cmd.Placement.NormalizeExisting != nil {
stack, err = cmd.Placement.NormalizeExisting(stack)
if err != nil {
return err
}
}
stack.Count += amount
}
if stack.ItemID != int64(cmd.Placement.Definition.ItemID) || stack.Count <= 0 {
return ErrPickupItemInvalid
}
if cmd.Finalize != nil {
stack, err = cmd.Finalize(cmd.SharedSlot, stack)
if err != nil {
return err
}
}
account.Slots[key] = cloneStack(stack)
account.UpdatedAt = now
if err := accounts.Save(ctx, account); err != nil {
return err
}
result = PickupItemResult{Slot: uint16(cmd.SharedSlot), Stack: cloneStack(stack)}
return nil
}
inventory, found, err := inventories.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return ErrInventoryNotFound
}
inventory = dnfrepo.CloneInventory(inventory)
slot, fallback, err := PlacePickupItem(&inventory, cmd.Placement)
if err != nil {
return err
}
if cmd.Finalize != nil {
fallback, err = cmd.Finalize(int16(slot), fallback)
if err != nil {
return err
}
inventory.Slots[mainSlotKey(slot)] = cloneStack(fallback)
}
inventory.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
result = PickupItemResult{Slot: slot, Stack: cloneStack(fallback)}
return nil
})
if err != nil {
return PickupItemResult{}, err
}
return result, nil
}
func validateAccountSharedPickupPlacement(placement PickupItemPlacement) error {
definition := placement.Definition
if placement.Amount == 0 || definition.ItemID == 0 || definition.Kind != PickupItemStackable ||
definition.SlotStart <= 0 || definition.SlotEnd < definition.SlotStart || placement.BuildNew == nil {
return ErrPickupItemInvalid
}
if definition.StackLimit > 0 && int64(placement.Amount) > definition.StackLimit {
return ErrPickupStackLimit
}
return nil
}
// PlacePickupItem owns the reusable dungeon/PVF placement policy. Current
// client raw-entry construction is supplied by BuildNew/Finalize.
func PlacePickupItem(record *dnfrepo.InventoryRecord, placement PickupItemPlacement) (uint16, dnfrepo.ItemStack, error) {
definition := placement.Definition
if record == nil || placement.Amount == 0 || definition.ItemID == 0 ||
definition.SlotStart < minimumPickupSlot(placement.ListType) ||
definition.SlotEnd < definition.SlotStart {
return 0, dnfrepo.ItemStack{}, ErrPickupItemInvalid
}
if definition.Kind != PickupItemStackable && definition.Kind != PickupItemEquipment {
return 0, dnfrepo.ItemStack{}, ErrPickupItemInvalid
}
if definition.Kind == PickupItemEquipment && placement.Amount != 1 {
return 0, dnfrepo.ItemStack{}, ErrPickupItemInvalid
}
if definition.Kind == PickupItemStackable && definition.StackLimit > 0 &&
int64(placement.Amount) > definition.StackLimit {
return 0, dnfrepo.ItemStack{}, fmt.Errorf(
"%w: item=%d amount=%d limit=%d",
ErrPickupStackLimit,
definition.ItemID,
placement.Amount,
definition.StackLimit,
)
}
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
// 保留格位只可能出现在主背包(每一个 discard origin 都是 list 0),而行键按
// 列表分开,所以别的列表不需要、也不应该看这份保留集。
reserved := placement.ReservedSlots
if placement.ListType != dnfrepo.MainInventoryListType {
reserved = nil
}
// Revive coins have one home: the fixed main-list slot-1 wallet. Placing
// them in an ordinary bag row leaves the client showing two separate
// counters -- the wallet's old total and a loose consumable stack whose
//右键 path stalls -- until the next character select runs the wallet
// consolidation. This is the chokepoint for dungeon drops, quest turn-in,
// attendance and event rewards, and the epic book, all of which reach the
// bag through here.
if dnfrevivecoin.IsReward(int64(definition.ItemID)) {
_, stack, err := dnfrevivecoin.GrantSlots(
record.Slots, int64(placement.Amount), "revive_coin_pickup")
if err != nil {
return 0, dnfrepo.ItemStack{}, err
}
return uint16(dnfrevivecoin.WalletSlot), stack, nil
}
// PVF [stack limit] 的"每容器总量"口径本轮先只覆盖邮件/商城/NPC 商店/礼包;
// 拾取与贩卖机共用本落点助手(贩卖机没有溢出邮件兜底,超额会被整笔拒绝),
// 所以这里暂按旧的按行语义,等给贩卖机接上邮件溢出后再一起打开。
newRowStackLimit := int64(0)
space := int64(math.MaxInt32)
// canOpenRow 是本地这一侧的落点闸门:PVF [stack limit] 已经给这个物品配了上限、
// 且容器里已经没有可开的新行时,不允许再新开一行。
// 【参数 listType 来自本次合并】他们让掉落可以落到非主列表(公会勋章 → list 38),
// 所以这里必须按落点自己的列表判定,否则主背包的开行策略会错误地影响 list 38。
canOpenRow := func(listType byte) bool {
return space > 0 && dnfrepo.CanOpenContainerItemRow(
record.Slots,
listType,
int64(definition.ItemID),
newRowStackLimit,
)
}
// 注意:这里刻意不做"并入容器里任意位置的同 id 行"。材料/消耗品的落点按 PVF 物品页
// 决定(快捷栏那一条只对 PreferQuickSlot 的消耗品生效),跨页强行合并会把材料拖进
// 快捷栏的堆里,与 AddCurrentDungeonPickupKeepsMaterialsInPVFCategorySlots 的约定冲突。
if definition.Kind == PickupItemStackable && definition.PreferQuickSlot {
if slot, stack, ok, err := stackPickupInRange(record, placement, 3, 8, space); err != nil {
return 0, dnfrepo.ItemStack{}, err
} else if ok {
return slot, stack, nil
}
// Quick slots and the PVF category are ranges of the same main
// inventory. Merge every existing stack before allocating another
// quick-slot stack so one pickup cannot split an already bagged item.
if slot, stack, ok, err := stackPickupInRange(
record,
placement,
definition.SlotStart,
definition.SlotEnd,
space,
); err != nil {
return 0, dnfrepo.ItemStack{}, err
} else if ok {
return slot, stack, nil
}
// 【合并说明 2026-09-18,来自服务端仓库 fefab3b】
// 他们给 firstEmptyMainSlot 增加了 listType 参数(公会勋章要落进 list 38,
// 不再一律进主背包),我们这一侧在落点前加了 canOpenRow() 这道 PVF 开行闸门。
// 两边意图都要保留:空位搜索认 placement.ListType,闸门也按同一个列表判定;
// 否则主背包"不允许再开新行"时会错误地挡掉 list 38 的勋章落点。
if slot, ok := firstEmptyMainSlot(record, placement.ListType, 3, 8, reserved); ok && canOpenRow(placement.ListType) {
return insertPickup(record, placement, slot)
}
}
if slot, stack, ok, err := stackPickupInRange(
record,
placement,
definition.SlotStart,
definition.SlotEnd,
space,
); err != nil {
return 0, dnfrepo.ItemStack{}, err
} else if ok {
return slot, stack, nil
}
// 同上:非快捷栏落点同样认 placement.ListType,并保留我们的开行闸门。
if slot, ok := firstEmptyMainSlot(record, placement.ListType, definition.SlotStart, definition.SlotEnd, reserved); ok && canOpenRow(placement.ListType) {
return insertPickup(record, placement, slot)
}
return 0, dnfrepo.ItemStack{}, fmt.Errorf(
"%w: item=%d range=%d..%d",
ErrPickupInventoryFull,
definition.ItemID,
definition.SlotStart,
definition.SlotEnd,
)
}
func stackPickupInRange(
record *dnfrepo.InventoryRecord,
placement PickupItemPlacement,
start int16,
end int16,
space int64,
) (uint16, dnfrepo.ItemStack, bool, error) {
definition := placement.Definition
if definition.Kind != PickupItemStackable {
return 0, dnfrepo.ItemStack{}, false, nil
}
for slot := start; slot <= end; slot++ {
key := pickupSlotKey(placement.ListType, uint16(slot))
stack, exists := record.Slots[key]
if !exists || stack.ItemID != int64(definition.ItemID) || stack.Bind || stack.Count < 0 {
continue
}
if placement.NormalizeExisting != nil {
var err error
stack, err = placement.NormalizeExisting(stack)
if err != nil {
return 0, dnfrepo.ItemStack{}, false, err
}
}
amount := int64(placement.Amount)
// 额度是"这个容器里该道具总量"的余量,不是一个格子的余量。
if stack.Count > math.MaxInt64-amount || amount > space ||
(definition.StackLimit > 0 && stack.Count > definition.StackLimit-amount) {
continue
}
stack.Count += amount
record.Slots[key] = stack
return uint16(slot), cloneStack(stack), true, nil
}
return 0, dnfrepo.ItemStack{}, false, nil
}
func firstEmptyMainSlot(record *dnfrepo.InventoryRecord, listType byte, start int16, end int16, reserved map[int16]struct{}) (int16, bool) {
for slot := start; slot <= end; slot++ {
if _, promised := reserved[slot]; promised {
continue
}
if _, occupied := record.Slots[pickupSlotKey(listType, uint16(slot))]; !occupied {
return slot, true
}
}
return 0, false
}
func insertPickup(
record *dnfrepo.InventoryRecord,
placement PickupItemPlacement,
slot int16,
) (uint16, dnfrepo.ItemStack, error) {
if placement.BuildNew == nil {
return 0, dnfrepo.ItemStack{}, ErrPickupItemInvalid
}
stack, err := placement.BuildNew(slot)
if err != nil {
return 0, dnfrepo.ItemStack{}, err
}
if stack.ItemID != int64(placement.Definition.ItemID) ||
stack.Count != int64(placement.Amount) {
return 0, dnfrepo.ItemStack{}, ErrPickupItemInvalid
}
record.Slots[pickupSlotKey(placement.ListType, uint16(slot))] = cloneStack(stack)
return uint16(slot), cloneStack(stack), nil
}
@@ -0,0 +1,89 @@
package dungeon
import (
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func reservedTestPlacement(slotStart int16, slotEnd int16) PickupItemPlacement {
return PickupItemPlacement{
Definition: PickupItemDefinition{
ItemID: 7001,
Kind: PickupItemStackable,
StackLimit: 999,
SlotStart: slotStart,
SlotEnd: slotEnd,
},
Amount: 1,
BuildNew: func(int16) (dnfrepo.ItemStack, error) {
return dnfrepo.ItemStack{ItemID: 7001, Count: 1}, nil
},
}
}
// 弃置物躺在地上期间,它被丢下的那一格必须留给它。
//
// 装备不能并进已占用的行,所以一旦新拾取物按"最小空格优先"占掉那一格,那件弃置物
// 就永久捡不起来 —— 2026-09-27 线上就是这样卡死的(新拾取占了 43 格,地面对象 504
// 再也拾取不了)。归还原格位这条规则不变,这里只验证"已许诺"的格位会被跳过。
func TestPlacePickupItemSkipsSlotsReservedForDiscardOrigins(t *testing.T) {
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
placement := reservedTestPlacement(5, 9)
placement.ReservedSlots = map[int16]struct{}{5: {}, 6: {}}
slot, _, err := PlacePickupItem(&record, placement)
if err != nil {
t.Fatalf("PlacePickupItem: %v", err)
}
if slot != 7 {
t.Fatalf("slot=%d, want 7 (5 and 6 are promised to pending discards)", slot)
}
if _, placed := record.Slots["0:7"]; !placed {
t.Fatalf("inventory=%+v", record.Slots)
}
}
// 保留集只对主背包生效。行键按列表分开("<list>:<slot>"),而每一个 discard origin
// 都是 list 0,所以别的列表不该被主背包的保留影响。
func TestPlacePickupItemIgnoresReservationsOnOtherLists(t *testing.T) {
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
placement := reservedTestPlacement(5, 9)
placement.ListType = 38
placement.ReservedSlots = map[int16]struct{}{5: {}}
slot, _, err := PlacePickupItem(&record, placement)
if err != nil {
t.Fatalf("PlacePickupItem: %v", err)
}
if slot != 5 {
t.Fatalf("slot=%d, want 5: reservations are main-bag only", slot)
}
}
// nil 保留集(除副本拾取以外的所有调用方)必须与改动前完全一致:最小空格优先。
func TestPlacePickupItemWithoutReservationsKeepsLowestFreeSlot(t *testing.T) {
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
slot, _, err := PlacePickupItem(&record, reservedTestPlacement(5, 9))
if err != nil {
t.Fatalf("PlacePickupItem: %v", err)
}
if slot != 5 {
t.Fatalf("slot=%d, want 5", slot)
}
}
// 保留一个格位不等于把它永久锁死:那个格位一旦被真正占用(或不再保留),
// 分配就回到原来的最小空格语义。
func TestPlacePickupItemFallsThroughWhenRangeIsOnlyReservedSlots(t *testing.T) {
record := dnfrepo.InventoryRecord{Slots: map[string]dnfrepo.ItemStack{}}
placement := reservedTestPlacement(5, 6)
placement.ReservedSlots = map[int16]struct{}{5: {}, 6: {}}
if _, _, err := PlacePickupItem(&record, placement); err == nil {
t.Fatal("want inventory-full error when every slot in range is reserved")
}
if len(record.Slots) != 0 {
t.Fatalf("inventory=%+v", record.Slots)
}
}
@@ -0,0 +1,84 @@
package dungeon
import (
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrevivecoin "longheng.io/server/internal/modules/dnf/revivecoin"
)
// A revive coin picked up in a dungeon must land in the fixed slot-1 wallet,
// never in an ordinary bag row. Item 42 (cash/coin_general.stk) counts as one
// wallet coin each, so it folds into the same row as item 1.
func TestPlacePickupItemRoutesReviveCoinsToTheWallet(t *testing.T) {
for _, itemID := range []uint32{1, 42} {
record := dnfrepo.InventoryRecord{CharacterID: "7", Slots: map[string]dnfrepo.ItemStack{
dnfrevivecoin.WalletKey(): {ItemID: 1, Count: 100},
}}
slot, stack, err := PlacePickupItem(&record, PickupItemPlacement{
Definition: PickupItemDefinition{
ItemID: itemID, Kind: PickupItemStackable, StackLimit: 0,
SlotStart: 121, SlotEnd: 176,
},
Amount: 10,
BuildNew: func(int16) (dnfrepo.ItemStack, error) {
t.Fatal("a revive coin must not build an ordinary bag row")
return dnfrepo.ItemStack{}, nil
},
})
if err != nil {
t.Fatalf("item %d: %v", itemID, err)
}
if slot != uint16(dnfrevivecoin.WalletSlot) || stack.ItemID != dnfrevivecoin.WalletItemID || stack.Count != 110 {
t.Fatalf("item %d: slot=%d stack=%+v want slot 1 item 1 x110", itemID, slot, stack)
}
if len(record.Slots) != 1 {
t.Fatalf("item %d: a loose bag row was left behind: %+v", itemID, record.Slots)
}
}
}
// Ordinary pickups keep the PVF slot-range placement.
func TestPlacePickupItemLeavesOrdinaryItemsInTheBag(t *testing.T) {
record := dnfrepo.InventoryRecord{CharacterID: "7", Slots: map[string]dnfrepo.ItemStack{}}
slot, _, err := PlacePickupItem(&record, PickupItemPlacement{
Definition: PickupItemDefinition{
ItemID: 3166, Kind: PickupItemStackable, SlotStart: 121, SlotEnd: 176,
},
Amount: 3,
BuildNew: func(slot int16) (dnfrepo.ItemStack, error) {
return dnfrepo.ItemStack{ItemID: 3166, Count: 3}, nil
},
})
if err != nil || slot != 121 {
t.Fatalf("slot=%d err=%v want 121", slot, err)
}
}
// The dungeon clear card flip is its own grant path and has to follow the
// same rule.
func TestAddCardItemRoutesReviveCoinsToTheWallet(t *testing.T) {
record := dnfrepo.InventoryRecord{CharacterID: "7", Slots: map[string]dnfrepo.ItemStack{}}
slot, err := addCardItem(&record, 256, CardItemReward{
Stack: dnfrepo.ItemStack{ItemID: 42, Count: 3},
Stackable: true,
SlotStart: 121,
SlotEnd: 176,
}, func(stack dnfrepo.ItemStack, _ time.Time) (dnfrepo.ItemStack, error) {
return stack, nil
})
if err != nil {
t.Fatal(err)
}
if slot != dnfrevivecoin.WalletSlot {
t.Fatalf("slot=%d want %d", slot, dnfrevivecoin.WalletSlot)
}
wallet := record.Slots[dnfrevivecoin.WalletKey()]
if wallet.ItemID != dnfrevivecoin.WalletItemID || wallet.Count != 3 {
t.Fatalf("wallet=%+v want item 1 x3", wallet)
}
if len(record.Slots) != 1 {
t.Fatalf("a loose bag row was left behind: %+v", record.Slots)
}
}
@@ -0,0 +1,142 @@
package dungeon
import (
"context"
"errors"
"math"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrReviveCoinInvalid = errors.New("dungeon revive coin command is invalid")
ErrReviveCoinConflict = errors.New("dungeon revive coin inventory state conflicts")
)
type ReviveCoinProjector func(int16, dnfrepo.ItemStack) (dnfrepo.ItemStack, error)
type ReviveCoinCommand struct {
CharacterID string
ItemID int64
WalletSlot int16
AllowFree bool
UpdatedAt time.Time
Project ReviveCoinProjector
}
type ReviveCoinResult struct {
Consumed bool
FreeRevive bool
SlotKey string
Slot int16
ItemID int64
CountAfter int64
Removed bool
Stack dnfrepo.ItemStack
}
// ConsumeReviveCoin locates the lowest occupied main-list revive-coin slot and
// consumes it inside the character-items transaction. The permanent wallet
// slot is retained at zero; ordinary depleted stacks are removed.
func (o *Owner) ConsumeReviveCoin(ctx context.Context, cmd ReviveCoinCommand) (ReviveCoinResult, error) {
if o == nil || o.items == nil || strings.TrimSpace(cmd.CharacterID) == "" ||
cmd.ItemID <= 0 || cmd.WalletSlot < 0 || cmd.Project == nil {
return ReviveCoinResult{}, ErrReviveCoinInvalid
}
ctx = contextOrBackground(ctx)
now := updatedAtOrNow(cmd.UpdatedAt)
var result ReviveCoinResult
err := o.items.WithinCharacterItems(ctx, cmd.CharacterID, func(
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
inventory, found, err := inventories.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found {
result.FreeRevive = cmd.AllowFree
return nil
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
bestKey := ""
bestSlot := int16(math.MaxInt16)
var bestStack dnfrepo.ItemStack
for key, stack := range inventory.Slots {
slot, parsed := parseMainInventorySlotKey(key)
if !parsed || stack.ItemID != cmd.ItemID || stack.Count <= 0 {
continue
}
if bestKey == "" || slot < bestSlot {
bestKey = key
bestSlot = slot
bestStack = cloneStack(stack)
}
}
if bestKey == "" {
result.FreeRevive = cmd.AllowFree
return nil
}
bestStack.Count--
if bestStack.Extra == nil {
bestStack.Extra = make(map[string]string, 4)
}
bestStack.Extra["count"] = strconv.FormatInt(bestStack.Count, 10)
bestStack.Extra["amount_or_count"] = strconv.FormatInt(bestStack.Count, 10)
bestStack.Extra["last_consume_source"] = "dungeon_use_coin_revive"
result = ReviveCoinResult{
Consumed: true,
SlotKey: bestKey,
Slot: bestSlot,
ItemID: cmd.ItemID,
CountAfter: bestStack.Count,
}
if bestStack.Count <= 0 && bestSlot != cmd.WalletSlot {
delete(inventory.Slots, bestKey)
result.Removed = true
result.CountAfter = 0
} else {
if bestStack.Count < 0 {
return ErrReviveCoinConflict
}
bestStack, err = cmd.Project(bestSlot, bestStack)
if err != nil {
return err
}
inventory.Slots[bestKey] = cloneStack(bestStack)
result.Stack = cloneStack(bestStack)
}
inventory.UpdatedAt = now
return dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots)
})
if err != nil {
return ReviveCoinResult{}, err
}
return result, nil
}
func parseMainInventorySlotKey(key string) (int16, bool) {
listRaw, slotRaw, ok := strings.Cut(strings.TrimSpace(key), ":")
if !ok {
return 0, false
}
listType, err := strconv.ParseInt(listRaw, 10, 16)
if err != nil || byte(listType) != dnfrepo.MainInventoryListType {
return 0, false
}
slot, err := strconv.ParseInt(slotRaw, 10, 16)
if err != nil || slot < 0 {
return 0, false
}
return int16(slot), true
}
@@ -0,0 +1,92 @@
package dungeon
import (
"errors"
"fmt"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
)
var (
ErrEntryLevelTooLow = errors.New("dnf dungeon character level is too low")
ErrEntryFatigueUnknown = errors.New("dnf dungeon character fatigue is unavailable")
ErrEntryFatigueExhausted = errors.New("dnf dungeon character fatigue is exhausted")
ErrEntryPartyLimit = errors.New("dnf dungeon party exceeds PVF limit")
)
// EntryPolicy contains only the proved PVF and persisted-character facts used
// to decide whether a character may enter a dungeon. Transport/session code is
// responsible for taking a stable party snapshot before calling ValidateEntry.
type EntryPolicy struct {
CharacterLevel int
MinimumLevel int64
MinimumLevelSet bool
PartyCount int
PartyLimit int64
PartyLimitSet bool
NoFatigue bool
EnterWithoutFatigue bool
Fatigue int64
FatigueKnown bool
}
func ValidateEntry(policy EntryPolicy) error {
if policy.MinimumLevelSet && int64(policy.CharacterLevel) < policy.MinimumLevel {
return fmt.Errorf(
"%w: level=%d required=%d",
ErrEntryLevelTooLow,
policy.CharacterLevel,
policy.MinimumLevel,
)
}
partyCount := policy.PartyCount
if partyCount < 1 {
partyCount = 1
}
if policy.PartyLimitSet && policy.PartyLimit > 0 && int64(partyCount) > policy.PartyLimit {
return fmt.Errorf("%w: members=%d limit=%d", ErrEntryPartyLimit, partyCount, policy.PartyLimit)
}
if policy.NoFatigue || policy.EnterWithoutFatigue {
return nil
}
if !policy.FatigueKnown {
return ErrEntryFatigueUnknown
}
if policy.Fatigue <= 0 {
return fmt.Errorf("%w: fatigue=%d", ErrEntryFatigueExhausted, policy.Fatigue)
}
return nil
}
func CharacterStat(stats map[string]int64, names ...string) (int64, bool) {
for _, name := range names {
if value, ok := stats[name]; ok {
return value, true
}
}
return 0, false
}
// SameSelectRequest defines idempotent current-client op16 replay identity.
// Opaque tails are deliberately excluded until current-EXE evidence proves
// their semantics.
func SameSelectRequest(left, right dungeoncmd.SelectDungeonRequest) bool {
return left.DungeonID == right.DungeonID &&
left.Difficulty == right.Difficulty &&
left.EntryOption == right.EntryOption &&
left.SelectionMode == right.SelectionMode &&
left.RuntimeState == right.RuntimeState &&
left.RuntimeToken == right.RuntimeToken &&
left.Reserved == right.Reserved &&
left.PartyState == right.PartyState &&
left.LeaderObjectKey == right.LeaderObjectKey &&
left.SpecialMode == right.SpecialMode &&
len(left.OpaqueTail) == 0 && len(right.OpaqueTail) == 0
}
func RuntimeOwnsCharacter(storedCharacterID string, selectedCharacterID uint16) bool {
return selectedCharacterID != 0 &&
strings.TrimSpace(storedCharacterID) == strconv.Itoa(int(selectedCharacterID))
}
@@ -0,0 +1,119 @@
package dungeon
import (
"errors"
"testing"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
)
func TestValidateEntry(t *testing.T) {
base := EntryPolicy{
CharacterLevel: 20,
MinimumLevel: 10,
MinimumLevelSet: true,
PartyCount: 1,
PartyLimit: 4,
PartyLimitSet: true,
Fatigue: 100,
FatigueKnown: true,
}
tests := []struct {
name string
mutate func(*EntryPolicy)
wantErr error
}{
{name: "accepted"},
{
name: "minimum level",
mutate: func(policy *EntryPolicy) {
policy.CharacterLevel = 9
},
wantErr: ErrEntryLevelTooLow,
},
{
name: "party limit",
mutate: func(policy *EntryPolicy) {
policy.PartyCount = 5
},
wantErr: ErrEntryPartyLimit,
},
{
name: "fatigue unavailable",
mutate: func(policy *EntryPolicy) {
policy.FatigueKnown = false
},
wantErr: ErrEntryFatigueUnknown,
},
{
name: "fatigue exhausted",
mutate: func(policy *EntryPolicy) {
policy.Fatigue = 0
},
wantErr: ErrEntryFatigueExhausted,
},
{
name: "PVF no fatigue",
mutate: func(policy *EntryPolicy) {
policy.FatigueKnown = false
policy.NoFatigue = true
},
},
{
name: "PVF enter without fatigue",
mutate: func(policy *EntryPolicy) {
policy.FatigueKnown = false
policy.EnterWithoutFatigue = true
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
policy := base
if test.mutate != nil {
test.mutate(&policy)
}
err := ValidateEntry(policy)
if !errors.Is(err, test.wantErr) {
t.Fatalf("error=%v want=%v", err, test.wantErr)
}
})
}
}
func TestSameSelectRequest(t *testing.T) {
base := dungeoncmd.SelectDungeonRequest{
DungeonID: 700,
Difficulty: 2,
EntryOption: 3,
SelectionMode: 4,
RuntimeState: 5,
RuntimeToken: 6,
Reserved: 7,
PartyState: 8,
LeaderObjectKey: 9,
SpecialMode: 10,
}
if !SameSelectRequest(base, base) {
t.Fatal("equal proved fields were not replay-compatible")
}
changed := base
changed.RuntimeToken++
if SameSelectRequest(base, changed) {
t.Fatal("different proved fields were replay-compatible")
}
withTail := base
withTail.OpaqueTail = []byte{1}
if SameSelectRequest(base, withTail) {
t.Fatal("opaque tail was accepted without current-client evidence")
}
}
func TestRuntimeOwnsCharacter(t *testing.T) {
if !RuntimeOwnsCharacter(" 99 ", 99) {
t.Fatal("stored character did not own its selected runtime")
}
if RuntimeOwnsCharacter("99", 0) || RuntimeOwnsCharacter("98", 99) {
t.Fatal("invalid selected character owned the runtime")
}
}
@@ -0,0 +1,101 @@
package dungeon
import (
"context"
"time"
"longheng.io/server/internal/modules/dnf/guild"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// GuildContributionClear carries the 公会贡献度 side of one dungeon clear: the
// roster snapshot taken at entry, the party that actually cleared, the PVF
// catalog and local policy, plus the coin grant callback. The settlement runs it
// inside the same transaction as the rest of the clear receipt.
//
// Ported from the upstream guild branch, minus its GuildDungeon coupling: see
// applySettlementContribution for why that dependency is deliberately dropped.
type GuildContributionClear struct {
Entry guild.ContributionEntry
Party []string
Catalog guild.ContributionCatalog
Policy guild.ContributionPolicy
Grant func(*dnfrepo.InventoryRecord, uint32) error
}
// applySettlementContribution records the 公会贡献度 events of one clear and
// delivers any coins that became due.
//
// guildDungeonAccepted is the caller's verdict on whether this clear may award
// the 公会地下城 ("guild dungeon clear") contribution event. It must be true for
// every ordinary dungeon; for a 公会地下城 clear it is our own admission verdict.
//
// Upstream expressed this as `cmd.GuildDungeon != nil && !guildDungeonAccepted`,
// because its SettlementCommand carries a *GuildDungeonClear holding the monthly
// DungeonCycle state. This tree deliberately does not model that: our 公会地下城 is
// a weekly gauge design and we identify guild-dungeon clears by looking the
// dungeon id back up in the catalog (see dnfbridge/wedding_ceremony.go
// currentWeddingGuildDungeon, which documents the same reasoning). Carrying a
// second, redundant notion of "is this a guild dungeon" in the command would let
// the two disagree, so the caller states the verdict instead and the command
// stays free of guild-dungeon state.
func applySettlementContribution(ctx context.Context, tx dnfrepo.Group, cmd SettlementCommand, inventory *dnfrepo.InventoryRecord, guildDungeonAccepted bool, now time.Time) error {
clear := cmd.GuildContribution
if clear == nil {
return nil
}
if !guildDungeonAccepted {
return nil
}
if clear.Entry.CharacterID != cmd.CharacterID || clear.Catalog == nil || clear.Grant == nil {
return ErrSettlementStateInvalid
}
r, err := guild.Load(ctx, tx.Settings)
if err != nil {
return err
}
eligible, count := r.ContributionEligible(clear.Entry, clear.Party)
if !eligible {
return nil
}
state, err := guild.LoadContribution(ctx, tx.Settings, cmd.CharacterID)
if err != nil {
return err
}
var points uint32
for _, event := range []struct {
kind string
enabled bool
}{
{guild.ContributionDungeon, guildDungeonAccepted},
{guild.ContributionParty, count >= clear.Catalog[guild.ContributionParty].Required && count >= 2},
} {
if !event.enabled {
continue
}
value, e := state.Observe(clear.Catalog, clear.Policy, event.kind, cmd.CompletionKey, count, now, now)
if e != nil {
return e
}
points += value
}
if points > 0 {
if err = r.AddContributionPoints(cmd.CharacterID, points, now); err != nil {
return err
}
}
delivered, err := state.Deliver(inventory, clear.Grant)
if err != nil {
return err
}
if delivered {
inventory.UpdatedAt = now
if err = dnfrepo.SaveInventoryFields(ctx, tx.Inventory, *inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
if err = guild.SaveContribution(ctx, tx.Settings, cmd.CharacterID, state, now); err != nil {
return err
}
return guild.Save(ctx, tx.Settings, r, now)
}
@@ -0,0 +1,576 @@
package dungeon
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
"longheng.io/server/internal/modules/dnf/honor"
"longheng.io/server/internal/modules/dnf/onlineevent"
"longheng.io/server/internal/modules/dnf/progression"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/wedding"
)
const (
settlementReceiptPrefix = "dungeon_settlement_receipt_"
settlementReceiptVersion = int64(1)
)
var ErrSettlementStateInvalid = errors.New("dungeon settlement persisted state is invalid")
type SettlementCommand struct {
AccountID string
CharacterID string
CompletionKey string
Tables *progression.Tables
Experience uint32
RecommendedDungeonClear bool
// WeddingCapsule 是婚姻成长胶囊的 PVF 规则;仅当"已婚角色通关推荐副本"时
// 由 bridge 解析并传入,其它情况为 nil,本 owner 会整段跳过。
WeddingCapsule *wedding.CapsuleCatalog
// 活动(onlineevent)进度闸门。这三项都必须在本事务内推进,否则"通关即签到"
// 这条链路就断了——签到类活动的进度只在副本结算里累加,没有第二个写入方。
//
// MonthlyAttendance 月度签到(活动 2370):推荐副本通关计数,每日 2 次
// WeeklyAttendance 周周签到(活动 2208):需要目录来判定当周与补签
// AttendanceMarble 签到弹珠:通关清次数决定额外骰子
//
// 三者都只在 RecommendedDungeonClear 为真时生效。
MonthlyAttendance bool
WeeklyAttendance *onlineevent.WeeklyCatalog
AttendanceMarble *onlineevent.MarbleCatalog
AdventureRuntime *adventuregroup.RuntimeConfig
HonorExpertTables *honor.Tables
MaximumCharacterLevel int
// RentalPointAward and RentalPointLimit are resolved from the current PVF
// rental catalog and committed with the dungeon completion receipt.
RentalPointAward uint32
RentalPointLimit uint32
// AllowZeroExperience opts a completion that legitimately pays no clear
// experience into the ledger. The zero-experience guard below exists to catch
// a caller whose clear-exp computation silently produced nothing; a raid gate
// is the one real case (its reward is the raid phase settlement, not character
// growth), so it has to ask explicitly instead of the guard being relaxed for
// everyone.
AllowZeroExperience bool
// Tower carries a Tower of Despair/Grief clear: its ten frozen reward slots
// and the entry pool it consumes. Nil for every ordinary dungeon.
Tower *TowerClear
// GuildContribution carries the 公会贡献度 events of this clear. Nil for
// every non-member and for clears that never took a roster snapshot.
GuildContribution *GuildContributionClear
UpdatedAt time.Time
}
type SettlementResult struct {
Character dnfrepo.CharacterRecord
Skill dnfrepo.SkillRecord
Inventory dnfrepo.InventoryRecord
ExperienceGain uint32
HonorExpertGain uint32
SPGain int
TPGain int
LuckyStarBefore uint32
LuckyStarApplied uint32
LuckyStarAfter uint32
LuckyStarAttempted bool
Replayed bool
}
// CommitSettlement atomically advances experience and the SP/TP ledger and
// writes an idempotency receipt tied to the accepted dungeon completion.
func (o *Owner) CommitSettlement(ctx context.Context, cmd SettlementCommand) (SettlementResult, error) {
if o == nil || o.settlements == nil || strings.TrimSpace(cmd.CharacterID) == "" ||
strings.TrimSpace(cmd.AccountID) == "" || strings.TrimSpace(cmd.CompletionKey) == "" ||
cmd.Tables == nil {
return SettlementResult{}, ErrOwnerUnavailable
}
// A tower clear pays its reward through the tower window rather than clear
// experience, so a zero-experience tower settlement is legitimate.
// 同理:签到类活动即使这一笔不带经验(例如免经验的活动副本),只要它会推进
// 活动进度,也必须放行——否则玩家通关了却什么都没记上。
if cmd.Experience == 0 && !cmd.AllowZeroExperience && cmd.Tower == nil &&
!(cmd.RecommendedDungeonClear && (cmd.RentalPointAward > 0 || cmd.WeddingCapsule != nil ||
cmd.MonthlyAttendance || cmd.WeeklyAttendance != nil || cmd.AttendanceMarble != nil)) {
return SettlementResult{}, ErrSettlementStateInvalid
}
ctx = contextOrBackground(ctx)
wantHash := sha256.Sum256([]byte(cmd.CompletionKey))
now := updatedAtOrNow(cmd.UpdatedAt)
var result SettlementResult
err := o.settlements.WithinCharacterSettlement(ctx, cmd.CharacterID, func(tx dnfrepo.Group) error {
character, found, err := tx.Character.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || character.CharacterID != cmd.CharacterID ||
strings.TrimSpace(character.AccountID) != strings.TrimSpace(cmd.AccountID) {
return ErrSettlementStateInvalid
}
skill, found, err := tx.Skill.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || skill.CharacterID != cmd.CharacterID {
return ErrSettlementStateInvalid
}
inventory, found, err := tx.Inventory.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || inventory.CharacterID != cmd.CharacterID {
return ErrSettlementStateInvalid
}
character = dnfrepo.CloneCharacter(character)
skill = dnfrepo.CloneSkill(skill)
inventory = dnfrepo.CloneInventory(inventory)
if settlementReceiptMatches(character.Stats, wantHash) {
receipt, err := settlementReceiptValues(character, skill)
if err != nil {
return err
}
result = SettlementResult{
Character: character, Skill: skill, Inventory: inventory,
ExperienceGain: receipt.ExperienceGain,
SPGain: receipt.SPGain,
LuckyStarBefore: receipt.LuckyStarBefore,
LuckyStarApplied: receipt.LuckyStarApplied,
LuckyStarAfter: receipt.LuckyStarAfter,
LuckyStarAttempted: receipt.LuckyStarAttempted,
Replayed: true,
}
return nil
}
experience, present := character.Stats["exp"]
if !present || experience < 0 || uint64(experience) > math.MaxUint32 ||
character.Level <= 0 || skill.Points.SyncedLevel != character.Level {
return ErrSettlementStateInvalid
}
maximumLevel := cmd.MaximumCharacterLevel
if maximumLevel <= 0 {
maximumLevel = progression.MaximumCharacterLevel
}
planned, err := PlanSettlementProgressionAtCap(
cmd.Tables,
character.Level,
uint32(experience),
cmd.Experience,
skill.Points,
maximumLevel,
)
if err != nil {
return err
}
maximumLevelExperience := maximumLevelExperienceGain(
character.Level,
uint32(experience),
cmd.Experience,
cmd.MaximumCharacterLevel,
cmd.Tables,
)
character.Level = planned.Experience.NewLevel
character.Stats["exp"] = int64(planned.Experience.NewExperience)
appliedHonorExpertGain := uint32(0)
if maximumLevelExperience > 0 && cmd.HonorExpertTables != nil {
currentExpert, err := honor.ExpertProgressFromStats(character.Stats)
if err != nil {
return err
}
nextExpert, err := cmd.HonorExpertTables.AdvanceExpert(currentExpert, uint64(maximumLevelExperience))
if err != nil {
return err
}
expertStats, err := honor.ExpertStats(nextExpert)
if err != nil {
return err
}
for key, value := range expertStats {
character.Stats[key] = value
}
appliedHonorExpertGain = maximumLevelExperience
}
if cmd.RecommendedDungeonClear {
// 签到弹珠:通关清次数决定额外骰子(4/8/12 次档)。必须在推荐次数
// 自增之前跑,它读的是"这一局之前"的累计清次数。
if err := onlineevent.RecordMarbleClear(&character, cmd.AttendanceMarble, now); err != nil {
return err
}
count := character.Stats[adventuregroup.RecommendedDungeonClearStatKey]
if count < 0 {
return ErrSettlementStateInvalid
}
if count < math.MaxUint16 {
character.Stats[adventuregroup.RecommendedDungeonClearStatKey] = count + 1
}
// 已婚角色的推荐副本通关同时累加婚姻成长胶囊进度。与推荐次数同事务,
// 所以重放同一 CompletionKey 不会重复计入(回执幂等门在上层)。
if cmd.WeddingCapsule != nil {
if err := wedding.AccumulateCapsuleClear(&character, cmd.WeddingCapsule); err != nil {
return err
}
}
}
// 月度/周签到的进度落在**账号**上,所以有它们就必须把账号行读进同一事务。
monthlyClear := cmd.MonthlyAttendance && cmd.RecommendedDungeonClear
weeklyClear := cmd.WeeklyAttendance != nil && cmd.RecommendedDungeonClear
// 骁勇结晶从 2026-09-24 起对**任意等级**的经验记账(不再只收满级溢出),
// 所以只要这笔结算带了经验、又给了冒险团配置,就必须把账号记录读进来
// 同事务提交;否则未满级角色通关会被 ErrSettlementStateInvalid 卡住。
needsAccount := monthlyClear || weeklyClear || cmd.RentalPointAward > 0 || cmd.Tower != nil ||
(cmd.AdventureRuntime != nil && (cmd.Experience > 0 || maximumLevelExperience > 0))
var account dnfrepo.AccountRecord
accountLoaded := false
if needsAccount {
if tx.Account == nil {
return ErrSettlementStateInvalid
}
loaded, accountFound, err := tx.Account.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
if !accountFound || strings.TrimSpace(loaded.AccountID) != strings.TrimSpace(cmd.AccountID) {
// 账号行缺失时的取舍与怪物击杀一致:这笔结算里只要有**满级部分**
// (进胶囊槽/名誉经验)或者**租赁点券**要落,就整笔失败 —— 那两项
// 是原版规则,静默丢掉比报错更糟;只有"未满级经验镜像进骁勇结晶"
// 时跳过记账、继续照常发角色经验与奖励,不让一行账本缺失把玩家的
// 通关结算整个卡死("改了A把B弄坏"正是要避免的)。
//
// 签到同理:账号行不在就没法记签到进度,但也不能因此把整笔通关
// 卡死——角色经验/技能/背包的写入还在下面。
if cmd.RentalPointAward > 0 || maximumLevelExperience > 0 || cmd.Tower != nil {
return ErrSettlementStateInvalid
}
// 注意:这里不能 return —— 角色经验/技能/背包的写入还在下面。
needsAccount = false
} else {
account = dnfrepo.CloneAccount(loaded)
accountLoaded = true
}
}
if accountLoaded {
if monthlyClear {
if err := onlineevent.RecordMonthlyClear(&account, now); err != nil {
return err
}
}
if weeklyClear {
if err := onlineevent.RecordWeeklyClear(&account, cmd.WeeklyAttendance, now); err != nil {
return err
}
}
}
if cmd.RentalPointAward > 0 {
if cmd.RentalPointLimit == 0 || cmd.RentalPointAward > cmd.RentalPointLimit {
return ErrSettlementStateInvalid
}
current, err := parseLuckyStars(account.Metadata[LuckyStarMetadataKey])
if err != nil {
return fmt.Errorf("%w: invalid %s metadata: %v", ErrSettlementStateInvalid, LuckyStarMetadataKey, err)
}
result.LuckyStarBefore = current
result.LuckyStarAfter = current
result.LuckyStarAttempted = true
if current < cmd.RentalPointLimit {
applied := cmd.RentalPointAward
available := cmd.RentalPointLimit - current
if applied > available {
applied = available
}
result.LuckyStarApplied = applied
result.LuckyStarAfter = current + applied
if applied > 0 {
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
account.Metadata[LuckyStarMetadataKey] = strconv.FormatUint(uint64(result.LuckyStarAfter), 10)
}
}
}
writeSettlementReceipt(
character.Stats,
wantHash,
cmd.Experience,
planned.SkillPoints.SPGain,
planned.Experience.NewLevel,
planned.Experience.NewExperience,
result.LuckyStarAttempted,
result.LuckyStarBefore,
result.LuckyStarApplied,
result.LuckyStarAfter,
)
character.UpdatedAt = now
skill.Points = planned.SkillPoints.New
skill.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldBase, dnfrepo.CharacterFieldStats); err != nil {
return err
}
if err := dnfrepo.SaveSkillFields(ctx, tx.Skill, skill, dnfrepo.SkillFieldPoints); err != nil {
return err
}
if accountLoaded && cmd.AdventureRuntime != nil && (cmd.Experience > 0 || maximumLevelExperience > 0) {
// 骁勇结晶 / 成长胶囊槽 / 账号名誉经验的口径只在 adventuregroup 里
// 定义一次:怪物击杀与任务完成两条路也用同一个函数记入,否则同一笔
// 经验会在不同路径上算出不同结果。
// AwardedExperience = 这次通关经验全额 → 骁勇结晶(任意等级)
// MaximumLevelExperience = 其中的满级溢出 → 胶囊槽 + 名誉经验
if _, err := adventuregroup.ApplyExperienceCredit(
&account,
*cmd.AdventureRuntime,
adventuregroup.ExperienceCredit{
AwardedExperience: uint64(cmd.Experience),
MaximumLevelExperience: uint64(maximumLevelExperience),
},
now,
); err != nil {
return err
}
}
if accountLoaded {
account.UpdatedAt = now
if err := tx.Account.Save(ctx, account); err != nil {
return err
}
}
if cmd.Tower != nil {
// Consume the entry pool (character entry first, then the account
// pool), then apply the frozen ten reward slots. Both live in this
// transaction so a failed grant leaves the floor and the pools
// untouched.
if !accountLoaded {
return ErrSettlementStateInvalid
}
if err := recordTowerEntryClear(&account, &character, cmd.Tower.Kind, now); err != nil {
return err
}
if err := applyTowerClear(&character, &inventory, cmd.Tower, now); err != nil {
return err
}
if err := RecordTower100FloorClear(&account, cmd.Tower.Kind, cmd.Tower.Floor); err != nil {
return err
}
character.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
account.UpdatedAt = now
if err := tx.Account.Save(ctx, account); err != nil {
return err
}
inventory.UpdatedAt = now
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
}
result = SettlementResult{
Character: character, Skill: skill, Inventory: inventory,
ExperienceGain: cmd.Experience,
HonorExpertGain: appliedHonorExpertGain,
SPGain: planned.SkillPoints.SPGain,
TPGain: planned.SkillPoints.TPGain,
LuckyStarBefore: result.LuckyStarBefore,
LuckyStarApplied: result.LuckyStarApplied,
LuckyStarAfter: result.LuckyStarAfter,
LuckyStarAttempted: result.LuckyStarAttempted,
}
return nil
})
if err != nil {
return SettlementResult{}, err
}
return result, nil
}
func maximumLevelExperienceGain(
previousLevel int,
previousExperience uint32,
gain uint32,
maximumLevel int,
tables *progression.Tables,
) uint32 {
if maximumLevel <= 0 || previousLevel <= 0 || previousLevel > maximumLevel || gain == 0 || tables == nil {
return 0
}
if previousLevel >= maximumLevel {
return gain
}
entryExperience, err := tables.ThresholdToNext(maximumLevel - 1)
if err != nil {
return 0
}
total := uint64(previousExperience) + uint64(gain)
if total <= uint64(entryExperience) {
return 0
}
excess := total - uint64(entryExperience)
if excess > math.MaxUint32 {
return math.MaxUint32
}
return uint32(excess)
}
func PlanSettlementProgression(
tables *progression.Tables,
level int,
total uint32,
gain uint32,
points dnfrepo.SkillPointState,
) (progression.ExperienceSkillPointPlan, error) {
return PlanSettlementProgressionAtCap(tables, level, total, gain, points, progression.MaximumCharacterLevel)
}
func PlanSettlementProgressionAtCap(
tables *progression.Tables,
level int,
total uint32,
gain uint32,
points dnfrepo.SkillPointState,
maximumLevel int,
) (progression.ExperienceSkillPointPlan, error) {
if tables == nil || level <= 0 || points.SyncedLevel != level || maximumLevel <= 0 ||
maximumLevel > progression.MaximumCharacterLevel || level > maximumLevel {
return progression.ExperienceSkillPointPlan{}, ErrSettlementStateInvalid
}
newTotal := uint64(total) + uint64(gain)
if newTotal > math.MaxUint32 {
newTotal = math.MaxUint32
}
result := progression.ExperienceResult{
PreviousLevel: level,
PreviousExperience: total,
Gain: gain,
NewLevel: level,
NewExperience: uint32(newTotal),
}
for result.NewLevel < maximumLevel {
threshold, err := tables.ThresholdToNext(result.NewLevel)
if err != nil {
return progression.ExperienceSkillPointPlan{}, err
}
if result.NewExperience < threshold {
break
}
if _, ok := tables.SkillPointsAtLevel(result.NewLevel + 1); !ok {
return progression.ExperienceSkillPointPlan{}, ErrSettlementStateInvalid
}
result.NewLevel++
}
// Max-level ordinary EXP stays at the entry threshold. The caller still
// assigns the full post-cap award to HonorExpert.
if result.NewLevel == maximumLevel && maximumLevel > 1 {
entryThreshold, err := tables.ThresholdToNext(maximumLevel - 1)
if err != nil {
return progression.ExperienceSkillPointPlan{}, err
}
if result.NewExperience > entryThreshold {
result.NewExperience = entryThreshold
result.Saturated = true
}
}
result.LevelsGained = result.NewLevel - result.PreviousLevel
advance, err := tables.AdvanceSkillPoints(points, result.NewLevel)
if err != nil {
return progression.ExperienceSkillPointPlan{}, err
}
return progression.ExperienceSkillPointPlan{Experience: result, SkillPoints: advance}, nil
}
func settlementReceiptMatches(stats map[string]int64, want [sha256.Size]byte) bool {
if stats == nil || stats[settlementReceiptPrefix+"version"] != settlementReceiptVersion {
return false
}
for index := 0; index < 4; index++ {
if uint64(stats[fmt.Sprintf("%shash_%d", settlementReceiptPrefix, index)]) !=
binary.LittleEndian.Uint64(want[index*8:(index+1)*8]) {
return false
}
}
return true
}
func writeSettlementReceipt(
stats map[string]int64,
hash [sha256.Size]byte,
gain uint32,
spGain int,
newLevel int,
newExperience uint32,
luckyStarAttempted bool,
luckyStarBefore uint32,
luckyStarApplied uint32,
luckyStarAfter uint32,
) {
stats[settlementReceiptPrefix+"version"] = settlementReceiptVersion
for index := 0; index < 4; index++ {
stats[fmt.Sprintf("%shash_%d", settlementReceiptPrefix, index)] =
int64(binary.LittleEndian.Uint64(hash[index*8 : (index+1)*8]))
}
stats[settlementReceiptPrefix+"experience_gain"] = int64(gain)
stats[settlementReceiptPrefix+"sp_gain"] = int64(spGain)
stats[settlementReceiptPrefix+"new_level"] = int64(newLevel)
stats[settlementReceiptPrefix+"new_experience"] = int64(newExperience)
stats[settlementReceiptPrefix+"lucky_star_attempted"] = 0
if luckyStarAttempted {
stats[settlementReceiptPrefix+"lucky_star_attempted"] = 1
}
stats[settlementReceiptPrefix+"lucky_star_before"] = int64(luckyStarBefore)
stats[settlementReceiptPrefix+"lucky_star_applied"] = int64(luckyStarApplied)
stats[settlementReceiptPrefix+"lucky_star_after"] = int64(luckyStarAfter)
}
type settlementReceipt struct {
ExperienceGain uint32
SPGain int
LuckyStarBefore uint32
LuckyStarApplied uint32
LuckyStarAfter uint32
LuckyStarAttempted bool
}
func settlementReceiptValues(
character dnfrepo.CharacterRecord,
skill dnfrepo.SkillRecord,
) (settlementReceipt, error) {
stats := character.Stats
gain, gainPresent := stats[settlementReceiptPrefix+"experience_gain"]
spGain := stats[settlementReceiptPrefix+"sp_gain"]
if !gainPresent || gain < 0 || gain > math.MaxUint32 || spGain < 0 || spGain > math.MaxInt ||
stats[settlementReceiptPrefix+"new_level"] != int64(character.Level) ||
stats[settlementReceiptPrefix+"new_experience"] != stats["exp"] ||
skill.Points.SyncedLevel != character.Level {
return settlementReceipt{}, ErrSettlementStateInvalid
}
receipt := settlementReceipt{ExperienceGain: uint32(gain), SPGain: int(spGain)}
attempted := stats[settlementReceiptPrefix+"lucky_star_attempted"]
if attempted == 0 {
if stats[settlementReceiptPrefix+"lucky_star_before"] != 0 ||
stats[settlementReceiptPrefix+"lucky_star_applied"] != 0 ||
stats[settlementReceiptPrefix+"lucky_star_after"] != 0 {
return settlementReceipt{}, ErrSettlementStateInvalid
}
return receipt, nil
}
if attempted != 1 {
return settlementReceipt{}, ErrSettlementStateInvalid
}
before := stats[settlementReceiptPrefix+"lucky_star_before"]
applied := stats[settlementReceiptPrefix+"lucky_star_applied"]
after := stats[settlementReceiptPrefix+"lucky_star_after"]
if before < 0 || before > math.MaxUint32 || applied < 0 || applied > math.MaxUint32 ||
after < 0 || after > math.MaxUint32 || before+applied != after {
return settlementReceipt{}, ErrSettlementStateInvalid
}
receipt.LuckyStarBefore = uint32(before)
receipt.LuckyStarApplied = uint32(applied)
receipt.LuckyStarAfter = uint32(after)
receipt.LuckyStarAttempted = true
return receipt, nil
}
@@ -0,0 +1,354 @@
package dungeon
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/premium"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Tower of Despair and Tower of Grief share one floor/progress model. The two
// kinds differ only in the PVF document and in how the clear reward is drawn:
// despair grants pickups, grief draws ten independent PVF card slots.
type TowerKind string
const (
TowerDespair TowerKind = "despair"
TowerGrief TowerKind = "grief"
TowerRewardSlots = 10
towerDailyUsageDayKey = "tower_daily_usage_day"
towerDailyUsageKeyPrefix = "tower_daily_used_"
)
var ErrTowerDailyLimitReached = errors.New("tower daily entry limit reached")
type TowerReward struct{ ItemID, Count uint32 }
type TowerClear struct {
Kind TowerKind
Floor uint16
Rewards [TowerRewardSlots]TowerReward
Grant func(*dnfrepo.InventoryRecord, uint32, uint32) error
}
func towerStat(kind TowerKind, suffix string) string { return "tower_" + string(kind) + "_" + suffix }
// Each tower has one character entry in addition to the shared account pool.
// Legacy clears only consumed the account pool, so an absent marker preserves
// the character entry instead of charging the same old clear to both pools.
func TowerCharacterDailyRemaining(character dnfrepo.CharacterRecord, kind TowerKind, now time.Time) byte {
if kind != TowerDespair && kind != TowerGrief {
return 0
}
if day, found := character.Stats[towerStat(kind, "character_entry_day")]; found && day >= premium.ServiceDayID(now) {
return 0
}
return 1
}
func recordTowerEntryClear(account *dnfrepo.AccountRecord, character *dnfrepo.CharacterRecord, kind TowerKind, now time.Time) error {
if TowerCharacterDailyRemaining(*character, kind, now) == 0 {
return RecordTowerDailyClear(account, kind, now)
}
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
character.Stats[towerStat(kind, "character_entry_day")] = premium.ServiceDayID(now)
return nil
}
func TowerFloor(character dnfrepo.CharacterRecord, kind TowerKind) (uint16, error) {
if kind != TowerDespair && kind != TowerGrief {
return 0, ErrSettlementStateInvalid
}
floor := character.Stats[towerStat(kind, "floor")]
if floor == 0 {
return 1, nil
}
if floor < 1 || floor > 100 {
return 0, ErrSettlementStateInvalid
}
return uint16(floor), nil
}
// TowerDailyEnterableLimit reads the account-wide daily cap from the active
// tower PVF document. The client uses this value for the account tooltip.
func TowerDailyEnterableLimit(source dnfpvf.Source, kind TowerKind, adventureLevel int) (uint32, error) {
if kind != TowerDespair && kind != TowerGrief {
return 0, ErrSettlementStateInvalid
}
doc, err := towerDocument(source, "etc/towerof"+string(kind)+".etc")
if err != nil {
return 0, err
}
value, ok := doc.Int("account enterable max count")
if !ok || value <= 0 || value > math.MaxUint32 {
return 0, ErrSettlementStateInvalid
}
rows := doc.Ints("adventure group add enterable count")
if len(rows)%3 != 0 {
return 0, ErrSettlementStateInvalid
}
var previousEnd int64
for i := 0; i < len(rows); i += 3 {
low, high, extra := rows[i], rows[i+1], rows[i+2]
if low <= previousEnd || high < low || extra < 0 || extra > math.MaxUint32-value {
return 0, ErrSettlementStateInvalid
}
previousEnd = high
if int64(adventureLevel) >= low && int64(adventureLevel) <= high {
value += extra
}
}
return uint32(value), nil
}
func towerDailyUsageKey(kind TowerKind) string {
return towerDailyUsageKeyPrefix + string(kind)
}
// TowerDailyUsed returns the number of successful clears in the current DNF
// service day. Stale metadata is treated as zero without mutating storage.
func TowerDailyUsed(account dnfrepo.AccountRecord, kind TowerKind, now time.Time) (uint32, error) {
if kind != TowerDespair && kind != TowerGrief {
return 0, ErrSettlementStateInvalid
}
if account.Metadata == nil || account.Metadata[towerDailyUsageDayKey] != strconv.FormatInt(premium.ServiceDayID(now), 10) {
return 0, nil
}
value := strings.TrimSpace(account.Metadata[towerDailyUsageKey(kind)])
if value == "" {
return 0, nil
}
used, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return 0, fmt.Errorf("%w: invalid tower daily usage", ErrSettlementStateInvalid)
}
return uint32(used), nil
}
// RecordTowerDailyClear updates account-wide usage inside the settlement
// transaction. Admission owns the limit check: an accepted, completed run must
// still receive its rewards. The settlement receipt makes retries apply once.
func RecordTowerDailyClear(account *dnfrepo.AccountRecord, kind TowerKind, now time.Time) error {
if account == nil || (kind != TowerDespair && kind != TowerGrief) {
return ErrSettlementStateInvalid
}
used, err := TowerDailyUsed(*account, kind, now)
if err != nil {
return err
}
if used == math.MaxUint32 {
return ErrSettlementStateInvalid
}
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
day := strconv.FormatInt(premium.ServiceDayID(now), 10)
if account.Metadata[towerDailyUsageDayKey] != day {
delete(account.Metadata, towerDailyUsageKey(TowerDespair))
delete(account.Metadata, towerDailyUsageKey(TowerGrief))
}
account.Metadata[towerDailyUsageDayKey] = day
account.Metadata[towerDailyUsageKey(kind)] = strconv.FormatUint(uint64(used+1), 10)
return nil
}
func tower100ClearMetadataKey(kind TowerKind) string {
switch kind {
case TowerDespair:
return "adventure_group_despair_100_clears"
case TowerGrief:
return "adventure_group_grief_100_clears"
default:
return ""
}
}
// RecordTower100FloorClear increments the account-wide adventure-group
// counter only when the cleared floor is 100. A missing counter means zero.
func RecordTower100FloorClear(account *dnfrepo.AccountRecord, kind TowerKind, floor uint16) error {
if account == nil || floor != 100 {
return nil
}
key := tower100ClearMetadataKey(kind)
if key == "" {
return ErrSettlementStateInvalid
}
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
var count uint64
if raw := strings.TrimSpace(account.Metadata[key]); raw != "" {
parsed, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
return fmt.Errorf("%w: invalid tower 100 clear counter", ErrSettlementStateInvalid)
}
count = parsed
}
if count == math.MaxUint32 {
return ErrSettlementStateInvalid
}
account.Metadata[key] = strconv.FormatUint(count+1, 10)
return nil
}
func applyTowerClear(character *dnfrepo.CharacterRecord, inventory *dnfrepo.InventoryRecord, clear *TowerClear, now time.Time) error {
if clear == nil {
return nil
}
if _, err := TowerFloor(*character, clear.Kind); err != nil {
return err
}
if clear.Floor == 0 || clear.Floor > 100 {
return ErrSettlementStateInvalid
}
for slot, reward := range clear.Rewards {
if (reward.ItemID == 0) != (reward.Count == 0) || reward.ItemID == math.MaxUint32 {
return ErrSettlementStateInvalid
}
if reward.ItemID != 0 {
if clear.Grant == nil {
return ErrSettlementStateInvalid
}
if err := clear.Grant(inventory, reward.ItemID, reward.Count); err != nil {
return err
}
}
character.Stats[towerStat(clear.Kind, fmt.Sprintf("reward_%d_item", slot))] = int64(reward.ItemID)
character.Stats[towerStat(clear.Kind, fmt.Sprintf("reward_%d_count", slot))] = int64(reward.Count)
}
character.Stats[towerStat(clear.Kind, "floor")] = int64(clear.Floor%100 + 1)
character.Stats[towerStat(clear.Kind, "last_clear_floor")] = int64(clear.Floor)
character.Stats[towerStat(clear.Kind, "last_clear_at")] = now.Unix()
return nil
}
func TowerLastRewards(character dnfrepo.CharacterRecord, kind TowerKind) ([TowerRewardSlots]TowerReward, error) {
var out [TowerRewardSlots]TowerReward
for slot := range out {
id := character.Stats[towerStat(kind, fmt.Sprintf("reward_%d_item", slot))]
count := character.Stats[towerStat(kind, fmt.Sprintf("reward_%d_count", slot))]
if id < 0 || id >= math.MaxUint32 || count < 0 || count > math.MaxUint32 || (id == 0) != (count == 0) {
return out, ErrSettlementStateInvalid
}
out[slot] = TowerReward{uint32(id), uint32(count)}
}
return out, nil
}
func towerDocument(source dnfpvf.Source, path string) (*dnfpvf.Document, error) {
text, err := source.ReadText(path)
if err != nil {
return nil, err
}
return dnfpvf.Parse(path, text)
}
func TowerDungeonID(source dnfpvf.Source, kind TowerKind, floor uint16) (int64, error) {
if kind != TowerDespair && kind != TowerGrief {
return 0, ErrSettlementStateInvalid
}
doc, err := towerDocument(source, "etc/towerof"+string(kind)+".etc")
if err != nil {
return 0, err
}
rows := doc.Ints("each layer matching dungeon")
if len(rows) == 0 || len(rows)%3 != 0 {
return 0, ErrSettlementStateInvalid
}
for i := 0; i < len(rows); i += 3 {
if rows[i] == int64(floor) && rows[i+1] > 0 {
return rows[i+1], nil
}
}
return 0, fmt.Errorf("tower %s floor %d missing from PVF", kind, floor)
}
// Each card has an independent 10000-point probability range. Unallocated
// probability produces the native empty slot. The server-owned completion key
// freezes the draw across retries; committed values are stored with progress.
func GriefTowerRewards(source dnfpvf.Source, floor uint16, completionKey string) ([TowerRewardSlots]TowerReward, error) {
var out [TowerRewardSlots]TowerReward
if floor == 0 || floor > 100 || completionKey == "" {
return out, ErrSettlementStateInvalid
}
doc, err := towerDocument(source, "etc/towerofgrief.etc")
if err != nil {
return out, err
}
rows, ok := doc.Section("each layer dungeon clear reward")
if !ok || len(rows)%3 != 0 {
return out, ErrSettlementStateInvalid
}
name := ""
for i := 0; i < len(rows); i += 3 {
if rows[i].Kind != dnfpvf.TokenInt || rows[i+1].Kind != dnfpvf.TokenInt || rows[i].Int < 1 || rows[i+1].Int < rows[i].Int || rows[i+1].Int > 100 {
return out, ErrSettlementStateInvalid
}
if rows[i].Int <= int64(floor) && int64(floor) <= rows[i+1].Int {
if name != "" {
return out, ErrSettlementStateInvalid
}
name = rows[i+2].Value
}
}
if name == "" {
return out, fmt.Errorf("grief floor %d reward table missing", floor)
}
doc, err = towerDocument(source, "etc/itemdropinfo_togclearreward.etc")
if err != nil {
return out, err
}
var table []dnfpvf.Token
for _, section := range doc.Sections {
if section.Name != "table" || section.Start >= section.End {
continue
}
tokens := doc.Tokens[section.Start:section.End]
if tokens[0].Value == name {
table = tokens[1:]
break
}
}
if len(table) == 0 || len(table)%4 != 0 {
return out, fmt.Errorf("invalid grief table %s", name)
}
var rolls, totals [TowerRewardSlots]uint32
for slot := range rolls {
hash := sha256.Sum256([]byte(completionKey + "/grief/" + strconv.Itoa(slot)))
rolls[slot] = binary.LittleEndian.Uint32(hash[:4]) % 10000
}
for i := 0; i < len(table); i += 4 {
label := table[i].Value
if !strings.HasPrefix(label, "card ") {
return out, ErrSettlementStateInvalid
}
slot, err := strconv.Atoi(strings.TrimPrefix(label, "card "))
if err != nil || slot < 1 || slot > TowerRewardSlots {
return out, ErrSettlementStateInvalid
}
slot--
id, probability, count := table[i+1], table[i+2], table[i+3]
if id.Kind != dnfpvf.TokenInt || probability.Kind != dnfpvf.TokenInt || count.Kind != dnfpvf.TokenInt || id.Int <= 0 || id.Int >= math.MaxUint32 || count.Int <= 0 || count.Int > math.MaxUint32 || probability.Int < 0 || probability.Int > 10000 {
return out, ErrSettlementStateInvalid
}
low := totals[slot]
totals[slot] += uint32(probability.Int)
if totals[slot] > 10000 {
return out, ErrSettlementStateInvalid
}
if rolls[slot] >= low && rolls[slot] < totals[slot] {
out[slot] = TowerReward{uint32(id.Int), uint32(count.Int)}
}
}
return out, nil
}
@@ -0,0 +1,233 @@
package dungeon
import (
"context"
"errors"
"reflect"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/premium"
"longheng.io/server/internal/modules/dnf/progression"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// The tower clear, its ten frozen reward slots and the consumed entry pool all
// commit in one transaction, and a retry replays the same receipt.
func TestTowerSettlementProgressAndRewardsCommitAndReplayTogether(t *testing.T) {
ctx := context.Background()
tables, err := progression.Load(ctx, settlementTestSource())
if err != nil {
t.Fatal(err)
}
repos := dnfrepomemory.NewMemoryGroup()
character := dnfrepo.CharacterRecord{AccountID: "a", CharacterID: "19", Level: 1, Stats: map[string]int64{"exp": 0}}
for _, err := range []error{
repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "a"}),
repos.Character.Save(ctx, character),
repos.Skill.Save(ctx, dnfrepo.SkillRecord{CharacterID: "19", Points: dnfrepo.SkillPointState{SyncedLevel: 1}}),
repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{}}),
} {
if err != nil {
t.Fatal(err)
}
}
owner, _ := NewOwner(repos)
grantFailure := errors.New("inventory write failed")
fail := true
clear := &TowerClear{Kind: TowerGrief, Floor: 1, Grant: func(inv *dnfrepo.InventoryRecord, id, count uint32) error {
if inv.Slots == nil {
inv.Slots = make(map[string]dnfrepo.ItemStack)
}
stack := inv.Slots["0:121"]
stack.ItemID = int64(id)
stack.Count += int64(count)
inv.Slots["0:121"] = stack
if fail {
return grantFailure
}
return nil
}}
clear.Rewards[2] = TowerReward{10160931, 1}
cmd := SettlementCommand{AccountID: "a", CharacterID: "19", CompletionKey: "tower-1", Tables: tables, Tower: clear, UpdatedAt: time.Unix(1800000000, 0)}
if _, err := owner.CommitSettlement(ctx, cmd); !errors.Is(err, grantFailure) {
t.Fatal(err)
}
saved, _, _ := repos.Character.Load(ctx, "19")
inv, _, _ := repos.Inventory.Load(ctx, "19")
if TowerCharacterDailyRemaining(saved, TowerGrief, cmd.UpdatedAt) != 1 {
t.Fatal("failed settlement consumed the character entry")
}
if floor, _ := TowerFloor(saved, TowerGrief); floor != 1 || len(inv.Slots) != 0 {
t.Fatalf("failed transaction advanced state: %+v %+v", saved, inv)
}
fail = false
for attempt := 0; attempt < 2; attempt++ {
res, err := owner.CommitSettlement(ctx, cmd)
if err != nil {
t.Fatal(err)
}
floor, _ := TowerFloor(res.Character, TowerGrief)
other, _ := TowerFloor(res.Character, TowerDespair)
if floor != 2 || other != 1 || res.Replayed != (attempt == 1) || res.Inventory.Slots["0:121"].Count != 1 {
t.Fatalf("result=%+v floor=%d other=%d", res, floor, other)
}
account, _, _ := repos.Account.Load(ctx, "a")
used, err := TowerDailyUsed(account, TowerGrief, cmd.UpdatedAt)
if err != nil || used != 0 || TowerCharacterDailyRemaining(res.Character, TowerGrief, cmd.UpdatedAt) != 0 {
t.Fatal("first clear/replay did not consume exactly one character entry")
}
}
// A new owner/session sees the durable floor and frozen reward snapshot.
owner, _ = NewOwner(repos)
clear.Rewards[2].Count = 9
res, err := owner.CommitSettlement(ctx, cmd)
if err != nil {
t.Fatal(err)
}
rewards, err := TowerLastRewards(res.Character, TowerGrief)
if err != nil || rewards[2].Count != 1 || res.Inventory.Slots["0:121"].Count != 1 {
t.Fatalf("replayed reward changed: %+v %v", rewards, err)
}
cmd.CompletionKey = "despair-top"
cmd.Tower = &TowerClear{Kind: TowerDespair, Floor: 100}
res, err = owner.CommitSettlement(ctx, cmd)
if err != nil {
t.Fatal(err)
}
if floor, _ := TowerFloor(res.Character, TowerDespair); floor != 1 {
t.Fatal(floor)
}
if floor, _ := TowerFloor(res.Character, TowerGrief); floor != 2 {
t.Fatal(floor)
}
}
// Each tower has one character entry on top of the shared account pool, and the
// 06:00 service-day boundary resets them independently.
func TestTowerEntryPoolsConsumeCharacterBeforeAccountAndResetIndependently(t *testing.T) {
reset := time.Date(2026, 9, 15, 6, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
now := reset.Add(-time.Hour)
account := dnfrepo.AccountRecord{AccountID: "a"}
character := dnfrepo.CharacterRecord{CharacterID: "19", Stats: map[string]int64{
"tower_despair_last_clear_at": now.Unix(), "tower_grief_last_clear_at": now.Unix(),
}}
for _, row := range []struct {
kind TowerKind
extra int
}{{TowerDespair, 6}, {TowerGrief, 3}} {
for clear := 1; clear <= row.extra+1; clear++ {
if err := recordTowerEntryClear(&account, &character, row.kind, now); err != nil {
t.Fatal(err)
}
used, err := TowerDailyUsed(account, row.kind, now)
if err != nil || used != uint32(clear-1) || TowerCharacterDailyRemaining(character, row.kind, now) != 0 {
t.Fatalf("%s clear %d charged the wrong pool: shared=%d err=%v", row.kind, clear, used, err)
}
}
}
other := dnfrepo.CharacterRecord{CharacterID: "20"}
for _, kind := range []TowerKind{TowerDespair, TowerGrief} {
if TowerCharacterDailyRemaining(other, kind, now) != 1 || TowerCharacterDailyRemaining(character, kind, reset.Add(-time.Second)) != 0 || TowerCharacterDailyRemaining(character, kind, reset) != 1 {
t.Fatal("character isolation or 06:00 reset is incorrect")
}
}
if err := recordTowerEntryClear(&account, &character, TowerDespair, reset); err != nil {
t.Fatal(err)
}
if TowerCharacterDailyRemaining(character, TowerGrief, reset) != 1 || character.Stats["tower_despair_character_entry_day"] != premium.ServiceDayID(reset) {
t.Fatal("next-day despair clear consumed the grief entry")
}
}
func TestTowerDailyUsageRecordsAcceptedClearsAndResets(t *testing.T) {
now := time.Date(2026, 9, 13, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
account := dnfrepo.AccountRecord{AccountID: "a"}
if used, err := TowerDailyUsed(account, TowerDespair, now); err != nil || used != 0 {
t.Fatalf("new account usage=%d err=%v", used, err)
}
if err := RecordTowerDailyClear(&account, TowerDespair, now); err != nil {
t.Fatal(err)
}
if err := RecordTowerDailyClear(&account, TowerDespair, now); err != nil {
t.Fatal(err)
}
if err := RecordTowerDailyClear(&account, TowerDespair, now); err != nil {
t.Fatalf("third clear err=%v", err)
}
if used, err := TowerDailyUsed(account, TowerDespair, now.Add(24*time.Hour)); err != nil || used != 0 {
t.Fatalf("next-day usage=%d err=%v", used, err)
}
if err := RecordTowerDailyClear(&account, TowerGrief, now); err != nil {
t.Fatal(err)
}
if used, err := TowerDailyUsed(account, TowerDespair, now); err != nil || used != 3 {
t.Fatalf("despair usage changed by grief clear=%d err=%v", used, err)
}
}
func TestTowerDailyEnterableLimitReadsPVF(t *testing.T) {
source := settlementMemorySource{
"etc/towerofdespair.etc": "[account enterable max count]\n3\n[adventure group add enterable count]\n2 6 1 7 11 3 12 30 5 31 99 7\n[/adventure group add enterable count]",
"etc/towerofgrief.etc": "[account enterable max count]\n1\n[adventure group add enterable count]\n2 6 1 7 11 2 12 30 3 31 99 4\n[/adventure group add enterable count]",
}
for _, row := range []struct {
level int
despair, grief uint32
}{{1, 3, 1}, {2, 4, 2}, {6, 4, 2}, {7, 6, 3}, {8, 6, 3}, {11, 6, 3}, {12, 8, 4}, {30, 8, 4}, {31, 10, 5}, {99, 10, 5}} {
for i, kind := range []TowerKind{TowerDespair, TowerGrief} {
want := []uint32{row.despair, row.grief}[i]
if got, err := TowerDailyEnterableLimit(source, kind, row.level); err != nil || got != want {
t.Fatalf("kind=%s level=%d limit=%d want=%d err=%v", kind, row.level, got, want, err)
}
}
}
}
func TestTowerDailyUsageDayChangeClearsBothTowers(t *testing.T) {
now := time.Date(2026, 9, 13, 5, 59, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
account := dnfrepo.AccountRecord{AccountID: "a"}
if err := RecordTowerDailyClear(&account, TowerGrief, now); err != nil {
t.Fatal(err)
}
now = now.Add(time.Minute)
if err := RecordTowerDailyClear(&account, TowerDespair, now); err != nil {
t.Fatal(err)
}
if used, err := TowerDailyUsed(account, TowerGrief, now); err != nil || used != 0 {
t.Fatalf("new day restored yesterday's grief usage: used=%d err=%v", used, err)
}
}
// The grief draw is deterministic per reward slot from the server-owned
// completion key, so a retry cannot re-roll the ten cards.
func TestGriefTowerPVFUsesFloorRangesAndIndependentCardSlots(t *testing.T) {
source := settlementMemorySource{
"etc/towerofgrief.etc": "[each layer dungeon clear reward]\n1 49 `normal` 50 50 `special` 51 99 `normal` 100 100 `special`\n[/each layer dungeon clear reward]",
"etc/itemdropinfo_togclearreward.etc": "[cardslot specific reward table]\n[table]\n`normal` `card 3` 10160931 10000 1\n[/table]\n[table]\n`special` `card 10` 10160930 10000 1\n[/table]\n[/cardslot specific reward table]",
}
for _, floor := range []uint16{1, 49, 50, 51, 99, 100} {
rewards, err := GriefTowerRewards(source, floor, "server-owned-run")
if err != nil {
t.Fatal(err)
}
var want [TowerRewardSlots]TowerReward
if floor == 50 || floor == 100 {
want[9] = TowerReward{10160930, 1}
} else {
want[2] = TowerReward{10160931, 1}
}
if rewards != want {
t.Fatalf("floor %d: %+v", floor, rewards)
}
again, err := GriefTowerRewards(source, floor, "server-owned-run")
if err != nil || !reflect.DeepEqual(rewards, again) {
t.Fatal("unstable reward draw")
}
}
source["etc/itemdropinfo_togclearreward.etc"] = "[table]\n`normal` `card 3` 10160931 10001 1\n[/table]"
if _, err := GriefTowerRewards(source, 1, "run"); err == nil {
t.Fatal("accepted invalid probability")
}
}
@@ -0,0 +1,286 @@
package dungeon
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const tutorialRewardMarkerValue = int64(1)
var (
ErrTutorialRewardInvalid = errors.New("dungeon tutorial reward is invalid")
ErrTutorialInventoryFull = errors.New("dungeon tutorial reward inventory is full")
ErrTutorialAssetsMissing = errors.New("dungeon tutorial reward assets are unavailable")
)
type TutorialItemReward struct {
Progress uint32
ItemID uint32
Count uint32
Consumable bool
StackLimit int64
SlotStart int16
SlotEnd int16
ExpireAt time.Time
PVFPath string
StackableType string
}
type TutorialRewardCommand struct {
CharacterID string
Progress uint32
Rewards []TutorialItemReward
UpdatedAt time.Time
Project StackProjector
}
type TutorialRewardRow struct {
Slot uint16
ItemID uint32
Count uint32
}
type TutorialRewardResult struct {
Granted bool
Rows []TutorialRewardRow
}
func (o *Owner) GrantTutorialReward(ctx context.Context, cmd TutorialRewardCommand) (TutorialRewardResult, error) {
if o == nil || o.assets == nil || strings.TrimSpace(cmd.CharacterID) == "" ||
cmd.Progress == 0 || len(cmd.Rewards) == 0 {
return TutorialRewardResult{}, ErrTutorialAssetsMissing
}
ctx = contextOrBackground(ctx)
now := updatedAtOrNow(cmd.UpdatedAt)
marker := TutorialRewardMarker(cmd.Progress)
var result TutorialRewardResult
err := o.assets.WithinCharacterAssets(ctx, cmd.CharacterID, func(
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
) error {
character, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found {
return ErrTutorialAssetsMissing
}
character = dnfrepo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
if character.Stats[marker] == tutorialRewardMarkerValue {
return nil
}
inventory, found, err := inventories.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found {
inventory = dnfrepo.InventoryRecord{CharacterID: cmd.CharacterID}
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
rows := make([]TutorialRewardRow, 0, len(cmd.Rewards))
for _, reward := range cmd.Rewards {
if reward.Progress != cmd.Progress {
return fmt.Errorf(
"%w: requested_progress=%d row_progress=%d",
ErrTutorialRewardInvalid,
cmd.Progress,
reward.Progress,
)
}
slot, err := PlaceTutorialReward(&inventory, cmd.Progress, reward, cmd.Project)
if err != nil {
return err
}
rows = append(rows, TutorialRewardRow{
Slot: slot, ItemID: reward.ItemID, Count: reward.Count,
})
}
character.Stats[marker] = tutorialRewardMarkerValue
character.UpdatedAt = now
inventory.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
return err
}
result.Granted = true
result.Rows = rows
return nil
})
if err != nil {
return TutorialRewardResult{}, err
}
return result, nil
}
func TutorialRewardMarker(progress uint32) string {
return "tutorial_reward_progress_" + strconv.FormatUint(uint64(progress), 10)
}
func PlaceTutorialReward(
record *dnfrepo.InventoryRecord,
progress uint32,
reward TutorialItemReward,
project StackProjector,
) (uint16, error) {
if record == nil || reward.Progress != progress || reward.ItemID == 0 || reward.Count == 0 ||
!reward.Consumable || reward.SlotStart < 0 || reward.SlotEnd < reward.SlotStart {
return 0, ErrTutorialRewardInvalid
}
if reward.StackLimit > 0 && uint64(reward.Count) > uint64(reward.StackLimit) {
return 0, ErrTutorialRewardInvalid
}
if !reward.ExpireAt.IsZero() && project == nil {
return 0, ErrStackProjectorRequired
}
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
// PVF [stack limit] 的"每容器总量"口径本轮只接在邮件/商城/NPC 商店三条链路上;
// 教学奖励这条落点先保持旧行为,等它单独验收时再接。
newRowStackLimit := int64(0)
space := int64(math.MaxInt32)
canOpenRow := func() bool {
return space > 0 && dnfrepo.CanOpenContainerItemRow(
record.Slots,
dnfrepo.MainInventoryListType,
int64(reward.ItemID),
newRowStackLimit,
)
}
if slot, ok, err := stackTutorialRewardInRange(record, reward, project, 3, 8, space); err != nil {
return 0, err
} else if ok {
return uint16(slot), nil
}
// 【合并说明 2026-09-18,来自服务端仓库 fefab3b】他们给 firstEmptyMainSlot 加了
// listType 参数(教程奖励固定进主背包,这里显式传 MainInventoryListType);
// 我们这一侧的 canOpenRow()(PVF 开行闸门)原样保留。
// 末位 nil:教程奖励不在副本拾取那条链路上,没有弃置物格位保留。
if slot, ok := firstEmptyMainSlot(record, dnfrepo.MainInventoryListType, 3, 8, nil); ok && canOpenRow() {
return insertTutorialReward(record, slot, progress, reward, project)
}
if slot, ok, err := stackTutorialRewardInRange(
record,
reward,
project,
reward.SlotStart,
reward.SlotEnd,
space,
); err != nil {
return 0, err
} else if ok {
return uint16(slot), nil
}
// 同上:回退区间也显式用主背包列表,并保留我们的开行闸门。
if slot, ok := firstEmptyMainSlot(record, dnfrepo.MainInventoryListType, reward.SlotStart, reward.SlotEnd, nil); ok && canOpenRow() {
return insertTutorialReward(record, slot, progress, reward, project)
}
return 0, fmt.Errorf(
"%w: progress=%d item=%d quick=3..8 fallback=%d..%d",
ErrTutorialInventoryFull,
progress,
reward.ItemID,
reward.SlotStart,
reward.SlotEnd,
)
}
func stackTutorialRewardInRange(
record *dnfrepo.InventoryRecord,
reward TutorialItemReward,
project StackProjector,
start int16,
end int16,
space int64,
) (int16, bool, error) {
for slot := start; slot <= end; slot++ {
key := mainSlotKey(uint16(slot))
stack, ok := record.Slots[key]
if !ok || !canStackTutorialReward(stack, reward) {
continue
}
if int64(reward.Count) > space {
continue
}
if !reward.ExpireAt.IsZero() {
var err error
stack, err = project(stack, reward.ExpireAt)
if err != nil {
return 0, false, err
}
}
stack.Count += int64(reward.Count)
updateDungeonRewardRawAmount(&stack)
record.Slots[key] = stack
return slot, true, nil
}
return 0, false, nil
}
func canStackTutorialReward(stack dnfrepo.ItemStack, reward TutorialItemReward) bool {
if stack.ItemID != int64(reward.ItemID) || stack.Bind || stack.Count < 0 {
return false
}
if reward.ExpireAt.IsZero() && !stack.ExpireAt.IsZero() {
return false
}
amount := int64(reward.Count)
if stack.Count > math.MaxInt64-amount {
return false
}
return reward.StackLimit <= 0 || stack.Count <= reward.StackLimit-amount
}
func insertTutorialReward(
record *dnfrepo.InventoryRecord,
slot int16,
progress uint32,
reward TutorialItemReward,
project StackProjector,
) (uint16, error) {
if slot <= 0 {
return 0, ErrTutorialRewardInvalid
}
extra := map[string]string{
"source": "tutorial_pvf_reward",
"reward_progress": strconv.FormatUint(uint64(progress), 10),
"item_kind": "stackable",
"pvf_path": reward.PVFPath,
"stackable_type": reward.StackableType,
}
if reward.StackLimit > 0 {
extra["stack_limit"] = strconv.FormatInt(reward.StackLimit, 10)
}
stack := dnfrepo.ItemStack{
ItemID: int64(reward.ItemID),
Count: int64(reward.Count),
Extra: extra,
}
if !reward.ExpireAt.IsZero() {
var err error
stack, err = project(stack, reward.ExpireAt)
if err != nil {
return 0, err
}
}
record.Slots[mainSlotKey(uint16(slot))] = stack
return uint16(slot), nil
}
@@ -0,0 +1,111 @@
// 本文件定义 DNF 地下城表的内存视图和查询接口。
package dungeon
import "errors"
const DefaultList = "dungeon/dungeon.lst"
var (
ErrIndexRequired = errors.New("dnf dungeon index is required")
ErrListEmpty = errors.New("dnf dungeon list is empty")
ErrDocMissing = errors.New("dnf dungeon document is missing")
)
type Options struct {
ListPath string
}
type Dungeon struct {
ID int64 `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
Area string `json:"area,omitempty"`
Kind string `json:"kind,omitempty"`
MinLevel int64 `json:"min_level,omitempty"`
MaxLevel int64 `json:"max_level,omitempty"`
Fatigue int64 `json:"fatigue,omitempty"`
PartyMin int64 `json:"party_min,omitempty"`
PartyMax int64 `json:"party_max,omitempty"`
MapPaths []string `json:"map_paths,omitempty"`
MonsterIDs []int64 `json:"monster_ids,omitempty"`
MonsterPaths []string `json:"monster_paths,omitempty"`
BossIDs []int64 `json:"boss_ids,omitempty"`
BossPaths []string `json:"boss_paths,omitempty"`
RewardPath string `json:"reward_path,omitempty"`
Scalars map[string]float64 `json:"scalars,omitempty"`
// Abyss (深渊) seal door positioning from [seal door map index] / [seal door pos].
SealDoorMapIndex int64 `json:"seal_door_map_index,omitempty"`
SealDoorPosX int64 `json:"seal_door_pos_x,omitempty"`
SealDoorPosY int64 `json:"seal_door_pos_y,omitempty"`
}
type Table struct {
dungeons []Dungeon
byID map[int64]int
byPath map[string]int
}
type Snapshot struct {
Dungeons int `json:"dungeons"`
}
// Dungeons 返回地下城表副本,避免调用方改坏内存索引。
func (t *Table) Dungeons() []Dungeon {
if t == nil || len(t.dungeons) == 0 {
return nil
}
out := make([]Dungeon, len(t.dungeons))
for idx, dungeon := range t.dungeons {
out[idx] = cloneDungeon(dungeon)
}
return out
}
// Find 按 `.lst` 中的地下城 id 查询内存表。
func (t *Table) Find(id int64) (Dungeon, bool) {
if t == nil {
return Dungeon{}, false
}
idx, ok := t.byID[id]
if !ok {
return Dungeon{}, false
}
return cloneDungeon(t.dungeons[idx]), true
}
// FindPath 按 PVF 相对路径查询内存表。
func (t *Table) FindPath(dungeonPath string) (Dungeon, bool) {
if t == nil {
return Dungeon{}, false
}
idx, ok := t.byPath[pathKey(dungeonPath)]
if !ok {
return Dungeon{}, false
}
return cloneDungeon(t.dungeons[idx]), true
}
// Snapshot 返回地下城表当前规模,用于启动日志和 debug 面板。
func (t *Table) Snapshot() Snapshot {
if t == nil {
return Snapshot{}
}
return Snapshot{Dungeons: len(t.dungeons)}
}
// cloneDungeon 复制切片和 map,保证加载后的表只读。
func cloneDungeon(dungeon Dungeon) Dungeon {
dungeon.MapPaths = append([]string(nil), dungeon.MapPaths...)
dungeon.MonsterIDs = append([]int64(nil), dungeon.MonsterIDs...)
dungeon.MonsterPaths = append([]string(nil), dungeon.MonsterPaths...)
dungeon.BossIDs = append([]int64(nil), dungeon.BossIDs...)
dungeon.BossPaths = append([]string(nil), dungeon.BossPaths...)
if len(dungeon.Scalars) > 0 {
scalars := make(map[string]float64, len(dungeon.Scalars))
for key, value := range dungeon.Scalars {
scalars[key] = value
}
dungeon.Scalars = scalars
}
return dungeon
}